#!/usr/bin/env python3
"""Persists one audit run as a snapshot record and diffs runs over time
(spec FR-5, plan §5-6).

It takes already-computed data — `plugin-inventory`, `offload-scan`,
`boundary-classify` — and does nothing but record, match, and subtract. Every
step here passes all five determinism tests, so none of it belongs in a model
(FR-8.1).

STORAGE AND THE PUSH BOUNDARY
-----------------------------
plan §5 says the store is this plugin's own Gitea wiki, "cloned to a local
cache and pushed via cluster:gitea-agent (this plugin never writes Gitea
directly)" — which is in tension with itself, because a git push to the wiki
repo IS a write to Gitea.

Resolved as: **this script is offline and filesystem-only.** It reads and
writes a local store directory laid out exactly like the wiki tree:

    <store>/Data/<target>/snapshots.jsonl
    <store>/Audits/<target>/<YYYY-MM-DD>.md
    <store>/Audits/<target>/Latest.md

It never opens a socket — no git push, no Gitea API — which keeps plan §7's
"offline, only the two output paths touch the network" true, and keeps the
wiki credentials entirely outside this plugin. Publishing is a separate,
explicit action by the caller (the `offload-trend` / `offload-audit` skill, or
a human): `audit-snapshot pages` emits exactly which wiki pages to write with
their full content, and the caller performs the writes with the Gitea wiki
tools. `audit-snapshot import` is the other half of that bridge — it seeds the
local store from a `snapshots.jsonl` the caller fetched off the wiki, so a
machine with a cold cache still diffs against real history.

The append-only JSONL lives on the wiki as a page whose name is the file path;
Gitea wiki pages are files in a git repo, so the wiki IS the git-backed store
plan §5 asks for, and `git log` on it is the audit trail. Appending is
read-current-content, add one line, write back — which is why `pages` emits
the whole file, not a delta.

MEMORY (FR-9)
-------------
`memory-queries` and `memory-notes` are the same bridge pointed at mempalace
instead of the wiki: filesystem in, JSON out, no MCP call ever made here. The
caller runs `search` with the queries verbatim and `add_drawer` with the
payloads verbatim. No judgment lives in what text gets written — which is the
whole point, because a plugin that narrates its own bookkeeping at inference
time is the thing this plugin files issues about (FR-8.1).

The memory renderers are deliberately NOT `render_run`/`render_latest`. Those
emit `notes`, which is caller-supplied free text (`--note`), and
`falsifiability` prose is one field away. A wiki page lives in this plugin's
own repo; a drawer lands in a wing shared with unrelated projects. So the
memory renderers read from a fixed allowlist of structured snapshot fields —
ids, positions, booleans, counts, ratios — and no free-text field crosses into
a drawer at all (FR-3.5, FR-9.3). Allowlist, never blocklist: a blocklist is
one new snapshot field away from leaking.
"""

import argparse
import json
import os
import sys
from datetime import datetime, timezone

from ia_store import (DEFAULT_STORE, MIN_INVOCATIONS, MIN_SHARE_OF_SPEND,
                      audits_dir, build_index, candidate_signature, find_match,
                      load_snapshots, scan_rows, signature_hash, snapshots_path,
                      store_root, tiered)

# Relative change in the volume-normalized metrics below which a candidate is
# `persisting` rather than grown/shrunk. Sampling noise across two windows of
# different length is easily a few percent; 10% is the smallest band that does
# not turn every run into a trend.
NOISE_BAND = 0.10

STATUSES = ("new", "persisting", "grown", "shrunk", "resolved",
            "signature-drifted", "stale", "claimed-fixed-unconfirmed",
            # the step ran in one window and not the other: no continuous
            # series, so no direction (FR-5.4)
            "unmeasured")


# --------------------------------------------------------------------------
# store
# --------------------------------------------------------------------------

def append_snapshot(store, target, snap):
    path = snapshots_path(store, target)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "a") as fh:
        fh.write(json.dumps(snap, sort_keys=True) + "\n")
    return path


def write_text(path, text):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as fh:
        fh.write(text)
    return path


# --------------------------------------------------------------------------
# write
# --------------------------------------------------------------------------

def tool_version():
    manifest = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                            ".claude-plugin", "plugin.json")
    try:
        with open(manifest) as fh:
            return json.load(fh).get("version")
    except OSError:
        return None


def measurements_by_signature(scan):
    """Every recurring signature the scan saw, keyed by hash — including ones
    that no longer classify as candidates. FR-5.5 needs exactly this: a later
    window's measured cost for a signature whose candidate is gone."""
    total = scan.get("totals", {}).get("weighted_tokens") or 0
    out = {}
    for _name, row in scan_rows(scan):
        for ng in row.get("ngrams", []):
            h = signature_hash(ng["sig"])
            invocations = ng.get("invocations", 0)
            waste = ng.get("waste", 0)
            out[h] = {
                "invocations": invocations,
                "offload_value": waste,
                "share_of_plugin": round(waste / total, 4) if total else 0.0,
                "cost_per_invocation": int(waste / invocations) if invocations else 0,
            }
    return out


def snapshot_candidate(cand, scan, explicit, issue_map):
    sig, source = candidate_signature(cand, scan, explicit)
    row = next((r for n, r in scan_rows(scan) if n == cand.get("skill")), {})
    invocations = row.get("invocations", 0)
    value = cand.get("offload_value", 0)
    return {
        "candidate_id": cand.get("candidate_id"),
        "signature": sig,
        "signature_source": source,
        "signature_hash": signature_hash(sig),
        "skill": cand.get("skill"),
        "position": cand.get("position"),
        "boundary_confidence": cand.get("boundary_confidence"),
        "determinism_tests": cand.get("determinism_tests"),
        "falsifiability": cand.get("falsifiability_triple"),
        "measurement": {
            "invocations": invocations,
            "offload_waste": row.get("offload_waste", 0),
            "offload_value": value,
            "share_of_plugin": cand.get("share_of_audited_spend", 0.0),
            # None, never 0, when the step did not run: a window in which a
            # candidate was never invoked has no cost-per-invocation to report,
            # and folding that to 0 makes "did not run" indistinguishable from
            # "improved to zero" — the quiet-window artefact FR-5.4 exists to
            # prevent, one level up from absolute tokens.
            "cost_per_invocation": int(value / invocations) if invocations else None,
        },
        "measurement_strength": cand.get("measurement_strength"),
        "digest_schema": cand.get("digest_schema"),
        "escalation_path": cand.get("escalation_path"),
        "issue": issue_map.get(cand.get("candidate_id")) or cand.get("issue"),
        "verdict": cand.get("verdict"),
    }


