"""Reproduce every published asset of the refused-edit demo (public/examples/refused-edit).

An agent is asked to post two Q3 actuals and fill one variance formula. Its first request writes
one value to the wrong column and names a table column that does not exist. The request declares
three invariants (scope, no new errors, ledger reconcile). This script:

1. authors the synthetic fixture q3-close.xlsx (fixture.py, then `wolf recalc`);
2. runs the faulty request with `wolf transact` and checks exit 7, `invariant-violated`, no
   output file, and an unchanged input digest;
3. runs the repaired request (same invariants) and checks exit 0 and a committed output;
4. applies the faulty request with openpyxl (openpyxl_edit.py), recalculates the saved copy in
   LibreOffice, and records what the copy holds;
5. renders both resulting workbooks with LibreOffice, draws the 1200x675 share card (card.py),
   records the terminal clip with VHS (refused-edit.tape), and writes results.json and SHA256SUMS.

Run from the site root (engines/wolfxl/www):

    WOLF_BIN=/path/to/wolf uv run --no-project --python 3.13 \\
        --with openpyxl==3.1.5 --with pillow==12.3.0 \\
        python scripts/examples/refused-edit/run_demo.py

The scripts also run from any folder holding run_demo.py, fixture.py, card.py, openpyxl_edit.py,
verdicts.jq, and refused-edit.tape; set DEMO_OUT to the output folder and CARD_FONT to a TrueType
or WOFF2 font with weight and width axes (the site uses Archivo).

Environment: WOLF_BIN (required), SOFFICE (LibreOffice binary; default the macOS app path),
PDFTOPPM, VHS, JQ, FFPROBE (default: found on PATH), DEMO_WORK (scratch directory, default
/tmp/wolf-demos/refused-edit/work), DEMO_OUT (default public/examples/refused-edit under the site
root), CARD_FONT. `--skip-clip` keeps the published clip and its results entry.
"""

import argparse
import datetime as dt
import getpass
import hashlib
import importlib.metadata
import json
import os
import platform
import re
import shutil
import socket
import subprocess
import sys
from pathlib import Path

from openpyxl import load_workbook
from PIL import Image, ImageChops

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))

import card  # noqa: E402
import fixture  # noqa: E402

PUBLIC = Path(os.environ.get("DEMO_OUT") or HERE.parents[2] / "public" / "examples" / "refused-edit")
# Published next to the assets so the folder alone reproduces them.
SCRIPTS = ("run_demo.py", "fixture.py", "card.py", "openpyxl_edit.py", "verdicts.jq", "refused-edit.tape")
SCRATCH_MARKER = ".refused-edit-scratch"
CLOCK = "2026-10-01T00:00:00Z"
RENDER_DPI = 192
FIXTURE = "q3-close.xlsx"
CLIP_FILES = ("refused-edit.gif", "refused-edit.mp4", "refused-edit-poster.png")

TASK = "Post the final Q3 actuals: Europe 6,240 and APAC 4,410. Then fill the Summary line Variance to ledger."

INVARIANTS = [
    {
        "id": "scope",
        "kind": "only-changes",
        "addresses": [
            "table:Regions/row:Europe/col:Q3",
            "table:Regions/row:APAC/col:Q3",
            "sheet:Regions/cell:C8",
            "sheet:Summary/cell:B3",
            "sheet:Summary/cell:B5",
            "sheet:Summary/cell:B7",
        ],
    },
    {"id": "no-errors", "kind": "no-new-errors"},
    {
        "id": "ledger",
        "kind": "reconcile",
        "left": "sheet:Regions/cell:C10",
        "right": "table:Regions/col:Q3",
        "aggregate": "sum",
        "tolerance": 0.005,
    },
]

BAD_OPS = [
    {"op": "set", "target": "table:Regions/row:Europe/col:Q3", "value": {"number": 6240}},
    {"op": "set", "target": "table:Regions/row:APAC/col:Q2", "value": {"number": 4410}},
    {"op": "set", "target": "sheet:Summary/cell:B7", "value": {"formula": "=SUM(Regions[Q3 Actual])-Regions!C10"}},
]

FIXED_OPS = [
    {"op": "set", "target": "table:Regions/row:Europe/col:Q3", "value": {"number": 6240}},
    {"op": "set", "target": "table:Regions/row:APAC/col:Q3", "value": {"number": 4410}},
    {"op": "set", "target": "sheet:Summary/cell:B7", "value": {"formula": "=SUM(Regions[Q3])-Regions!C10"}},
]

