#!/usr/bin/env python3
"""checks.py — the one engine behind every discover gate.

Usage:
  checks.py lint-record <record.md>        one surface record (the PostToolUse hook calls this)
  checks.py lint-summary <_summary.md>      one lane summary
  checks.py walk <run-dir>                  capture log, saved states, index
  checks.py model <run-dir>                 records, citations, quotes, census
  checks.py all <run-dir> [--log-defects]   walk + model + every summary; --log-defects appends fired rules to defects.jsonl
  checks.py coverage <run-dir> [--write]    every coverage number, computed from files; --write sets the block in 05-review.md
  checks.py questions <run-dir> --seed S [--n 30]   a sealed critic question set
  checks.py critic-diff <run-dir>           questions, the run's claims and the critic's answers, side by side
  checks.py find <run-dir> "term" ...       the saved states (dump or capture-log row) that carry each term, page chrome removed
  checks.py view <run-dir> <state> [--vs <state>] [--max N]   a dump without page chrome; --vs prints only what the second state adds or drops
  checks.py rules                           the rule registry

Exit codes: 0 no error-severity finding, 1 at least one error, 2 bad usage or unreadable input.
Standard library only. Every finding cites its rule id; every firing is appended to .discover/telemetry.jsonl.
"""
import json, os, re, sys, random, hashlib, datetime

HERE = os.path.dirname(os.path.abspath(__file__))
VOCAB = json.load(open(os.path.join(HERE, "..", "data", "vocab.json")))

# id: (failure class, severity, what it enforces)
RULES = {
    "record.frontmatter":   ("K13", "error", "a record carries every required frontmatter key with a vocabulary value"),
    "record.states":        ("K13", "error", "every state a record cites has its image and its dump on disk"),
    "record.walked-empty":  ("K15", "error", "a walked record cites at least one saved state"),
    "record.tab-unrecorded":("K15", "error", "every tab in a walked record's dumps appears in the record"),
    "record.popup-unprobed":("K15", "warn",  "every named popup control in a walked record's dumps has a Probes row or n/a"),
    "record.quote-unanchored":("K13","warn", "a quoted string in Carries or Result appears in the cited state's dump"),
    "record.absence-unseen":("K1",  "error", "an Absences row rests on a saved state of a walked record"),
    "record.blocked-reason":("K15", "error", "a blocked or partial record names its condition"),
    "record.control-unstated":("K6", "warn", "every labelled control in a cited dump that is not page chrome appears in the record"),
    "walk.log-missing":     ("K13", "error", "evidence/capture-log.md exists once capture started"),
    "walk.state-files":     ("K13", "error", "every capture-log state has its image and dump"),
    "walk.orphan-image":    ("K13", "warn",  "every saved image has a capture-log row"),
    "walk.index-status":    ("K15", "error", "every index row has a valid status; blocked carries a condition"),
    "walk.index-record":    ("K13", "error", "every index row that links a record links a file that exists"),
    "walk.unwalked":        ("K15", "warn",  "no index row is left unwalked at close"),
    "model.cite-missing":   ("K13", "error", "every model citation resolves to a record or evidence file"),
    "model.uncited-row":    ("K13", "warn",  "every model table row carries a citation"),
    "census.counts":        ("K13", "error", "census frontmatter counts equal a recount of its rows"),
    "census.verdict":       ("K13", "error", "every census row has one verdict from the vocabulary"),
    "census.overreach":     ("K2",  "error", "ABSENT and CONTRADICTS rest on seen or walked evidence, never an affordance, docs or source"),
    "census.absent-cite":   ("K1",  "error", "ABSENT cites a record with an Absences row"),
    "census.universal":     ("K2",  "error", "a reason with every/all/no/never/anywhere names its denominator"),
    "census.nc-unsearched": ("K6",  "error", "a NOT CAPTURED row ends its reason with searched: \"term\" ..., the product's words a deciding state would carry"),
    "census.nc-decidable":  ("K6",  "error", "a searched term of a NOT CAPTURED row appears in a saved state the row does not cite"),
    "summary.frontmatter":  ("K9",  "error", "a lane summary carries lane, run_id, status, method, confidence, sources, gaps"),
    "summary.method":       ("K9",  "error", "method is in the closed vocabulary"),
    "summary.band":         ("K9",  "error", "confidence does not exceed the band of its method"),
    "summary.raw-empty":    ("K5",  "error", "a complete lane has at least one verbatim raw/ file"),
    "summary.run-id":       ("K6",  "error", "a lane summary carries this run's run_id"),
    "summary.absent-control":("K1", "error", "a lane recorded absent names its positive control"),
}

FINDINGS = []