def build_snapshot(target, inventory, scan, classified, issue_map, explicit, run_id, repo,
                   extra_notes=()):
    plugin = inventory.get("plugin", {})
    scan_total = scan.get("totals", {})

    rows = classified.get("candidates", [])
    filed = [c for c in rows if c.get("verdict") == "file"]
    questions = [c for c in rows if c.get("verdict") == "boundary-question"]
    # Derived notes first, caller-supplied after. The caller's are outcomes of
    # the filing pass — `filing: unavailable` and why (FR-6.4) — which this
    # script cannot see and which have to reach the wiki page, not just the
    # user's terminal.
    notes = [
        f"{c['candidate_id']}: {c['verdict']} — {c['reasons'][-1] if c.get('reasons') else ''}"
        for c in rows if c.get("verdict") in ("no-offload", "drift-note")
    ] + list(extra_notes)

    return {
        "run_id": run_id,
        "rubric_version": classified.get("rubric_version"),
        "tool_versions": {
            "inference-arbitrage": tool_version(),
            "cc-tokens": scan.get("tool_versions", {}).get("cc_tokens_path"),
        },
        "target": {
            "name": target,
            "version": plugin.get("version"),
            "source": plugin.get("source"),
            "repo": repo,
            # Not in plan §6, but FR-5.3's `stale` is uncomputable without it:
            # "the skill no longer exists" needs the skill list of the run it
            # no longer exists in.
            "skills": sorted({s.get("name") for s in inventory.get("skills", []) if s.get("name")}
                             | {a.get("name") for a in inventory.get("agents", []) if a.get("name")}),
        },
        "window": scan.get("window", {}),
        "coverage": {
            "ratio": scan.get("coverage", {}).get("ratio"),
            "attributed_turns": scan.get("coverage", {}).get("attributed_turns"),
        },
        "totals": {
            "weighted_tokens": scan_total.get("weighted_tokens", 0),
            "invocations": scan_total.get("invocations", 0),
            "share_mechanical": scan_total.get("mechanical_share", 0.0),
            "invocation_caveat": scan_total.get("invocation_caveat"),
        },
        "candidates": [snapshot_candidate(c, scan, explicit, issue_map) for c in filed],
        "boundary_questions": [snapshot_candidate(c, scan, explicit, issue_map) for c in questions],
        "measurements_by_signature": measurements_by_signature(scan),
        "notes": notes,
    }


# --------------------------------------------------------------------------
# diff (FR-5.3, FR-5.4, FR-5.5)
# --------------------------------------------------------------------------

def rel(before, after):
    """None when the base is zero — a ratio against nothing is not a ratio.
    Callers fall back to the sign of the absolute delta in that case."""
    if not before:
        return None
    return round((after - before) / before, 4)


def metric_delta(prev_m, curr_m, key):
    # A missing measurement dict means the candidate was absent from that
    # snapshot entirely (new, or departed) and 0 is the right base. An explicit
    # None means the candidate was present but not invoked — that is an
    # absence of measurement and must not be folded to a number.
    a = (prev_m or {}).get(key, 0)
    b = (curr_m or {}).get(key, 0)
    if a is None or b is None:
        # At least one window has no measurement for this metric. There is no
        # delta to take against an absence, and inventing one is how a window
        # the step sat out becomes a reported improvement.
        return {"from": a, "to": b, "delta": None, "rel": None, "unmeasured": True}
    return {"from": a, "to": b, "delta": b - a, "rel": rel(a, b)}


def direction(primary, secondary):
    """Volume-normalized verdict (FR-5.4). cost-per-invocation leads: it is the
    metric a quiet week cannot move. share-of-spend only breaks the tie, and
    absolute tokens never vote."""
    if primary.get("unmeasured"):
        # The step was not invoked in one of the two windows, so the series is
        # not continuous and no direction can be read from it.
        return "unmeasured"
    for m in (primary, secondary):
        if m.get("unmeasured"):
            continue
        r = m["rel"]
        if r is None:
            # Base of zero: the ratio is undefined, so the sign of the move is
            # all there is.
            if m["delta"] > 0:
                return "grown"
            continue
        if r > NOISE_BAND:
            return "grown"
        if r < -NOISE_BAND:
            return "shrunk"
    return "persisting"


def remeasured(prev_cand, curr_snap):
    return (curr_snap.get("measurements_by_signature") or {}).get(
        prev_cand.get("signature_hash"))


def departed_status(prev_cand, curr_snap, issue_state):
    """A candidate that is no longer on the list. `resolved` is the strict
    FR-5.5 conjunction — issue closed AND remeasured below threshold with
    enough invocations. Everything else that merely stopped being reported is
    `claimed-fixed-unconfirmed`, with the missing leg named: committed is not
    deployed, and closed is not fixed."""
    skills = set((curr_snap.get("target") or {}).get("skills") or [])
    if skills and prev_cand.get("skill") not in skills:
        return "stale", (f"skill {prev_cand.get('skill')} is absent from "
                         f"{curr_snap['target'].get('name')} "
                         f"{curr_snap['target'].get('version')} — the candidate no longer exists")

    issue = prev_cand.get("issue")
    state = issue_state.get(issue) if issue else None
    m = remeasured(prev_cand, curr_snap)
    below = bool(m and m["invocations"] >= MIN_INVOCATIONS
                 and m["share_of_plugin"] < MIN_SHARE_OF_SPEND)

    if state == "closed" and below:
        return "resolved", (f"{issue} closed and remeasured at "
                            f"{m['share_of_plugin']:.2%} of spend over "
                            f"{m['invocations']} invocations")
    if state == "closed":
        if m:
            return "claimed-fixed-unconfirmed", (
                f"{issue} closed but remeasurement is {m['invocations']} invocation(s) at "
                f"{m['share_of_plugin']:.2%} — needs >= {MIN_INVOCATIONS} invocations below "
                f"{MIN_SHARE_OF_SPEND:.0%}")
        return "claimed-fixed-unconfirmed", (
            f"{issue} closed but the signature was not remeasured in this window")
    if issue:
        return "claimed-fixed-unconfirmed", (
            f"dropped off the candidate list while {issue} is still {state or 'unknown'}")
    return "claimed-fixed-unconfirmed", (
        "dropped off the candidate list with no linked issue and no remeasurement below threshold")