# The cells the page and card report, by sheet and A1 reference.
REPORTED = {
    "regions_q2_total": ("Regions", "B8"),
    "regions_q3_total": ("Regions", "C8"),
    "ledger_control": ("Regions", "C10"),
    "summary_q3_revenue": ("Summary", "B3"),
    "summary_q2_revenue": ("Summary", "B4"),
    "summary_growth": ("Summary", "B5"),
    "summary_variance": ("Summary", "B7"),
}


def sha(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def run(argv: list[str], cwd: Path, env: dict[str, str], check: bool = True) -> subprocess.CompletedProcess:
    proc = subprocess.run(argv, cwd=cwd, env=env, capture_output=True, text=True)
    if check and proc.returncode != 0:
        raise SystemExit(f"{argv[0]} failed with exit {proc.returncode}:\n{proc.stdout}\n{proc.stderr}")
    return proc


def request(request_id: str, ops: list[dict]) -> dict:
    return {
        "contract_version": "v1",
        "document_kind": "request",
        "request_id": request_id,
        "verb": "transact",
        "inputs": [{"path": FIXTURE, "format": "xlsx", "sha256": None}],
        "output_dir": "out",
        "options": {"ops": ops, "invariants": INVARIANTS},
        "determinism": {"clock": {"instant": CLOCK, "timezone": "UTC"}, "random_seed": 0},
    }


def write_json(path: Path, data: object) -> None:
    path.write_text(json.dumps(data, indent=2) + "\n")


def fresh_dir(path: Path) -> Path:
    """Recreate a scratch directory this script owns (it carries the scratch marker)."""
    if path.exists():
        if not (path / SCRATCH_MARKER).is_file():
            raise SystemExit(f"refusing to clear {path}: it lacks the {SCRATCH_MARKER} marker")
        shutil.rmtree(path)
    path.mkdir(parents=True)
    (path / SCRATCH_MARKER).touch()
    return path


def verdicts(receipt: dict) -> list[dict]:
    keep = ("id", "kind", "verdict", "implicit", "detail", "violations")
    return [{k: v[k] for k in keep if k in v} for v in receipt["transaction"]["invariants"]]


def diff_changes(receipt: dict) -> list[dict]:
    keep = ("address", "change", "property", "before", "after", "cause")
    return [{k: c[k] for k in keep if k in c} for c in receipt["transaction"]["diff"]["changes"]]


def view_values(wolf_env: dict[str, str], workbook: Path, outdir: Path) -> tuple[dict, dict]:
    """Observe a workbook with wolf; return the reported cells and the positional sheet names."""
    run(["wolf", "observe", workbook.name, "--outdir", str(outdir)], workbook.parent, wolf_env)
    view = json.loads(next(outdir.glob("*.view.json")).read_text())
    nodes = view["nodes"]
    sheets = {n["id"]: n["label"] for n in nodes if n["kind"] == "sheet"}
    cells = {}
    for n in nodes:
        if n["kind"] == "cell" and "a1" in n:
            sheet_id = n["id"].split("/")[0]
            if sheet_id in sheets:
                cells[(sheets[sheet_id], n["a1"])] = n
    values = {}
    for key, (sheet, ref) in REPORTED.items():
        node = cells.get((sheet, ref))
        values[key] = None if node is None else node.get("value")
    return values, sheets


def libreoffice_values(path: Path) -> dict:
    wb = load_workbook(path, data_only=True)
    return {key: wb[sheet][ref].value for key, (sheet, ref) in REPORTED.items()}


def formula_cache(path: Path) -> dict:
    """Count formula cells and those saved without a cached value."""
    formulas = load_workbook(path)
    values = load_workbook(path, data_only=True)
    cells = [
        (ws.title, c.coordinate)
        for ws in formulas.worksheets
        for row in ws.iter_rows()
        for c in row
        if isinstance(c.value, str) and c.value.startswith("=")
    ]
    missing = [f"{s}!{ref}" for s, ref in cells if values[s][ref].value is None]
    return {"formula_cells": len(cells), "without_cached_value": missing}


def autocrop(page: Image.Image, pad: int = 24) -> Image.Image:
    rgb = page.convert("RGB")
    box = ImageChops.difference(rgb, Image.new("RGB", rgb.size, "white")).getbbox()
    if box is None:
        raise SystemExit("rendered page is blank")
    left, top, right, bottom = box
    return rgb.crop((max(left - pad, 0), max(top - pad, 0), min(right + pad, rgb.width), min(bottom + pad, rgb.height)))


def render_sheets(pdf: Path, out_png: Path, pdftoppm: str, env: dict[str, str]) -> dict:
    """Rasterize both sheet pages of a LibreOffice PDF and stack them into one image."""
    prefix = pdf.with_suffix("")
    run([pdftoppm, "-r", str(RENDER_DPI), "-png", pdf.name, prefix.name], pdf.parent, env)
    pages = sorted(pdf.parent.glob(f"{prefix.name}-*.png"))
    if len(pages) != 2:
        raise SystemExit(f"expected two sheet pages in {pdf.name}, found {len(pages)}")
    crops = [autocrop(Image.open(p)) for p in pages]
    gap = 24
    width = max(c.width for c in crops)
    height = sum(c.height for c in crops) + gap
    sheet = Image.new("RGB", (width, height), "white")
    y = 0
    for crop in crops:
        sheet.paste(crop, (0, y))
        y += crop.height + gap
    sheet.save(out_png, optimize=True)
    return {"file": out_png.name, "width": width, "height": height, "pages": len(pages)}


def soffice_profile(work: Path) -> Path:
    """An isolated LibreOffice profile that recalculates every formula when a file loads."""
    profile = work / "lo-profile"
    (profile / "user").mkdir(parents=True)
    (profile / "user" / "registrymodifications.xcu").write_text(
        '<?xml version="1.0" encoding="UTF-8"?>\n'
        '<oor:items xmlns:oor="http://openoffice.org/2001/registry" '
        'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
        'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n'
        '<item oor:path="/org.openoffice.Office.Calc/Formula/Load">'
        '<prop oor:name="OOXMLRecalcMode" oor:op="fuse"><value>0</value></prop></item>\n'
        "</oor:items>\n"
    )
    return profile


def soffice_convert(soffice: str, profile: Path, src: Path, to: str, outdir: Path, env: dict[str, str]) -> Path:
    outdir.mkdir(parents=True, exist_ok=True)
    run(
        [soffice, f"-env:UserInstallation={profile.as_uri()}", "--headless", "--convert-to", to, "--outdir", str(outdir), str(src)],
        outdir,
        env,
    )
    produced = outdir / f"{src.stem}.{to}"
    if not produced.is_file():
        raise SystemExit(f"LibreOffice produced no {produced.name}")
    return produced


def transact(name: str, run_dir: Path, wolf_env: dict[str, str]) -> tuple[int, dict]:
    proc = run(["wolf", "transact", "--request", f"{name}-request.json"], run_dir, wolf_env, check=False)
    (run_dir / f"{name}-receipt.json").write_text(proc.stdout)
    return proc.returncode, json.loads(proc.stdout)


def record_clip(work: Path, run_dir: Path, wolf_env: dict[str, str], tools: dict[str, str], expected: dict) -> dict:
    clip = fresh_dir(work / "clip")
    (clip / "bin").mkdir()
    (clip / "bin" / "wolf").symlink_to(Path(os.environ["WOLF_BIN"]).resolve())
    for name in (FIXTURE, "bad-request.json", "fixed-request.json"):
        shutil.copy(run_dir / name, clip / name)
    shutil.copy(HERE / "verdicts.jq", clip / "verdicts.jq")
    shutil.copy(HERE / "refused-edit.tape", clip / "refused-edit.tape")
    env = dict(wolf_env)
    env["PATH"] = os.pathsep.join([str(Path(tools["jq"]).parent), env["PATH"]])
    run([tools["vhs"], "refused-edit.tape"], clip, env)
    bad = json.loads((clip / "bad-receipt.json").read_text())
    fixed = json.loads((clip / "fixed-receipt.json").read_text())
    output = clip / "out" / FIXTURE
    checks = {
        "bad_exit_code": bad["exit"]["code"],
        "bad_committed": bad["transaction"]["committed"],
        "fixed_exit_code": fixed["exit"]["code"],
        "fixed_committed": fixed["transaction"]["committed"],
        "input_sha256_after": sha(clip / FIXTURE),
        "output_sha256": sha(output) if output.is_file() else None,
    }
    for key, value in checks.items():
        if expected[key] != value:
            raise SystemExit(f"clip run differs from the scripted run: {key} {value!r} != {expected[key]!r}")
    for name in CLIP_FILES:
        shutil.copy(clip / name, PUBLIC / name)
    probe = run(
        [tools["ffprobe"], "-v", "error", "-show_entries", "format=duration:stream=width,height", "-of", "json", "refused-edit.mp4"],
        clip,
        env,
    )
    info = json.loads(probe.stdout)
    stream = info["streams"][0]
    return {
        "tape": "scripts/examples/refused-edit/refused-edit.tape",
        "gif": "refused-edit.gif",
        "gif_bytes": (PUBLIC / "refused-edit.gif").stat().st_size,
        "mp4": "refused-edit.mp4",
        "mp4_bytes": (PUBLIC / "refused-edit.mp4").stat().st_size,
        "poster": "refused-edit-poster.png",
        "width": stream["width"],
        "height": stream["height"],
        "duration_seconds": round(float(info["format"]["duration"]), 1),
        "run_matches_script": checks,
    }


def tool_version(argv: list[str], env: dict[str, str]) -> str:
    proc = run(argv, Path.cwd(), env)
    return (proc.stdout or proc.stderr).strip().splitlines()[0]


def privacy_check(paths: list[Path]) -> None:
    needles = {str(Path.home()), f"/Users/{getpass.getuser()}", socket.gethostname()}
    for path in paths:
        if path.suffix in {".json", ".py", ".jq", ".tape", ".txt"} or path.name == "SHA256SUMS":
            text = path.read_text()
            for needle in needles:
                if needle and needle in text:
                    raise SystemExit(f"{path.name} contains a private path or host name")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--skip-clip", action="store_true", help="keep the published clip and its results entry")
    args = parser.parse_args()

    wolf_bin = os.environ.get("WOLF_BIN")
    if not wolf_bin or not Path(wolf_bin).is_file():
        raise SystemExit("set WOLF_BIN to the wolf binary")
    tools = {
        "soffice": os.environ.get("SOFFICE", "/Applications/LibreOffice.app/Contents/MacOS/soffice"),
        "pdftoppm": os.environ.get("PDFTOPPM") or shutil.which("pdftoppm"),
        "vhs": os.environ.get("VHS") or shutil.which("vhs"),
        "jq": os.environ.get("JQ") or shutil.which("jq"),
        "ffprobe": os.environ.get("FFPROBE") or shutil.which("ffprobe"),
    }
    needed = ["soffice", "pdftoppm"] + ([] if args.skip_clip else ["vhs", "jq", "ffprobe"])
    for name in needed:
        if not tools[name]:
            raise SystemExit(f"{name} not found; set {name.upper()}")

    work = fresh_dir(Path(os.environ.get("DEMO_WORK", "/tmp/wolf-demos/refused-edit/work")))
    (work / "bin").mkdir()
    (work / "bin" / "wolf").symlink_to(Path(wolf_bin).resolve())
    # wolf runs as `wolf` from PATH, so receipts record a neutral argv and relative paths.
    wolf_env = dict(os.environ)
    wolf_env["PATH"] = os.pathsep.join([str(work / "bin"), os.environ.get("PATH", "")])

    wolf_version = tool_version(["wolf", "--version"], wolf_env)
    commit = re.search(r"commit ([0-9a-f]{40})", wolf_version)

    # 1. Fixture.
    author_dir = work / "author"
    author_dir.mkdir()
    fixture.author(author_dir / FIXTURE)
    run(["wolf", "recalc", FIXTURE, "--outdir", "recalc", "--clock", CLOCK, "--seed", "0"], author_dir, wolf_env)
    run_dir = work / "run"
    run_dir.mkdir()
    shutil.copy(author_dir / "recalc" / FIXTURE, run_dir / FIXTURE)
    base_sha = sha(run_dir / FIXTURE)
    base_values, sheet_names = view_values(wolf_env, run_dir / FIXTURE, work / "observe-base")

    for name, rid, ops in (("bad", "post-q3-actuals", BAD_OPS), ("fixed", "post-q3-actuals-repaired", FIXED_OPS)):
        req = request(rid, ops)
        req["inputs"][0]["sha256"] = base_sha
        write_json(run_dir / f"{name}-request.json", req)

    # 2. The faulty request.
    bad_exit, bad = transact("bad", run_dir, wolf_env)
    bad_after = sha(run_dir / FIXTURE)
    out_dir = run_dir / "out"
    bad_output_dir_exists = out_dir.exists()
    assert bad_exit == 7, bad_exit
    assert bad["exit"] == {"code": 7, "category": "invariant"}, bad["exit"]
    assert bad["transaction"]["committed"] is False
    assert bad["outputs"] == []
    assert not out_dir.exists()
    assert bad_after == base_sha
    bad_errors = sorted({e["code"] for e in bad["errors"]})
    assert bad_errors == ["invariant-violated"], bad_errors

    # 3. The repaired request, with the same invariants.
    fixed_exit, fixed = transact("fixed", run_dir, wolf_env)
    output = out_dir / FIXTURE
    assert fixed_exit == 0, fixed_exit
    assert fixed["transaction"]["committed"] is True
    assert all(v["verdict"] == "passed" for v in fixed["transaction"]["invariants"])
    assert output.is_file()
    assert sha(output) == fixed["transaction"]["result_sha256"] == fixed["outputs"][0]["sha256"]
    fixed_after = sha(run_dir / FIXTURE)
    assert fixed_after == base_sha
    fixed_values, _ = view_values(wolf_env, output, work / "observe-fixed")

    # 4. The faulty request through openpyxl, then LibreOffice recalculation of the saved copy.
    openpyxl_copy = run_dir / "q3-close-openpyxl.xlsx"
    edit = run([sys.executable, str(HERE / "openpyxl_edit.py"), FIXTURE, openpyxl_copy.name], run_dir, dict(os.environ), check=False)
    assert edit.returncode == 0, edit.stderr
    warnings = [line for line in edit.stderr.splitlines() if line.startswith("warning:")]
    profile = soffice_profile(work)
    lo_env = dict(os.environ)
    lo_xlsx = soffice_convert(tools["soffice"], profile, openpyxl_copy, "xlsx", work / "lo" / "openpyxl", lo_env)
    lo_values = libreoffice_values(lo_xlsx)
    lo_formula = load_workbook(lo_xlsx)["Summary"]["B7"].value

    # 5. Renders: the committed Wolf output and the openpyxl copy, both laid out by LibreOffice.
    # The published directory holds only this script's outputs; --skip-clip keeps the clip files.
    PUBLIC.mkdir(parents=True, exist_ok=True)
    kept = {*CLIP_FILES, "results.json"} if args.skip_clip else set()
    for stale in PUBLIC.iterdir():
        if stale.is_file() and stale.name not in kept:
            stale.unlink()
    renders = {}
    for key, src, png in (
        ("wolf", output, "sheets-wolf.png"),
        ("openpyxl", openpyxl_copy, "sheets-openpyxl.png"),
    ):
        pdf = soffice_convert(tools["soffice"], profile, src, "pdf", work / "lo" / f"{key}-pdf", lo_env)
        renders[key] = render_sheets(pdf, PUBLIC / png, tools["pdftoppm"], lo_env)

    def check(v: dict) -> dict:
        out = {k: v[k] for k in ("id", "kind", "verdict") if k in v}
        for k in ("detail", "violations"):
            if k in v:
                out[k] = v[k]
        return out

    results = {
        "demo": "refused-edit",
        "generated_by": "scripts/examples/refused-edit/run_demo.py",
        "generated_at": dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
        "environment": {
            "wolf_version": wolf_version,
            "wolf_commit": commit.group(1) if commit else None,
            "wolf_binary_sha256": sha(Path(wolf_bin).resolve()),
            "host": f"{platform.system()} {platform.machine()}",
            "python": platform.python_version(),
            "openpyxl": importlib.metadata.version("openpyxl"),
            "pillow": importlib.metadata.version("pillow"),
            "libreoffice": tool_version([tools["soffice"], f"-env:UserInstallation={profile.as_uri()}", "--version"], lo_env),
            "pdftoppm": tool_version([tools["pdftoppm"], "-v"], lo_env),
        },
        "fixture": {
            "file": FIXTURE,
            "sha256": base_sha,
            "bytes": (run_dir / FIXTURE).stat().st_size,
            "authored_by": "scripts/examples/refused-edit/fixture.py, then wolf recalc with a fixed clock",
            "regions": [
                {"region": r, "q2": q2, "q3": q3} for r, q2, q3 in fixture.REGIONS
            ],
            "values": base_values,
            "sheet_ids": sheet_names,
        },
        "task": TASK,
        "invariants": INVARIANTS,
        "bad": {
            "request": "bad-request.json",
            "receipt": "bad-receipt.json",
            "ops": BAD_OPS,
            "argv": bad["invocation"]["argv"],
            "exit_code": bad_exit,
            "status": bad["status"],
            "error_codes": bad_errors,
            "errors": [e["message"] for e in bad["errors"]],
            "committed": bad["transaction"]["committed"],
            "outputs": bad["outputs"],
            "output_dir_exists": bad_output_dir_exists,
            "input_sha256_before": base_sha,
            "input_sha256_after": bad_after,
            "input_unchanged": bad_after == base_sha,
            "unwritten_result_sha256": bad["transaction"]["result_sha256"],
            "verdicts": [check(v) for v in verdicts(bad)],
            "failed": [v["id"] for v in verdicts(bad) if v["verdict"] == "failed"],
            "diff_counts": bad["transaction"]["diff"]["counts"],
            "diff": diff_changes(bad),
        },
        "fixed": {
            "request": "fixed-request.json",
            "receipt": "fixed-receipt.json",
            "ops": FIXED_OPS,
            "argv": fixed["invocation"]["argv"],
            "exit_code": fixed_exit,
            "status": fixed["status"],
            "committed": fixed["transaction"]["committed"],
            "output": "q3-close-wolf.xlsx",
            "output_sha256": sha(output),
            "input_sha256_after": fixed_after,
            "input_unchanged": fixed_after == base_sha,
            "verdicts": [check(v) for v in verdicts(fixed)],
            "parts_written": fixed["transaction"]["parts_written"],
            "parts_preserved": fixed["transaction"]["parts_preserved"],
            "diff_counts": fixed["transaction"]["diff"]["counts"],
            "diff": diff_changes(fixed),
            "values": fixed_values,
        },
        "openpyxl": {
            "script": "openpyxl_edit.py",
            "exit_code": edit.returncode,
            "warnings": warnings,
            "output": openpyxl_copy.name,
            "output_sha256": sha(openpyxl_copy),
            "cached_values": formula_cache(openpyxl_copy),
            "recalculated_by": "LibreOffice, isolated profile, recalculate on load",
            "values_after_recalc": lo_values,
            "variance_formula_after_recalc": lo_formula,
        },
        "renders": {**renders, "renderer": f"LibreOffice PDF export, pdftoppm at {RENDER_DPI} dpi"},
    }

    # Published files (the clip is added below).
    shutil.copy(run_dir / FIXTURE, PUBLIC / FIXTURE)
    for name in ("bad-request.json", "fixed-request.json", "bad-receipt.json", "fixed-receipt.json"):
        shutil.copy(run_dir / name, PUBLIC / name)
    shutil.copy(output, PUBLIC / "q3-close-wolf.xlsx")
    shutil.copy(openpyxl_copy, PUBLIC / openpyxl_copy.name)
    for name in SCRIPTS:
        shutil.copy(HERE / name, PUBLIC / name)

    expected_clip = {
        "bad_exit_code": 7,
        "bad_committed": False,
        "fixed_exit_code": 0,
        "fixed_committed": True,
        "input_sha256_after": base_sha,
        "output_sha256": sha(output),
    }
    if args.skip_clip:
        previous = json.loads((PUBLIC / "results.json").read_text())
        results["clip"] = previous["clip"]
        for name in CLIP_FILES:
            if not (PUBLIC / name).is_file():
                raise SystemExit(f"--skip-clip needs the published {name}")
    else:
        tools_env = {"jq": tools["jq"], "vhs": tools["vhs"], "ffprobe": tools["ffprobe"]}
        results["clip"] = record_clip(work, run_dir, wolf_env, tools_env, expected_clip)
        results["environment"]["vhs"] = tool_version([tools["vhs"], "--version"], wolf_env)
        results["environment"]["jq"] = tool_version([tools["jq"], "--version"], wolf_env)

    card.draw(results, PUBLIC / "card.png")
    results["card"] = {"file": "card.png", "width": 1200, "height": 675}
    write_json(PUBLIC / "results.json", results)

    published = sorted(p for p in PUBLIC.iterdir() if p.is_file() and p.name != "SHA256SUMS")
    (PUBLIC / "SHA256SUMS").write_text("".join(f"{sha(p)}  {p.name}\n" for p in published))
    privacy_check(published + [PUBLIC / "SHA256SUMS"])
    print(f"published {len(published) + 1} files to {PUBLIC}")


if __name__ == "__main__":
    main()