def find(rule, path, msg, line=None):
    cls, sev, _ = RULES[rule]
    FINDINGS.append({"rule": rule, "class": cls, "severity": sev, "file": path, "line": line, "msg": msg})

# ---------- parsing helpers ----------

def read(path):
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            return f.read()
    except OSError:
        return None

def frontmatter(text):
    """Flat YAML subset: key: value, inline [lists], quoted strings. Nested blocks are kept as raw text."""
    if not text or not text.startswith("---"):
        return {}, text or ""
    end = text.find("\n---", 3)
    if end < 0:
        return {}, text
    fm, body = text[3:end], text[end + 4:]
    out, key = {}, None
    for raw in fm.splitlines():
        if not raw.strip() or raw.lstrip().startswith("#"):
            continue
        if raw[0] in " \t-" and key:
            out[key] = (out[key] + "\n" + raw) if isinstance(out[key], str) else out[key]
            continue
        m = re.match(r"([A-Za-z_][\w-]*):\s*(.*)$", raw)
        if not m:
            continue
        key, val = m.group(1), re.sub(r"\s+#.*$", "", m.group(2)).strip()
        if val.startswith("[") and val.endswith("]"):
            out[key] = [v.strip().strip("'\"") for v in val[1:-1].split(",") if v.strip()]
        else:
            out[key] = val.strip("'\"")
    return out, body

def tables(body):
    """{section heading: [rows as dicts]} for every markdown table under a ## heading."""
    out, section, header = {}, "", None
    for line in body.splitlines():
        h = re.match(r"^#{2,3}\s+(.*)", line)
        if h:
            section, header = h.group(1).strip().lower(), None
            continue
        if line.strip().startswith("|"):
            cells = [c.strip() for c in line.strip().strip("|").split("|")]
            if header is None:
                header = [c.lower() for c in cells]
            elif all(re.fullmatch(r":?-{2,}:?", c or "--") for c in cells):
                continue
            else:
                out.setdefault(section, []).append(dict(zip(header, cells)))
        else:
            header = None if not line.strip() else header
    return out