def row_of(cand, tier, status, reason, matched_by, prev_cand=None):
    prev_m = (prev_cand or {}).get("measurement")
    curr_m = cand.get("measurement") if cand else None
    return {
        "candidate_id": (cand or prev_cand).get("candidate_id"),
        "skill": (cand or prev_cand).get("skill"),
        "tier": tier,
        "status": status,
        "matched_by": matched_by,
        "issue": (cand or prev_cand).get("issue"),
        "primary": {
            "cost_per_invocation": metric_delta(prev_m, curr_m, "cost_per_invocation"),
            "share_of_plugin": metric_delta(prev_m, curr_m, "share_of_plugin"),
        },
        "context_only": {
            "offload_value": metric_delta(prev_m, curr_m, "offload_value"),
            "invocations": metric_delta(prev_m, curr_m, "invocations"),
        },
        "reason": reason,
    }


def diff_snapshots(prev, curr, issue_state):
    prev_all = list(tiered(prev))
    idx = build_index([c for c, _ in prev_all])
    prev_tier = {c["candidate_id"]: t for c, t in prev_all}
    seen = set()
    rows = []

    for cand, tier in tiered(curr):
        match, by = find_match(cand, idx)
        if not match:
            rows.append(row_of(cand, tier, "new", "not present in the previous snapshot", None))
            continue
        seen.add(match["candidate_id"])
        if match.get("signature_hash") != cand.get("signature_hash"):
            reason = (f"tool-sequence signature changed "
                      f"{match.get('signature_hash')} -> {cand.get('signature_hash')} "
                      f"(matched by {by}); the step was refactored, so the cost series is not continuous")
            rows.append(row_of(cand, tier, "signature-drifted", reason, by, match))
            continue
        row = row_of(cand, tier, "persisting", None, by, match)
        row["status"] = direction(row["primary"]["cost_per_invocation"],
                                  row["primary"]["share_of_plugin"])
        row["reason"] = (f"cost/invocation {row['primary']['cost_per_invocation']['rel']}, "
                         f"share {row['primary']['share_of_plugin']['rel']} "
                         f"(band +/-{NOISE_BAND:.0%})")
        rows.append(row)

    for cand, tier in prev_all:
        if cand["candidate_id"] in seen:
            continue
        status, reason = departed_status(cand, curr, issue_state)
        rows.append(row_of(None, tier, status, reason, None, cand))

    summary = {s: sum(1 for r in rows if r["status"] == s) for s in STATUSES}
    unaddressed = [
        r for r in rows
        if r["tier"] == "candidate" and r["status"] in ("persisting", "grown", "signature-drifted")
    ]
    promotions = [
        r for r in rows
        if r["tier"] == "boundary-question" and r["status"] == "grown"
        and prev_tier.get(r["candidate_id"]) == "boundary-question"
    ]

    prev_vol = prev.get("totals", {}).get("weighted_tokens", 0)
    curr_vol = curr.get("totals", {}).get("weighted_tokens", 0)

    return {
        "target": curr.get("target", {}).get("name"),
        "from": {"run_id": prev.get("run_id"), "window": prev.get("window"),
                 "rubric_version": prev.get("rubric_version"),
                 "target_version": prev.get("target", {}).get("version")},
        "to": {"run_id": curr.get("run_id"), "window": curr.get("window"),
               "rubric_version": curr.get("rubric_version"),
               "target_version": curr.get("target", {}).get("version")},
        "rubric_comparable": prev.get("rubric_version") == curr.get("rubric_version"),
        "volume": {
            "weighted_tokens": {"from": prev_vol, "to": curr_vol, "rel": rel(prev_vol, curr_vol)},
            "note": ("absolute totals are context only — a quieter window lowers every absolute "
                     "number without anything improving. Per-candidate verdicts come from "
                     "cost-per-invocation and share-of-spend."),
        },
        "candidates": rows,
        "summary": summary,
        "unaddressed": [r["candidate_id"] for r in unaddressed],
        "unaddressed_issues": sorted({r["issue"] for r in unaddressed if r["issue"]}),
        "promotions": [r["candidate_id"] for r in promotions],
    }


# --------------------------------------------------------------------------
# rendering
# --------------------------------------------------------------------------

def fmt_rel(r):
    if r is None:
        return "n/a"
    return f"{r:+.0%}"


def fmt_cpi(v):
    """`not run`, never 0 — the step had no invocations in that window."""
    return "not run" if v is None else f"{v:,}"


def candidate_table(rows):
    if not rows:
        return "— none —\n"
    out = ["| Candidate | Status | Cost/invocation | Share of spend | Issue |",
           "|---|---|---|---|---|"]
    for r in rows:
        cpi = r["primary"]["cost_per_invocation"]
        share = r["primary"]["share_of_plugin"]
        out.append(
            f"| `{r['candidate_id']}` | {r['status']} | "
            f"{fmt_cpi(cpi['from'])} → {fmt_cpi(cpi['to'])} ({fmt_rel(cpi['rel'])}) | "
            f"{share['from']:.2%} → {share['to']:.2%} ({fmt_rel(share['rel'])}) | "
            f"{r['issue'] or '—'} |")
    return "\n".join(out) + "\n"


def render_run(snap, diffdoc):
    t = snap["target"]
    w = snap.get("window", {})
    lines = [
        f"# {t['name']} — audit {snap['run_id']}",
        "",
        f"- Target: `{t['name']}` {t.get('version') or '?'} ({t.get('source') or '?'})"
        + (f", repo `{t['repo']}`" if t.get("repo") else ""),
        f"- Window: {w.get('since')} → {w.get('until')}",
        f"- Attribution coverage: {(snap['coverage'].get('ratio') or 0):.1%} "
        f"({snap['coverage'].get('attributed_turns')} attributed turns) — "
        "hook-driven and unattributed agent turns are excluded, so every figure below is a lower bound.",
        f"- Mechanical share of audited spend: {(snap['totals'].get('share_mechanical') or 0):.1%}",
        f"- Rubric v{snap.get('rubric_version')}, inference-arbitrage "
        f"v{snap.get('tool_versions', {}).get('inference-arbitrage')}",
    ]
    invocation_caveat = snap["totals"].get("invocation_caveat")
    if invocation_caveat:
        lines.append(f"- Note: {invocation_caveat}, so per-invocation figures below are "
                     "correspondingly inflated")
    lines += [
        "",
        "## Candidates",
        "",
    ]
    for c in snap.get("candidates", []):
        m = c["measurement"]
        lines += [
            f"### `{c['candidate_id']}`",
            "",
            f"- Skill: `{c['skill']}` — position {c['position']}, confidence {c['boundary_confidence']}",
            f"- {m['offload_value']:,} weighted tokens of offload value over {m['invocations']} "
            f"invocations ({fmt_cpi(m['cost_per_invocation'])}/invocation, "
            f"{m['share_of_plugin']:.2%} of audited spend)",
            f"- Evidence: {c.get('measurement_strength')}; signature `{c['signature_hash']}` "
            f"({c.get('signature_source')})",
            f"- Issue: {c.get('issue') or 'not yet filed'}",
            "",
        ]
    if not snap.get("candidates"):
        lines += ["Nothing to offload here.", ""]

    if snap.get("boundary_questions"):
        lines += ["## Boundary questions", "",
                  "Reported, not filed — the rubric could not reach `high` confidence with an "
                  "overrule case.", ""]
        for c in snap["boundary_questions"]:
            lines.append(f"- `{c['candidate_id']}` (`{c['skill']}`) — {c['boundary_confidence']}, "
                         f"{c['measurement']['share_of_plugin']:.2%} of spend")
        lines.append("")

    if snap.get("notes"):
        lines += ["## Notes", ""] + [f"- {n}" for n in snap["notes"]] + [""]

    if diffdoc:
        lines += ["## Change since " + str(diffdoc["from"]["run_id"]), "",
                  candidate_table(diffdoc["candidates"]), ""]
        if not diffdoc["rubric_comparable"]:
            lines.append(f"**Rubric version changed** "
                         f"({diffdoc['from']['rubric_version']} → {diffdoc['to']['rubric_version']}): "
                         "classifications either side of the change were not graded by the same "
                         "table and must not be compared naively.\n")
    return "\n".join(lines).rstrip() + "\n"


def render_latest(snap, diffdoc, history):
    t = snap["target"]
    lines = [
        f"# {t['name']} — latest audit",
        "",
        f"Regenerated from `Data/{t['name']}/snapshots.jsonl` — do not edit by hand.",
        "",
        f"- Latest run: `{snap['run_id']}` against {t['name']} {t.get('version') or '?'}",
        f"- Runs recorded: {len(history)} (first `{history[0]['run_id']}`)",
        f"- Open candidates: {len(snap.get('candidates', []))}; "
        f"boundary questions: {len(snap.get('boundary_questions', []))}",
        "",
    ]
    if diffdoc:
        s = diffdoc["summary"]
        lines += [
            "## Trend",
            "",
            f"Against `{diffdoc['from']['run_id']}`. Audited volume moved "
            f"{fmt_rel(diffdoc['volume']['weighted_tokens']['rel'])} — context only; the verdicts "
            "below are per-invocation and share-of-spend.",
            "",
            "| Status | Count |", "|---|---|",
        ] + [f"| {k} | {v} |" for k, v in s.items() if v] + [""]
        if diffdoc["unaddressed"]:
            refs = ", ".join(diffdoc["unaddressed_issues"]) or "none filed"
            lines += [f"**{len(diffdoc['unaddressed'])} prior recommendation(s) still unaddressed** "
                      f"— {refs}", ""]
        if diffdoc["promotions"]:
            lines += ["**Boundary questions recurring with growing cost** (candidates for promotion): "
                      + ", ".join(f"`{c}`" for c in diffdoc["promotions"]), ""]
        lines += [candidate_table(diffdoc["candidates"]), ""]

    lines += ["## Open candidates", "",
              "| Candidate | Skill | Share of spend | Cost/invocation | Issue |",
              "|---|---|---|---|---|"]
    for c in snap.get("candidates", []):
        m = c["measurement"]
        lines.append(f"| `{c['candidate_id']}` | `{c['skill']}` | {m['share_of_plugin']:.2%} | "
                     f"{fmt_cpi(m['cost_per_invocation'])} | {c.get('issue') or '—'} |")
    if not snap.get("candidates"):
        lines.append("| — none — | | | | |")
    lines += ["", "## Run history", "", "| Run | Target version | Coverage | Candidates |",
              "|---|---|---|---|"]
    for h in history[-10:]:
        lines.append(f"| `{h['run_id']}` | {h['target'].get('version') or '?'} | "
                     f"{(h.get('coverage', {}).get('ratio') or 0):.1%} | "
                     f"{len(h.get('candidates', []))} |")
    return "\n".join(lines).rstrip() + "\n"


def regenerate(store, target, history):
    """Dated page appended, Latest.md regenerated (plan §5)."""
    snap = history[-1]
    diffdoc = diff_snapshots(history[-2], snap, {}) if len(history) > 1 else None
    day = str(snap["run_id"])[:10]
    return [
        write_text(os.path.join(audits_dir(store, target), f"{day}.md"), render_run(snap, diffdoc)),
        write_text(os.path.join(audits_dir(store, target), "Latest.md"),
                   render_latest(snap, diffdoc, history)),
    ]