def norm(s):
    return re.sub(r"\s+", " ", s.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"')).strip().lower()

QUOTE = re.compile(r"[\"“]([^\"”]{3,80})[\"”]")
CITE = re.compile(r"\((\d{2,3}-[a-z0-9-]+|evidence/[^\s·)]+)(?:#[\w-]+)?\s*·\s*([\w:+-]+)[^)]*\)")
UNIVERSAL = re.compile(r"\b(every|all|no|never|anywhere|nowhere|none)\b", re.I)
DENOM = re.compile(r"\b(of|across|on|in)\s+(the\s+)?(\d+|all\s+\d+)\b|\b\d+\s+(walked|recorded|captured)\b", re.I)

def run_paths(run):
    return {
        "scope": os.path.join(run, "00-scope.md"),
        "log": os.path.join(run, "evidence", "capture-log.md"),
        "screens": os.path.join(run, "evidence", "screens"),
        "a11y": os.path.join(run, "evidence", "a11y"),
        "index": os.path.join(run, "model", "surfaces", "_index.md"),
        "surfaces": os.path.join(run, "model", "surfaces"),
        "model": os.path.join(run, "model"),
        "census": os.path.join(run, "model", "census.md"),
        "review": os.path.join(run, "05-review.md"),
        "defects": os.path.join(run, "defects.jsonl"),
    }

def run_dir_of(path):
    p = os.path.abspath(path)
    while p != os.path.dirname(p):
        if os.path.exists(os.path.join(p, "00-scope.md")) or os.path.isdir(os.path.join(p, "evidence")) and os.path.isdir(os.path.join(p, "model")):
            return p
        p = os.path.dirname(p)
    return None

IMAGE_EXT = (".png", ".jpg", ".jpeg", ".webp")

def state_files(run, state):
    """(image, dump) for a state; the image is whichever saved extension exists, .png when none does."""
    base = os.path.join(run, "evidence", "screens", state)
    img = next((base + e for e in IMAGE_EXT if os.path.exists(base + e)), base + ".png")
    return img, os.path.join(run, "evidence", "a11y", state + ".txt")

def run_id_of(run):
    fm, _ = frontmatter(read(os.path.join(run, "00-scope.md")) or "")
    rid = fm.get("run_id")
    if not rid:
        cfg = discover_cfg(run)
        rid = cfg.get("run_id") if cfg else None
    return rid

def discover_cfg(start):
    p = os.path.abspath(start)
    while p != os.path.dirname(p):
        f = os.path.join(p, ".discover", "run.json")
        if os.path.exists(f):
            try:
                return json.load(open(f))
            except ValueError:
                return {}
        p = os.path.dirname(p)
    return {}

# ---------- record lint ----------

REQUIRED_RECORD = ["surface", "title", "archetype", "object", "route", "depth", "evidence", "states", "status"]

def lint_record(path, run=None):
    text = read(path)
    if text is None:
        find("record.frontmatter", path, "unreadable")
        return
    run = run or run_dir_of(path)
    fm, body = frontmatter(text)
    if os.path.basename(path).startswith("_"):
        return
    for k in REQUIRED_RECORD:
        if k not in fm:
            find("record.frontmatter", path, f"missing frontmatter key '{k}'")
    arche = fm.get("archetype", "")
    if arche and arche not in VOCAB["archetypes"] and not arche.startswith("x-"):
        find("record.frontmatter", path, f"archetype '{arche}' not in vocabulary (an extension is x-<slug>)")
    status = fm.get("status", "")
    if status and status not in VOCAB["record_status"]:
        find("record.frontmatter", path, f"status '{status}' not one of {VOCAB['record_status']}")
    if fm.get("depth") and fm["depth"] not in VOCAB["depth"]:
        find("record.frontmatter", path, f"depth '{fm['depth']}' not one of {VOCAB['depth']}")
    if status in ("blocked", "partial") and not fm.get("blocked_by"):
        find("record.blocked-reason", path, f"status {status} without blocked_by")
    states = fm.get("states") or []
    if isinstance(states, str):
        states = [s.strip() for s in states.split(",") if s.strip()]
    if status == "walked" and not states:
        find("record.walked-empty", path, "status walked but no saved states cited")
    dumps = {}
    if run:
        for s in states:
            png, txt = state_files(run, s)
            if not os.path.exists(png) or not os.path.exists(txt):
                find("record.states", path, f"state '{s}' missing {'image' if not os.path.exists(png) else 'dump'}")
            if os.path.exists(txt):
                dumps[s] = read(txt) or ""
    tabs_ = tables(body)
    body_l = norm(body)
    alldump = norm("\n".join(dumps.values()))
    if status == "walked" and dumps:
        tab_names = set()
        popups = set()
        for d in dumps.values():
            for line in d.splitlines():
                m = re.search(r"\[tab\]\s+(.+?)(\s+\(selected\)|\s+\(disabled\)|$)", line)
                if m and m.group(1).strip():
                    tab_names.add(re.sub(r"\s+\d+$", "", m.group(1).strip()))
                if "(haspopup)" in line:
                    n = re.sub(r"^\s*(\[[^\]]+\]\s*)?", "", line).replace("(haspopup)", "").strip()
                    if 2 < len(n) < 40:
                        popups.add(n)
        for t in sorted(tab_names):
            if norm(t) not in body_l:
                find("record.tab-unrecorded", path, f"tab '{t}' is in a saved dump but never recorded")
        probes = norm(" ".join(" ".join(r.values()) for r in tabs_.get("probes", [])))
        for p in sorted(popups):
            if norm(p) not in probes:
                find("record.popup-unprobed", path, f"popup control '{p}' has no Probes row")
    for sec in ("elements", "probes"):
        for row in tabs_.get(sec, []):
            cell = row.get("carries") or row.get("result") or ""
            st = [s.strip() for s in re.split(r"[,\s]+", row.get("state", "")) if s.strip()]
            hay = norm("\n".join(dumps.get(s, "") for s in st)) if st and any(s in dumps for s in st) else alldump
            if not hay:
                continue
            for q in QUOTE.findall(cell):
                if norm(q) not in hay:
                    find("record.quote-unanchored", path, f"\"{q}\" not found in the cited dump")
    for row in tabs_.get("absences", []):
        st = row.get("state", "") or row.get("evidence", "")
        if status != "walked" or not st or st.strip() in ("", "-", "—"):
            find("record.absence-unseen", path, f"absence '{row.get('absent', row)}' without a saved state on a walked record")
    if run and dumps and status != "blocked":
        miss = [c for c in state_controls(run, states) if norm(c) not in body_l]
        if miss:
            find("record.control-unstated", path, f"{len(miss)} labelled control(s) in the cited dumps appear nowhere in the record, e.g. {'; '.join(miss[:8])}; state each, or say why it is not a surface element")

# ---------- walk ----------

def check_walk(run, closing=False):
    P = run_paths(run)
    log = read(P["log"])
    logged = set()
    if log is None:
        if os.path.isdir(P["screens"]) and os.listdir(P["screens"]):
            find("walk.log-missing", P["log"], "images saved but no capture log")
    else:
        for rows in tables(log).values():
            for r in rows:
                s = (r.get("state") or "").strip("` ")
                if not s:
                    continue
                logged.add(s)
                png, txt = state_files(run, s)
                miss = [n for n, f in (("image", png), ("dump", txt)) if not os.path.exists(f)]
                if miss:
                    find("walk.state-files", P["log"], f"state '{s}' missing {' and '.join(miss)}")
    if os.path.isdir(P["screens"]):
        for f in sorted(os.listdir(P["screens"])):
            stem, ext = os.path.splitext(f)
            if ext.lower() in IMAGE_EXT and stem not in logged:
                find("walk.orphan-image", os.path.join(P["screens"], f), "image with no capture-log row")
    idx = read(P["index"])
    if idx is not None:
        for rows in tables(idx).values():
            for r in rows:
                st = (r.get("status") or "").strip()
                base = st.split(":")[0].strip()
                if base not in VOCAB["index_status"]:
                    find("walk.index-status", P["index"], f"row '{r.get('surface')}' status '{st}'")
                if base == "blocked" and ":" not in st:
                    find("walk.index-status", P["index"], f"row '{r.get('surface')}' blocked without a condition")
                if base == "unwalked" and closing:
                    find("walk.unwalked", P["index"], f"row '{r.get('surface')}' left unwalked")
                rec = (r.get("record") or "").strip("` ")
                m = re.search(r"([\w.-]+\.md)", rec)
                if m and not os.path.exists(os.path.join(P["surfaces"], m.group(1))):
                    find("walk.index-record", P["index"], f"record {m.group(1)} does not exist")

# ---------- model ----------

def check_model(run):
    P = run_paths(run)
    if os.path.isdir(P["surfaces"]):
        for f in sorted(os.listdir(P["surfaces"])):
            if f.endswith(".md") and not f.startswith("_"):
                lint_record(os.path.join(P["surfaces"], f), run)
    if os.path.isdir(P["model"]):
        for f in sorted(os.listdir(P["model"])):
            if not f.endswith(".md"):
                continue
            path = os.path.join(P["model"], f)
            text = read(path) or ""
            for m in CITE.finditer(text):
                ref = m.group(1)
                target = os.path.join(P["surfaces"], ref + ".md") if not ref.startswith("evidence/") else os.path.join(run, ref)
                if not os.path.exists(target):
                    find("model.cite-missing", path, f"citation '{ref}' does not resolve")
            if f != "census.md":
                for sec, rows in tables(frontmatter(text)[1]).items():
                    for r in rows:
                        joined = " ".join(r.values())
                        if len(joined) > 40 and not CITE.search(joined) and "not observed" not in joined.lower():
                            find("model.uncited-row", path, f"[{sec}] row without a citation: {joined[:70]}")
    if os.path.exists(P["census"]):
        check_census(run, P["census"])

def census_rows(text):
    fm, body = frontmatter(text)
    rows = []
    for sec, rs in tables(body).items():
        for r in rs:
            rid = (r.get("row") or "").strip("* ")
            if re.fullmatch(r"[A-Z]{1,2}\d{1,2}", rid):
                rows.append((sec, r))
    return fm, rows

# ---------- state search (the one reader of saved dumps for find, view and the census) ----------

def clean_line(raw):
    t = re.sub(r"^\s*\[[\w-]+\]\s*", "", raw)
    return re.sub(r"\s*\((haspopup|selected|expanded|checked|disabled)\)", "", t).strip()

def dump_docs(run):
    """{state: [(raw line, clean text)]} for every saved dump, page chrome removed: a text carried by half
    of all dumps (sidebar, toast region) is the page's frame, not a state's content."""
    d = run_paths(run)["a11y"]
    docs = {}
    if os.path.isdir(d):
        for f in sorted(os.listdir(d)):
            if f.endswith(".txt"):
                docs[f[:-4]] = [(l, clean_line(l)) for l in (read(os.path.join(d, f)) or "").splitlines()]
    df = {}
    for lines in docs.values():
        for t in {c for _, c in lines if c}:
            df[t] = df.get(t, 0) + 1
    cut = max(3, len(docs) / 2)
    return {s: [(l, c) for l, c in lines if c and df[c] < cut and not l.strip().startswith("[separator]")] for s, lines in docs.items()}

def log_texts(run):
    out = {}
    for rows in tables(read(run_paths(run)["log"]) or "").values():
        for r in rows:
            s = (r.get("state") or "").strip("` ")
            if s:
                out[s] = " ".join(v for k, v in r.items() if k not in ("state", "overlay"))
    return out

_DOCS = {}

def state_controls(run, states):
    """Labelled controls (a role and its label on one line) in the given states' dumps that fewer than 8 dumps carry."""
    if run not in _DOCS:
        docs = dump_docs(run)
        df = {}
        for lines in docs.values():
            for t in {c for _, c in lines}:
                df[t] = df.get(t, 0) + 1
        _DOCS[run] = (docs, df)
    docs, df = _DOCS[run]
    out = set()
    for s in states:
        for raw, c in docs.get(s, []):
            if re.match(r"^\s*\[(button|tab|textbox|switch|radio|checkbox|combobox|menuitem|link)\]\s+\S", raw) and 3 <= len(c) <= 60 and df[c] < 8:
                out.add(c)
    return sorted(out)

def find_states(run, term, docs=None, logs=None):
    """{state: [matching lines]} for states whose dump or capture-log row carries the term."""
    docs = dump_docs(run) if docs is None else docs
    logs = log_texts(run) if logs is None else logs
    t, hits = term.lower(), {}
    for s, lines in docs.items():
        m = [c for _, c in lines if t in c.lower()]
        if m:
            hits[s] = m
    for s, txt in logs.items():
        if t in txt.lower():
            hits.setdefault(s, []).append("(capture log) " + txt[:120])
    return hits

def cited_states(run, cell):
    """States a census Cites cell covers: a state id, or a record's `states:`."""
    out = set()
    for tok in re.findall(r"\b\d{2,3}-[a-z0-9-]+", cell or ""):
        out.add(tok)
        rec = read(os.path.join(run_paths(run)["surfaces"], tok + ".md"))
        if rec:
            st = frontmatter(rec)[0].get("states") or []
            out.update([s.strip() for s in st.split(",")] if isinstance(st, str) else st)
    return out

def check_census(run, path):
    text = read(path) or ""
    fm, rows = census_rows(text)
    P = run_paths(run)
    docs, logs = dump_docs(run), log_texts(run)
    counts = {k: 0 for k in ("confirms", "contradicts", "extends", "absent", "not_captured", "na")}
    observed = 0
    scored = 0
    for sec, r in rows:
        rid = r.get("row")
        if rid[0] in "UZ" and rid[:2] != "AI":
            continue
        scored += 1
        v = (r.get("verdict") or "").strip("* ").upper()
        vbase = re.sub(r"\s*\(.*\)$", "", v).strip()
        if vbase not in VOCAB["census_verdicts"]:
            find("census.verdict", path, f"{rid}: verdict '{v}'")
            continue
        key = {"CONFIRMS": "confirms", "CONTRADICTS": "contradicts", "EXTENDS": "extends", "ABSENT": "absent", "NOT CAPTURED": "not_captured", "N/A": "na"}[vbase]
        counts[key] += 1
        ev = (r.get("evidence") or "").lower()
        if vbase in ("CONFIRMS", "CONTRADICTS", "EXTENDS", "ABSENT") and re.match(r"^(seen|walked)\b", ev) and "affordance" not in ev:
            observed += 1
        if vbase in ("ABSENT", "CONTRADICTS") and not re.match(r"^(seen|walked)\b", ev) or (vbase in ("ABSENT", "CONTRADICTS") and "affordance" in ev):
            find("census.overreach", path, f"{rid}: {vbase} on evidence '{ev or '—'}'")
        if vbase == "ABSENT":
            cites = re.findall(r"\b(\d{2,3}-[a-z0-9-]+)", (r.get("cites") or "") + " " + (r.get("reason") or ""))
            ok = False
            for c in cites:
                rec = read(os.path.join(P["surfaces"], c + ".md")) or ""
                if tables(frontmatter(rec)[1]).get("absences"):
                    ok = True
            if not ok:
                find("census.absent-cite", path, f"{rid}: ABSENT without a cited record that has an Absences row")
        reason = r.get("reason") or ""
        if vbase in ("ABSENT", "CONTRADICTS") and UNIVERSAL.search(reason) and not DENOM.search(reason):
            find("census.universal", path, f"{rid}: universal claim without a denominator: {reason[:80]}")
        if vbase == "NOT CAPTURED":
            m = re.search(r"searched:(.*)$", reason, re.I)
            terms = QUOTE.findall(m.group(1)) if m else []
            if not terms:
                find("census.nc-unsearched", path, f"{rid}: NOT CAPTURED without 'searched: \"term\" ...'; run `checks.py find <run-dir> \"term\"` for the words a deciding state would carry")
                continue
            covered = cited_states(run, r.get("cites") or "")
            for term in terms:
                extra = {s: h for s, h in find_states(run, term, docs, logs).items() if s not in covered}
                if extra:
                    s0 = sorted(extra)[0]
                    find("census.nc-decidable", path, f"{rid}: searched \"{term}\" appears in {len(extra)} state(s) the row does not cite ({', '.join(sorted(extra)[:5])}); e.g. {s0}: {extra[s0][0][:80]}. Score the row from them, or say in the reason why they do not decide it and cite them")
    want = dict(counts, observed=observed)
    for k, v in want.items():
        if k in fm and str(fm[k]).strip() != str(v):
            find("census.counts", path, f"frontmatter {k}={fm[k]} but rows give {v}")
    if scored and "observed_share" in fm:
        share = round(100 * observed / scored)
        try:
            if abs(float(fm["observed_share"]) - share) > 1:
                find("census.counts", path, f"observed_share={fm['observed_share']} but rows give {share}")
        except ValueError:
            find("census.counts", path, "observed_share is not a number")

# ---------- lane summaries ----------

def lint_summary(path, run=None):
    text = read(path)
    if text is None:
        find("summary.frontmatter", path, "unreadable")
        return
    fm, _ = frontmatter(text)
    for k in ("lane", "run_id", "status", "method", "confidence", "sources", "gaps"):
        if k not in fm:
            find("summary.frontmatter", path, f"missing '{k}'")
    if fm.get("status") and fm["status"] not in VOCAB["lane_status"]:
        find("summary.frontmatter", path, f"status '{fm['status']}'")
    methods = [m.strip() for m in re.split(r"[,+]", fm.get("method", "")) if m.strip()]
    bands = []
    for m in methods:
        if m not in VOCAB["methods"]:
            find("summary.method", path, f"method '{m}' not in vocabulary (propose a term; use 'other' with method_note meanwhile)")
        else:
            bands.append(VOCAB["methods"][m])
    order = {"low": 0, "medium": 1, "high": 2}
    conf = fm.get("confidence")
    if bands and conf in order and order[conf] > max(order[b] for b in bands):
        find("summary.band", path, f"confidence {conf} above the band of {methods}")
    lane_dir = os.path.dirname(path)
    raw = os.path.join(lane_dir, "raw")
    if fm.get("status") == "complete" and (not os.path.isdir(raw) or not any(os.scandir(raw))):
        find("summary.raw-empty", path, "complete with no verbatim file under raw/")
    if fm.get("status") == "absent" and not fm.get("control"):
        find("summary.absent-control", path, "absent without a positive control")
    run = run or run_dir_of(path)
    rid = run_id_of(run) if run else None
    if rid and fm.get("run_id") and fm["run_id"] != rid:
        find("summary.run-id", path, f"run_id {fm['run_id']} is not this run's ({rid})")

# ---------- coverage ----------

def coverage(run):
    P = run_paths(run)
    c = {}
    idx = read(P["index"]) or ""
    statuses = [(r.get("status") or "").split(":")[0].strip() for rows in tables(idx).values() for r in rows]
    for s in ("walked", "partial", "blocked", "unwalked"):
        c["surfaces_" + s] = statuses.count(s)
    c["surfaces_known"] = len(statuses)
    log = read(P["log"]) or ""
    c["states_logged"] = sum(1 for rows in tables(log).values() for r in rows if r.get("state"))
    c["screens"] = len([f for f in os.listdir(P["screens"]) if os.path.splitext(f)[1].lower() in IMAGE_EXT]) if os.path.isdir(P["screens"]) else 0
    c["dumps"] = len([f for f in os.listdir(P["a11y"]) if f.endswith(".txt")]) if os.path.isdir(P["a11y"]) else 0
    ev = {}
    if os.path.isdir(P["surfaces"]):
        for f in os.listdir(P["surfaces"]):
            if f.endswith(".md") and not f.startswith("_"):
                for row in tables(frontmatter(read(os.path.join(P["surfaces"], f)) or "")[1]).get("elements", []):
                    e = (row.get("evidence") or "?").split(":")[0].strip()
                    ev[e] = ev.get(e, 0) + 1
    c["elements_by_evidence"] = ev
    tot = sum(ev.values())
    c["walked_share"] = round(100 * (ev.get("seen", 0) + ev.get("walked", 0)) / tot) if tot else 0
    if os.path.exists(P["census"]):
        fm, rows = census_rows(read(P["census"]) or "")
        scored = [r for _, r in rows if not (r.get("row", "X")[0] in "UZ" and r.get("row", "")[:2] != "AI")]
        obs = sum(1 for r in scored if re.match(r"^(seen|walked)\b", (r.get("evidence") or "").lower()) and "affordance" not in (r.get("evidence") or "").lower() and not (r.get("verdict") or "").upper().startswith(("NOT CAPTURED", "N/A")))
        c["census_rows"] = len(scored)
        c["census_observed_share"] = round(100 * obs / len(scored)) if scored else 0
    claims = read(os.path.join(P["model"], "claims.md")) or ""
    disp = {}
    for rows in tables(frontmatter(claims)[1]).values():
        for r in rows:
            d = (r.get("disposition") or "").strip()
            if d:
                disp[d] = disp.get(d, 0) + 1
    c["claims_by_disposition"] = disp
    return c

def write_coverage(run, c):
    path = run_paths(run)["review"]
    text = read(path) or "---\n---\n"
    line = "coverage: " + json.dumps(c, sort_keys=True)
    if text.startswith("---"):
        end = text.find("\n---", 3)
        fm = text[3:end]
        fm = re.sub(r"\ncoverage:.*", "", "\n" + fm).lstrip("\n")
        text = "---\n" + (fm + "\n" if fm.strip() else "") + line + text[end:]
    else:
        text = "---\n" + line + "\n---\n" + text
    with open(path, "w") as f:
        f.write(text)

# ---------- critic questions ----------

def questions(run, seed, n):
    P = run_paths(run)
    pool = []
    if os.path.isdir(P["surfaces"]):
        for f in sorted(os.listdir(P["surfaces"])):
            if not f.endswith(".md") or f.startswith("_"):
                continue
            fm, body = frontmatter(read(os.path.join(P["surfaces"], f)) or "")
            t = tables(body)
            title = fm.get("title", f)
            for row in t.get("elements", []):
                pool.append(("element", f"On the surface '{title}' ({f[:-3]}), what does the {row.get('element','?')} in the {row.get('region','?')} region carry? Answer from the saved states only, citing the state id.", row.get("carries", ""), f, row.get("evidence", "")))
            for row in t.get("absences", []):
                pool.append(("absence", f"Does the surface '{title}' ({f[:-3]}) have a {row.get('expected by archetype') or row.get('expected') or row.get('absent')}? Answer from the saved states only, citing the state id.", "ABSENT: " + (row.get("absent") or ""), f, "seen"))
    if os.path.exists(P["census"]):
        _, rows = census_rows(read(P["census"]) or "")
        for _, r in rows:
            if r.get("row", "X")[0] in "UZ" and r.get("row", "")[:2] != "AI":
                continue
            pool.append(("census", f"Canon row {r.get('row')}: from the saved evidence only, does the product implement it, implement it differently, lack it, or can the evidence not tell? Cite the state or file.", f"{r.get('verdict')} · {r.get('evidence')} · {r.get('reason','')}", "census.md", r.get("evidence", "")))
    rnd = random.Random(seed)
    by = {}
    for p in pool:
        by.setdefault(p[0], []).append(p)
    quota = {"element": int(n * 0.5), "absence": max(3, int(n * 0.15)), "census": n}
    picked = []
    for kind in ("element", "absence", "census"):
        items = by.get(kind, [])
        rnd.shuffle(items)
        picked += items[: min(len(items), quota[kind] if kind != "census" else n - len(picked))]
    cdir = os.path.join(os.path.dirname(os.path.dirname(run)) if False else run, ".discover-critic")
    os.makedirs(cdir, exist_ok=True)
    with open(os.path.join(cdir, "questions.md"), "w") as f:
        f.write(f"# Critic questions\n\nseed: {seed} · {len(picked)} questions · answer each from `evidence/` only; do not open `model/`.\n\n| # | Question | Your answer | State or file |\n| --- | --- | --- | --- |\n")
        for i, p in enumerate(picked, 1):
            f.write(f"| Q{i} | {p[1]} |  |  |\n")
    with open(os.path.join(cdir, "key.json"), "w") as f:
        json.dump([{"q": f"Q{i}", "kind": p[0], "claim": p[2], "source": p[3], "evidence": p[4]} for i, p in enumerate(picked, 1)], f, indent=1)
    print(f"{len(picked)} questions → {os.path.join(cdir, 'questions.md')} (key sealed beside it)")

def critic_diff(run):
    cdir = os.path.join(run, ".discover-critic")
    key = json.load(open(os.path.join(cdir, "key.json")))
    ans = {}
    for rows in tables(read(os.path.join(cdir, "answers.md")) or read(os.path.join(cdir, "questions.md")) or "").values():
        for r in rows:
            ans[r.get("#", "")] = (r.get("your answer", ""), r.get("state or file", ""))
    for k in key:
        a = ans.get(k["q"], ("", ""))
        print(f"## {k['q']} ({k['kind']}, {k['source']})\n- run claims: {k['claim']} [{k['evidence']}]\n- critic: {a[0]} [{a[1]}]\n")

# ---------- output ----------

def flush(run, log_defects=False):
    cfg = discover_cfg(run or os.getcwd())
    rid = cfg.get("run_id") or (run_id_of(run) if run else None)
    tele_root = None
    p = os.path.abspath(run or os.getcwd())
    while p != os.path.dirname(p):
        if os.path.isdir(os.path.join(p, ".discover")):
            tele_root = os.path.join(p, ".discover")
            break
        p = os.path.dirname(p)
    ts = datetime.datetime.now().isoformat(timespec="seconds")
    if tele_root:
        with open(os.path.join(tele_root, "telemetry.jsonl"), "a") as f:
            for x in FINDINGS:
                f.write(json.dumps(dict(x, run_id=rid, ts=ts)) + "\n")
    if log_defects and run:
        dpath = run_paths(run)["defects"]
        seen = set()
        if os.path.exists(dpath):
            for line in open(dpath):
                try:
                    d = json.loads(line)
                    seen.add((d.get("check"), d.get("evidence", [""])[0], d.get("what")))
                except ValueError:
                    pass
        n = sum(1 for _ in open(dpath)) if os.path.exists(dpath) else 0
        with open(dpath, "a") as f:
            for x in FINDINGS:
                if x["severity"] != "error":
                    continue
                rel = os.path.relpath(x["file"], run)
                k = (x["rule"], rel, x["msg"])
                if k in seen:
                    continue
                seen.add(k)
                n += 1
                f.write(json.dumps({"id": f"{rid}:S{n}", "class": x["class"], "step": x["rule"].split(".")[0], "what": x["msg"], "evidence": [rel], "caught_by": "script", "check": x["rule"], "fixed_in_run": False}) + "\n")
    errs = [x for x in FINDINGS if x["severity"] == "error"]
    for x in FINDINGS:
        loc = os.path.relpath(x["file"]) if os.path.isabs(x["file"]) else x["file"]
        print(f"[{x['severity']}] {x['rule']} ({x['class']}) {loc}: {x['msg']}")
    print(f"{len(FINDINGS)} finding(s), {len(errs)} error(s)")
    return 1 if errs else 0

def main(argv):
    if len(argv) < 2:
        print(__doc__)
        return 2
    cmd = argv[1]
    if cmd == "rules":
        for k, (c, s, d) in RULES.items():
            print(f"{k:26} {c:4} {s:5} {d}")
        return 0
    if len(argv) < 3:
        print(__doc__)
        return 2
    arg = argv[2]
    if cmd == "lint-record":
        lint_record(arg)
        return flush(run_dir_of(arg))
    if cmd == "lint-summary":
        lint_summary(arg)
        return flush(run_dir_of(arg))
    run = os.path.abspath(arg)
    if not os.path.isdir(run):
        print(f"not a run directory: {arg}")
        return 2
    if cmd == "walk":
        check_walk(run)
        return flush(run)
    if cmd == "model":
        check_model(run)
        return flush(run)
    if cmd == "all":
        check_walk(run, closing=True)
        check_model(run)
        ev = os.path.join(run, "evidence")
        if os.path.isdir(ev):
            for lane in sorted(os.listdir(ev)):
                s = os.path.join(ev, lane, "_summary.md")
                if os.path.exists(s):
                    lint_summary(s, run)
        return flush(run, log_defects="--log-defects" in argv)
    if cmd == "coverage":
        c = coverage(run)
        print(json.dumps(c, indent=1, sort_keys=True))
        if "--write" in argv:
            write_coverage(run, c)
        return 0
    if cmd == "questions":
        seed = argv[argv.index("--seed") + 1] if "--seed" in argv else "0"
        n = int(argv[argv.index("--n") + 1]) if "--n" in argv else 30
        questions(run, seed, n)
        return 0
    if cmd == "critic-diff":
        critic_diff(run)
        return 0
    if cmd == "find":
        docs, logs = dump_docs(run), log_texts(run)
        for term in [a for a in argv[3:] if not a.startswith("--")]:
            hits = find_states(run, term, docs, logs)
            print(f"\"{term}\": {len(hits)} state(s)")
            for s in sorted(hits):
                print(f"  {s}: {' | '.join(dict.fromkeys(hits[s]))[:150]}")
        return 0
    if cmd == "view":
        args = [a for a in argv[3:] if not a.startswith("--")]
        if not args:
            print(__doc__)
            return 2
        docs = dump_docs(run)
        vs = argv[argv.index("--vs") + 1] if "--vs" in argv else None
        top = int(argv[argv.index("--max") + 1]) if "--max" in argv else 200
        if args[0] not in docs or (vs and vs not in docs):
            print(f"no dump for '{args[0] if args[0] not in docs else vs}'")
            return 2
        a = [l for l, _ in docs[args[0]]]
        if vs:
            import difflib
            b = [l for l, _ in docs[vs]]
            out = [("+ " if d[0] == "+" else "- ") + d[1:] for d in difflib.ndiff(a, b) if d[0] in "+-"]
            print(f"# {vs} against {args[0]}: + added by {vs}, - only in {args[0]}")
        else:
            out = a
        print("\n".join(l.rstrip() for l in out[:top]))
        if len(out) > top:
            print(f"... {len(out) - top} more line(s); raise --max")
        return 0
    print(__doc__)
    return 2

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