# --------------------------------------------------------------------------
# memory (FR-9)
# --------------------------------------------------------------------------

# Target-scoped: the audited plugin's own manifest name, mirroring FR-6.1's
# "the finding belongs on the target's surface, not this plugin's". The room is
# dedicated and namespaced by this plugin's name so that a wing collision — an
# audited plugin sharing a name with an unrelated wing, which really happens
# (`cluster`, `imagex`) — is a shelving overlap and never a content collision.
MEMORY_ROOM = "inference-arbitrage-audits"

# Cross-target: this plugin's own accumulated judgment, in the wing its own
# development history already lives in. Written every run regardless of target.
MEMORY_CROSS_WING = "claude-plugins"
MEMORY_CROSS_ROOM = "inference-arbitrage-lessons"

MEMORY_ADDED_BY = "inference-arbitrage"

# mempalace's search takes at most 250 characters of query.
QUERY_MAX = 250

REDACTION_NOTE = (
    "Shapes and counts only — no transcript content, file contents, command "
    "arguments, or user prose (FR-3.5). Rendered mechanically by "
    "`audit-snapshot memory-notes`; nothing here was written at inference time."
)

ADVISORY_NOTE = (
    "Results are advisory context for grading only. They never override "
    "bin/boundary-classify (FR-4.2) or bin/stability-classify (FR-4.5), and a "
    "prior drawer is never a reason to file, skip a marker search, or re-grade "
    "a verdict (FR-9.2)."
)


def chunk_query(prefix, words):
    """Deterministic packing of names into queries that fit QUERY_MAX."""
    out, cur = [], prefix
    for w in words:
        if len(cur) + 1 + len(w) > QUERY_MAX:
            out.append(cur)
            cur = prefix
        cur = f"{cur} {w}" if cur else w
    if cur != prefix:
        out.append(cur)
    return out


def surface_names(inventory, snap):
    """Skill and agent names, from the inventory if the caller has one (the
    read step runs before any snapshot exists) and from the last snapshot
    otherwise."""
    if inventory:
        names = {s.get("name") for s in inventory.get("skills", [])}
        names |= {a.get("name") for a in inventory.get("agents", [])}
        return sorted(n for n in names if n)
    return sorted((snap or {}).get("target", {}).get("skills") or [])


def memory_queries(target, names):
    q = [
        {"wing": target, "room": MEMORY_ROOM,
         "query": f"{target} offload audit declined candidates gotchas calibration"},
        {"wing": MEMORY_CROSS_WING, "room": MEMORY_CROSS_ROOM,
         "query": "offload audit calibration lessons false positives coverage dilution"},
        {"wing": MEMORY_CROSS_WING, "room": MEMORY_CROSS_ROOM,
         "query": f"{target} offload audit rubric verdict"},
    ]
    for text in chunk_query(f"{target} offload candidates", names):
        q.append({"wing": target, "room": MEMORY_ROOM, "query": text})
    return q


def pct(x):
    return f"{(x or 0):.1%}"


def share(x):
    return f"{(x or 0):.2%}"


def tests_line(tests):
    tests = tests or {}
    return " ".join(f"{t} {'PASS' if tests.get(t) is True else 'FAIL'}"
                    for t in ("T1", "T2", "T3", "T4", "T5"))


def triple_line(f):
    f = f or {}
    return ", ".join(
        f"{k.replace('_', ' ')} {'yes' if f.get(k) is True else 'no'}"
        for k in ("signature", "examples", "overrule_case"))


def decline_reason(c, downgraded_ids=frozenset()):
    """Why the gate refused, read off the structured grade — never off the
    graders' prose `reasons`, whose provenance is mixed.

    Checks stay in FR-4.2's own order (overrule case, tests, confidence)
    first — a candidate can be BOTH ungraded-for-filing AND coincidentally
    named in a stability finding built from unrelated synthetic data, and
    those real disqualifications must still win. Only the generic fallback at
    the end — "did not clear the filing threshold" — is replaced when the
    candidate is a genuine FR-4.5 downgrade, because a candidate that reaches
    that fallback cleared every earlier check on its own grade: complete
    triple, all determinism tests, `high` confidence. It cannot have failed
    the filing threshold on its own merits and ALSO be one `stability-classify`
    downgraded — real downgrades only ever touch candidates that already
    cleared `boundary-classify`'s gate (verdict `file`), so reaching this line
    while flagged in `downgraded_ids` means the fallback was always the wrong
    reason for it and FR-4.5 is the real one. Fix for
    kotkan/claude-plugin-inference-arbitrage#16, which contradicted the
    correct FR-4.5 section rendered a few lines below it in the same drawer.
    """
    f = c.get("falsifiability") or {}
    tests = c.get("determinism_tests") or {}
    if f.get("overrule_case") is not True:
        return ("no overrule case — the falsifiability triple is incomplete, so the "
                "hard FR-4.2 gate refused it")
    failed = [t for t in ("T1", "T2", "T3", "T4", "T5") if tests.get(t) is not True]
    if failed:
        return (f"determinism test(s) {', '.join(failed)} did not hold — the cut is not "
                "yet located")
    if c.get("boundary_confidence") != "high":
        return f"boundary confidence {c.get('boundary_confidence')} — reported, not filed"
    if c.get("candidate_id") in downgraded_ids:
        return ("cleared boundary-classify's gate on its own grade — downgraded "
                "afterward by the evidence-stability gate (FR-4.5); see below")
    return "did not clear the filing threshold in this window"


def cand_row(c):
    m = c.get("measurement") or {}
    return (f"| `{c.get('candidate_id')}` | `{c.get('skill')}` | {c.get('position')} | "
            f"{c.get('boundary_confidence')} | {c.get('measurement_strength')} | "
            f"{share(m.get('share_of_plugin'))} | {fmt_cpi(m.get('cost_per_invocation'))} | "
            f"{c.get('issue') or '—'} |")


def render_memory_run(snap):
    t = snap.get("target", {})
    w = snap.get("window", {})
    cov = snap.get("coverage", {})
    tot = snap.get("totals", {})
    lines = [
        f"# inference-arbitrage audit — {t.get('name')} {t.get('version') or '?'} — run {snap.get('run_id')}",
        "",
        REDACTION_NOTE,
        "",
        f"- Target: `{t.get('name')}` {t.get('version') or '?'} ({t.get('source') or '?'})"
        + (f", repo `{t.get('repo')}`" if t.get("repo") else ""),
        f"- Window: {w.get('since')} → {w.get('until')}",
        f"- Attribution coverage: {pct(cov.get('ratio'))} of the turns in sessions where this "
        f"plugin appeared ({cov.get('attributed_turns')} attributed turns) — a dilution "
        "measure, not completeness.",
        f"- Mechanical share of audited spend: {pct(tot.get('share_mechanical'))}",
        f"- Audited volume: {tot.get('weighted_tokens', 0):,} weighted tokens over "
        f"{tot.get('invocations', 0)} invocations",
        f"- Rubric v{snap.get('rubric_version')}, inference-arbitrage "
        f"v{snap.get('tool_versions', {}).get('inference-arbitrage')}",
        "",
        "## Filed candidates",
        "",
    ]
    header = ["| Candidate | Skill | Position | Confidence | Evidence | Share of spend "
              "| Cost/invocation | Issue |", "|---|---|---|---|---|---|---|---|"]
    filed = snap.get("candidates") or []
    if filed:
        lines += header + [cand_row(c) for c in filed]
    else:
        lines.append("— none — nothing cleared the filing gate in this window.")
    lines += ["", "## Boundary questions — reported, never filed", ""]
    questions = snap.get("boundary_questions") or []
    if questions:
        lines += header + [cand_row(c) for c in questions]
    else:
        lines.append("— none —")
    return "\n".join(lines).rstrip() + "\n"


def render_memory_judgments(snap, findings):
    t = snap.get("target", {})
    questions = snap.get("boundary_questions") or []
    if not questions and not findings:
        return None
    downgraded_ids = {f.get("candidate_id") for f in findings}
    lines = [
        f"# inference-arbitrage — judgment calls on {t.get('name')} (run {snap.get('run_id')})",
        "",
        "Why candidates were declined, so a later audit does not re-propose them without "
        "new evidence. Every reason below is read off the structured grade, not off prose.",
        "",
        REDACTION_NOTE,
        "",
    ]
    if questions:
        lines += ["## Declined — reported as boundary questions, never filed", ""]
        for c in questions:
            m = c.get("measurement") or {}
            lines += [
                f"- `{c.get('candidate_id')}` (`{c.get('skill')}`) — position "
                f"{c.get('position')}, confidence {c.get('boundary_confidence')}, evidence "
                f"{c.get('measurement_strength')} at {share(m.get('share_of_plugin'))} of "
                f"audited spend over {m.get('invocations')} invocations.",
                f"  - Determinism tests: {tests_line(c.get('determinism_tests'))}",
                f"  - Falsifiability triple: {triple_line(c.get('falsifiability'))}",
                f"  - Declined because: {decline_reason(c, downgraded_ids)}",
            ]
        lines.append("")
    if findings:
        lines += ["## Evidence-stability downgrades (FR-4.5)", "",
                  "The auditor described the same candidate two different ways depending on the "
                  "date range. That is a defect in this plugin, not a fact about the target.", ""]
        for f in findings:
            prior, curr = f.get("prior") or {}, f.get("current") or {}
            lines += [
                f"- `{f.get('candidate_id')}` (`{f.get('skill')}`), matched by "
                f"{f.get('matched_by')}: {prior.get('measurement_strength')} at "
                f"{share(prior.get('share_of_audited_spend'))} in run {prior.get('run_id')}, "
                f"{curr.get('measurement_strength')} at "
                f"{share(curr.get('share_of_audited_spend'))} in this window.",
                f"  - Self-reported on {(f.get('self_report') or {}).get('repo')}, marker "
                f"`{(f.get('self_report') or {}).get('marker')}`",
            ]
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def counted(items, key):
    out = {}
    for c in items:
        out[c.get(key)] = out.get(c.get(key), 0) + 1
    return ", ".join(f"{k} {v}" for k, v in sorted(out.items(), key=lambda kv: str(kv[0]))) or "none"


def render_memory_lessons(snap, findings):
    t = snap.get("target", {})
    cov = snap.get("coverage", {})
    tot = snap.get("totals", {})
    filed = snap.get("candidates") or []
    questions = snap.get("boundary_questions") or []
    every = filed + questions

    lines = [
        f"# inference-arbitrage — what the {t.get('name')} audit adds to the rubric's "
        f"track record (run {snap.get('run_id')})",
        "",
        "Cross-target calibration record, written on every run regardless of outcome, so "
        "this plugin's judgment accumulates independently of which plugin was audited.",
        "",
        REDACTION_NOTE,
        "",
        f"- Target: `{t.get('name')}` {t.get('version') or '?'} ({t.get('source') or '?'})",
        f"- Rubric v{snap.get('rubric_version')}, inference-arbitrage "
        f"v{snap.get('tool_versions', {}).get('inference-arbitrage')}",
        f"- Coverage dilution {pct(cov.get('ratio'))} over {cov.get('attributed_turns')} "
        f"attributed turns; mechanical share {pct(tot.get('share_mechanical'))}",
        f"- Verdicts: {len(filed)} filed, {len(questions)} boundary questions",
        f"- Positions seen: {counted(every, 'position')}",
        f"- Evidence strengths: {counted(every, 'measurement_strength')}",
        f"- Stability gate: {len(findings)} downgrade(s) this run",
        "",
        "## What this run says about the rubric",
        "",
    ]
    said = []
    if not filed and (tot.get("share_mechanical") or 0) >= 0.2:
        said.append(f"Nothing cleared the gate despite {pct(tot.get('share_mechanical'))} of "
                    "audited spend being mechanical — the conservative filing threshold held "
                    "against a target that looked like a candidate on the headline number.")
    if not every:
        said.append("No candidates at either tier: this target's boundary is already cut where "
                    "the rubric would put it.")
    if findings:
        said.append(f"{len(findings)} candidate(s) had window-dependent evidence, caught "
                    "mechanically by the stability gate rather than by human review.")
    if len(questions) > len(filed):
        said.append("More candidates were declined than filed at this rubric version — the "
                    "asymmetry (under-scripting costs money, over-scripting costs correctness) "
                    "is being applied, not just documented.")
    if (cov.get("ratio") or 0) < 0.05:
        said.append(f"Coverage dilution of {pct(cov.get('ratio'))} is normal for this "
                    "environment's long multi-topic sessions and is not a failed scan; the "
                    "honest completeness signal is the absolute count "
                    f"({cov.get('attributed_turns')} attributed turns).")
    if not said:
        said.append("Nothing anomalous: verdicts, evidence strengths and coverage all fell "
                    "inside the ranges the rubric was calibrated on.")
    lines += [f"- {s}" for s in said]
    return "\n".join(lines).rstrip() + "\n"


def memory_facts(snap, findings):
    t = snap.get("target", {})
    name = t.get("name")
    day = str(snap.get("run_id"))[:10]
    facts = [{"subject": name, "predicate": "audited_by", "object": "inference-arbitrage",
              "valid_from": day}]
    for c in snap.get("candidates") or []:
        facts.append({"subject": c.get("candidate_id"), "predicate": "classified_as",
                      "object": c.get("position"), "valid_from": day})
        if c.get("issue"):
            facts.append({"subject": c.get("candidate_id"), "predicate": "filed_as",
                          "object": c.get("issue"), "valid_from": day})
    for c in snap.get("boundary_questions") or []:
        facts.append({"subject": c.get("candidate_id"), "predicate": "declined_as",
                      "object": "boundary-question", "valid_from": day})
    for f in findings:
        facts.append({"subject": f.get("candidate_id"), "predicate": "downgraded_by",
                      "object": "evidence-stability gate FR-4.5", "valid_from": day})
    return facts


def memory_drawers(snap, findings):
    t = snap.get("target", {})
    name = t.get("name")
    day = str(snap.get("run_id"))[:10]
    src = f"inference-arbitrage/Audits/{name}/{day}.md"
    out = [{
        "kind": "run-summary",
        "wing": name,
        "room": MEMORY_ROOM,
        "added_by": MEMORY_ADDED_BY,
        "source_file": src,
        "content": render_memory_run(snap),
    }]
    judgments = render_memory_judgments(snap, findings)
    if judgments:
        out.append({
            "kind": "judgment-calls",
            "wing": name,
            "room": MEMORY_ROOM,
            "added_by": MEMORY_ADDED_BY,
            "source_file": src.replace(".md", ".judgments.md"),
            "content": judgments,
        })
    out.append({
        "kind": "cross-target-lessons",
        "wing": MEMORY_CROSS_WING,
        "room": MEMORY_CROSS_ROOM,
        "added_by": MEMORY_ADDED_BY,
        "source_file": src.replace(".md", ".lessons.md"),
        "content": render_memory_lessons(snap, findings),
    })
    return out


# --------------------------------------------------------------------------
# commands
# --------------------------------------------------------------------------

def load_json(path):
    with open(path) as fh:
        return json.load(fh)


def cmd_write(args):
    inventory = load_json(args.from_files[0])
    scan = load_json(args.from_files[1])
    classified = load_json(args.classified)
    issue_map = load_json(args.issue_map) if args.issue_map else {}
    explicit = load_json(args.signatures) if args.signatures else {}

    run_id = args.run_id or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    history = load_snapshots(args.store, args.target)
    if any(s.get("run_id") == run_id for s in history):
        sys.exit(f"error: run_id {run_id} already recorded for {args.target}")

    snap = build_snapshot(args.target, inventory, scan, classified,
                          issue_map, explicit, run_id, args.repo, args.note)
    path = append_snapshot(args.store, args.target, snap)
    history.append(snap)
    pages = regenerate(args.store, args.target, history)
    return {"run_id": run_id, "snapshots": path, "pages": pages,
            "candidates": len(snap["candidates"]),
            "boundary_questions": len(snap["boundary_questions"])}


def cmd_list(args):
    history = load_snapshots(args.store, args.target)
    return {
        "target": args.target,
        "runs": [{
            "run_id": s.get("run_id"),
            "target_version": s.get("target", {}).get("version"),
            "rubric_version": s.get("rubric_version"),
            "window": s.get("window", {}),
            "coverage": s.get("coverage", {}).get("ratio"),
            "weighted_tokens": s.get("totals", {}).get("weighted_tokens"),
            "candidates": len(s.get("candidates", [])),
            "boundary_questions": len(s.get("boundary_questions", [])),
        } for s in history],
    }


def cmd_diff(args):
    history = load_snapshots(args.store, args.target)
    if len(history) < 2:
        sys.exit(f"error: {args.target} has {len(history)} snapshot(s); diff needs 2")
    curr = history[-1]
    if args.against:
        prev = next((s for s in history if s.get("run_id") == args.against), None)
        if prev is None:
            sys.exit(f"error: no snapshot {args.against} for {args.target}")
    else:
        prev = history[-2]
    issue_state = load_json(args.issue_state) if args.issue_state else {}
    return diff_snapshots(prev, curr, issue_state)


def cmd_pages(args):
    """What to publish to the wiki, and with what content. The caller performs
    the writes — see the module docstring on why this script never does."""
    history = load_snapshots(args.store, args.target)
    if not history:
        sys.exit(f"error: no snapshots recorded for {args.target}")
    snap = history[-1]
    day = str(snap["run_id"])[:10]
    root = store_root(args.store)
    pages = [{
        "page": f"Data/{args.target}/snapshots.jsonl",
        "title": f"Data/{args.target}/snapshots.jsonl",
        "append_only": True,
        "local": snapshots_path(args.store, args.target),
    }, {
        "page": f"Audits/{args.target}/{day}",
        "title": f"Audits/{args.target}/{day}",
        "append_only": False,
        "local": os.path.join(audits_dir(args.store, args.target), f"{day}.md"),
    }, {
        "page": f"Audits/{args.target}/Latest",
        "title": f"Audits/{args.target}/Latest",
        "append_only": False,
        "local": os.path.join(audits_dir(args.store, args.target), "Latest.md"),
    }]
    for p in pages:
        with open(p["local"]) as fh:
            p["content"] = fh.read()
        p["local"] = os.path.relpath(p["local"], root)
    return {"target": args.target, "store": root, "run_id": snap["run_id"], "pages": pages}


def cmd_memory_queries(args):
    """What to search mempalace for before grading (FR-9.1). Runs before any
    snapshot exists, so it takes the inventory rather than the store."""
    inventory = load_json(args.inventory) if args.inventory else None
    history = load_snapshots(args.store, args.target)
    names = surface_names(inventory, history[-1] if history else None)
    return {
        "target": args.target,
        "wings": {"target": args.target, "cross_target": MEMORY_CROSS_WING},
        "rooms": {"target": MEMORY_ROOM, "cross_target": MEMORY_CROSS_ROOM},
        "queries": memory_queries(args.target, names),
        "advisory": ADVISORY_NOTE,
    }


def cmd_memory_notes(args):
    """What to write to mempalace after a run (FR-9.1). Same contract as
    `pages`: the caller makes the MCP calls with these payloads verbatim."""
    history = load_snapshots(args.store, args.target)
    if not history:
        sys.exit(f"error: no snapshots recorded for {args.target}")
    snap = history[-1]
    findings = (load_json(args.stable).get("stability_findings", []) if args.stable else [])
    return {
        "target": args.target,
        "run_id": snap["run_id"],
        "drawers": memory_drawers(snap, findings),
        "facts": memory_facts(snap, findings),
        "note": "Call add_drawer once per drawer and kg_add once per fact, with these "
                "fields verbatim. Do not summarize, reword, or add commentary — the "
                "content is already the exact text to store.",
    }


def cmd_import(args):
    """Seed the local store from a snapshots.jsonl fetched off the wiki, so a
    cold cache still diffs against real history."""
    with open(args.snapshots) as fh:
        incoming = [json.loads(line) for line in fh if line.strip()]
    # The only place a snapshot arrives from outside this script: a wiki page a
    # human can edit. Validate here rather than defensively everywhere after.
    for i, snap in enumerate(incoming, 1):
        missing = [k for k in ("run_id", "target", "window", "coverage", "totals",
                               "candidates", "boundary_questions") if k not in snap]
        if missing:
            sys.exit(f"error: {args.snapshots}:{i}: not a snapshot record, missing {missing}")
    existing = {s.get("run_id") for s in load_snapshots(args.store, args.target)}
    added = [s for s in incoming if s.get("run_id") not in existing]
    for snap in added:
        append_snapshot(args.store, args.target, snap)
    history = load_snapshots(args.store, args.target)
    pages = regenerate(args.store, args.target, history) if history else []
    return {"target": args.target, "imported": len(added), "skipped": len(incoming) - len(added),
            "runs": len(history), "pages": pages}


def main():
    ap = argparse.ArgumentParser(prog="audit-snapshot")
    ap.add_argument("--store", default=DEFAULT_STORE,
                    help=f"local mirror of the wiki store (default {DEFAULT_STORE})")
    ap.add_argument("--json", action="store_true", help="compact single-line JSON")
    sub = ap.add_subparsers(dest="cmd", required=True)

    w = sub.add_parser("write", help="append one snapshot and regenerate the audit pages")
    w.add_argument("--target", required=True)
    w.add_argument("--from", dest="from_files", nargs=2, required=True,
                   metavar=("INVENTORY", "SCAN"))
    w.add_argument("--classified", required=True, help="bin/boundary-classify output")
    w.add_argument("--issue-map", help='JSON {candidate_id: "owner/repo#N"}')
    w.add_argument("--signatures", help="JSON {candidate_id: [tool signature, ...]}")
    w.add_argument("--repo", help="target's repo, owner/repo")
    w.add_argument("--run-id", help="override the run id (tests; default is now, UTC)")
    w.add_argument("--note", action="append", default=[], metavar="TEXT",
                   help="add a note to the snapshot and its summary page; repeatable. "
                        "Carries filing outcomes the audit knows and this script does not, "
                        "notably FR-6.4's `filing: unavailable` and its reason")
    w.set_defaults(func=cmd_write)

    ls = sub.add_parser("list", help="list recorded runs")
    ls.add_argument("--target", required=True)
    ls.set_defaults(func=cmd_list)

    d = sub.add_parser("diff", help="diff the latest run against a prior one")
    d.add_argument("--target", required=True)
    d.add_argument("--against", help="run_id to compare against (default: previous)")
    d.add_argument("--issue-state", help='JSON {"owner/repo#N": "open"|"closed"}')
    d.set_defaults(func=cmd_diff)

    p = sub.add_parser("pages", help="emit the wiki pages to publish, with content")
    p.add_argument("--target", required=True)
    p.set_defaults(func=cmd_pages)

    mq = sub.add_parser("memory-queries",
                        help="emit the mempalace searches to run before grading (FR-9)")
    mq.add_argument("--target", required=True)
    mq.add_argument("--inventory", help="bin/plugin-inventory output, for skill/agent names")
    mq.set_defaults(func=cmd_memory_queries)

    mn = sub.add_parser("memory-notes",
                        help="emit the mempalace drawers and facts to write, with content (FR-9)")
    mn.add_argument("--target", required=True)
    mn.add_argument("--stable", help="bin/stability-classify output, for stability_findings")
    mn.set_defaults(func=cmd_memory_notes)

    i = sub.add_parser("import", help="seed the store from a wiki-fetched snapshots.jsonl")
    i.add_argument("--target", required=True)
    i.add_argument("--snapshots", required=True)
    i.set_defaults(func=cmd_import)

    args = ap.parse_args()
    result = args.func(args)
    json.dump(result, sys.stdout, indent=None if args.json else 2)
    sys.stdout.write("\n")


if __name__ == "__main__":
    main()
