#!/usr/bin/env python3
"""
RECEIPT  --  the check you run yourself.  (RECLAiM, 2026-10-09)

What it proves, on YOUR machine, about what RECLAiM did with YOUR export -- measured, not promised:

  NEVER SPIES    Before the tool runs, this script shuts every network door in the Python process
                 (socket.socket, create_connection, getaddrinfo, gethostbyname). Any attempt to reach
                 the internet while the tool runs is refused and counted. The receipt prints the count.
  NEVER FORGETS  This script counts every message in your export with its own walk (the same declared
                 rule as the tool: a message is kept when it has text or an attachment and a speaker),
                 then counts every message the tool wrote. Both numbers go on the receipt, and any
                 conversation where they differ is listed.
  NEVER LIES     Every message the tool wrote is matched word for word against a message in your
                 export (SHA-256 of the text after the tool's declared cleaning), and its timestamp
                 against the export's. Any text or date the tool did not take from your file is
                 listed. Every output file's integrity hash is recomputed from your export and compared.

Run:   python RECEIPT.py <your unzipped export folder>
       (or double-click RUN_RECEIPT.bat on Windows / RUN_RECEIPT.command on a Mac)
Writes <export>/output_v2_receipt/ (your record, rebuilt by the tool under watch) and RECEIPT.txt
inside it, then prints the receipt. Exit code 0 only when all three held.
Nothing leaves your machine. No name, no account, no machine identity is written anywhere.
       python RECEIPT.py --self-test   proves the check itself on a made-up export, including a
                                       forged message and a blocked network call.
"""
import hashlib, io, json, os, platform, runpy, socket, sys, tempfile, time
from datetime import datetime, timezone
from pathlib import Path

HERE = Path(__file__).resolve().parent
TOOL = HERE / "reclaim_v2.py"
RECEIPT_VERSION = "1.0.1"
NET_ATTEMPTS = []          # every refused reach for the network, with the door it tried


# ---------------------------------------------------------------- the network, shut
def shut_the_network():
    """Replace every socket door with one that refuses and counts. Returns the originals."""
    originals = {k: getattr(socket, k) for k in ("socket", "create_connection", "getaddrinfo", "gethostbyname", "gethostbyname_ex", "socketpair")}

    class ShutSocket(originals["socket"]):
        def __init__(self, *a, **k):
            NET_ATTEMPTS.append("socket.socket")
            raise OSError("RECEIPT: the network is shut while the tool runs")

    def refuse(door):
        def _r(*a, **k):
            NET_ATTEMPTS.append(door)
            raise OSError("RECEIPT: the network is shut while the tool runs (%s)" % door)
        return _r

    socket.socket = ShutSocket
    for door in ("create_connection", "getaddrinfo", "gethostbyname", "gethostbyname_ex", "socketpair"):
        setattr(socket, door, refuse("socket." + door))
    return originals


def open_the_network(originals):
    for k, v in originals.items():
        setattr(socket, k, v)


# ---------------------------------------------------------------- your export, counted by this script
def _tool():
    """The tool's own declared cleaning and attachment rule, imported so the receipt matches by the tool's
    stated definitions (the WALK and the COUNT below are this script's own)."""
    sys.path.insert(0, str(HERE))
    import reclaim_v2
    return reclaim_v2


def walk_chatgpt(convo, tool):
    """Every message node in the whole tree (every branch), kept when it has a speaker and text or an attachment."""
    roles = {"user", "assistant", "system", "tool"}
    out = []
    for node in (convo.get("mapping") or {}).values():
        msg = node.get("message")
        if not msg:
            continue
        if (msg.get("author") or {}).get("role") not in roles:
            continue
        parts = (msg.get("content") or {}).get("parts") or []
        body = tool.clean_text("\n".join(p for p in parts if isinstance(p, str)).strip())
        assets = tool._walk_for_asset_ids([p for p in parts if not isinstance(p, str)]) + tool._walk_for_asset_ids(msg.get("metadata"))
        if not (body or assets):
            continue
        ts = msg.get("create_time") or node.get("create_time")
        out.append((hashlib.sha256(body.encode("utf-8")).hexdigest(), tool.ts_to_iso(ts), (msg.get("author") or {}).get("role")))
    return out


def walk_claude(convo, tool):
    out = []
    for raw in convo.get("chat_messages") or []:
        if raw.get("sender") not in ("human", "assistant"):
            continue
        content = raw.get("content")
        if isinstance(content, list) and content:
            body = tool.clean_text("\n".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text" and b.get("text")).strip())
            assets = tool._walk_for_asset_ids([b for b in content if isinstance(b, dict) and b.get("type") != "text"])
        else:
            body = tool.clean_text((raw.get("text") or "").strip())
            assets = []
        atts = raw.get("attachments") or raw.get("files")      # a message that is only an uploaded file is still a message
        if atts:
            assets = assets + tool._walk_for_asset_ids(atts)
        if not (body or assets):
            continue
        out.append((hashlib.sha256(body.encode("utf-8")).hexdigest(), raw.get("created_at"), raw.get("sender")))
    return out


def record_hash(record):
    """Recomputed here, independently: SHA-256 of the record as canonical JSON (sorted keys, no spaces)."""
    return hashlib.sha256(json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")).hexdigest()


def read_export(export_dir):
    """The same files the tool reads, in the same order. Returns (files, records)."""
    export_dir = Path(export_dir)
    files = sorted(export_dir.glob("conversations-*.json")) or ([export_dir / "conversations.json"] if (export_dir / "conversations.json").exists() else [])
    records = []
    for f in files:
        with open(f, encoding="utf-8", errors="replace") as fh:
            data = json.load(fh)
        records.extend(data if isinstance(data, list) else [data])
    return files, records


def sha256_file(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


# ---------------------------------------------------------------- the run, under watch
def run_tool_shut(export_dir, out_dir):
    """Run reclaim_v2.py in this process with every network door shut. Returns (exit_code, seconds, captured stdout)."""
    originals = shut_the_network()
    argv, stdout = sys.argv, sys.stdout
    buf = io.StringIO()
    code, t0 = 0, time.time()
    try:
        sys.argv = [str(TOOL), str(export_dir), "--output", str(out_dir)]
        sys.stdout = buf
        try:
            runpy.run_path(str(TOOL), run_name="__main__")
        except SystemExit as e:
            code = int(e.code or 0) if not isinstance(e.code, str) else 1
    finally:
        sys.argv, sys.stdout = argv, stdout
        open_the_network(originals)
    return code, time.time() - t0, buf.getvalue()


# ---------------------------------------------------------------- the check
def check(export_dir, out_dir, tool):
    files, records = read_export(export_dir)
    platform_guess = tool.detect_platform(records[0]) if records else "unknown"
    walker = walk_chatgpt if platform_guess == "chatgpt" else walk_claude
    expected = {}                      # record index (1-based) -> list of (hash, ts, role)
    roles = {}
    for i, rec in enumerate(records, start=1):
        msgs = walker(rec, tool)
        expected[i] = msgs
        for _h, _t, r in msgs:
            roles[r] = roles.get(r, 0) + 1
    written = sorted(Path(out_dir).glob("*.json"))
    per_file, unmatched, hash_bad, count_bad = [], [], [], []
    total_written = 0
    for jf in written:
        try:
            idx = int(jf.name[:3])
        except ValueError:
            continue
        payload = json.loads(jf.read_text(encoding="utf-8"))
        msgs = payload.get("messages") or []
        total_written += len(msgs)
        exp = expected.get(idx, [])
        pool = {}
        for h, t, _r in exp:
            pool[(h, t)] = pool.get((h, t), 0) + 1
        bad_here = 0
        for n, m in enumerate(msgs, start=1):
            key = (hashlib.sha256((m.get("body") or "").encode("utf-8")).hexdigest(), m.get("timestamp"))
            if pool.get(key, 0) > 0:
                pool[key] -= 1
            else:
                bad_here += 1
                unmatched.append((jf.name, n, (m.get("body") or "")[:60].replace("\n", " "), m.get("timestamp")))
        if len(msgs) != len(exp):
            count_bad.append((jf.name, len(exp), len(msgs)))
        src = (payload.get("metadata") or {}).get("source_hash", "")
        if idx <= len(records) and src != "sha256:" + record_hash(records[idx - 1]):
            hash_bad.append(jf.name)
        per_file.append((jf.name, len(exp), len(msgs), bad_here))
    # conversations the tool wrote nothing for, that your export holds messages in
    written_idx = {int(j.name[:3]) for j in written if j.name[:3].isdigit()}
    for i, msgs in expected.items():
        if msgs and i not in written_idx:
            count_bad.append(("%03d_(no file written)" % i, len(msgs), 0))
    return {
        "files": files, "records": len(records), "platform": platform_guess,
        "expected_total": sum(len(v) for v in expected.values()), "roles": roles,
        "expected_convs_with_messages": sum(1 for v in expected.values() if v),
        "written_files": len(written), "written_total": total_written,
        "count_bad": count_bad, "unmatched": unmatched, "hash_bad": hash_bad, "per_file": per_file,
    }


def receipt_text(export_dir, out_dir, r, code, secs, tool_out):
    now = datetime.now(timezone.utc)
    spies_ok = len(NET_ATTEMPTS) == 0
    forgets_ok = not r["count_bad"] and r["expected_total"] == r["written_total"]
    lies_ok = not r["unmatched"] and not r["hash_bad"]
    held = spies_ok and forgets_ok and lies_ok and code == 0
    L = ["RECEIPT -- what RECLAiM did with your export, checked on your own machine",
         "=" * 76,
         "when (UTC):        %s" % now.strftime("%Y-%m-%d %H:%M:%S"),
         "when (this clock): %s" % datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"),
         "export folder:     %s" % export_dir,
         "rebuilt into:      %s" % out_dir,
         "python:            %s on %s" % (platform.python_version(), platform.system()),
         "the tool:          reclaim_v2.py  sha256 %s" % sha256_file(TOOL),
         "this receipt:      RECEIPT.py v%s  sha256 %s" % (RECEIPT_VERSION, sha256_file(Path(__file__))),
         "tool exit code:    %d   (%.1f s)" % (code, secs),
         "",
         "WHAT YOU GAVE IT"]
    for f in r["files"]:
        L.append("  %s  %s bytes  sha256 %s" % (f.name, "{:,}".format(f.stat().st_size), sha256_file(f)))
    L += ["  platform read from the file: %s" % r["platform"],
          "  conversations in your file: %d (%d hold at least one message)" % (r["records"], r["expected_convs_with_messages"]),
          "  messages in your file, counted by this receipt: %s   (%s)" % ("{:,}".format(r["expected_total"]), ", ".join("%s %s" % (k, "{:,}".format(v)) for k, v in sorted(r["roles"].items()))),
          "",
          "NEVER SPIES -- %s" % ("HELD" if spies_ok else "NOT HELD"),
          "  network doors shut before the tool ran: socket, create_connection, getaddrinfo, gethostbyname, gethostbyname_ex, socketpair",
          "  attempts to reach the network during the run: %d%s" % (len(NET_ATTEMPTS), ("  -- " + ", ".join(sorted(set(NET_ATTEMPTS)))) if NET_ATTEMPTS else ""),
          "",
          "NEVER FORGETS -- %s" % ("HELD" if forgets_ok else "NOT HELD"),
          "  messages the tool wrote: %s in %d files" % ("{:,}".format(r["written_total"]), r["written_files"]),
          "  messages in your file:   %s" % "{:,}".format(r["expected_total"]),
          ]
    if r["count_bad"]:
        L.append("  conversations where the counts differ (file, in your export, written):")
        for name, e, w in r["count_bad"][:50]:
            L.append("    %s  %d -> %d" % (name, e, w))
        if len(r["count_bad"]) > 50:
            L.append("    ... and %d more" % (len(r["count_bad"]) - 50))
    else:
        L.append("  every conversation: the same count in your file and in what was written")
    L += ["",
          "NEVER LIES -- %s" % ("HELD" if lies_ok else "NOT HELD"),
          "  messages matched word for word (text + timestamp) to your file: %s of %s" % ("{:,}".format(r["written_total"] - len(r["unmatched"])), "{:,}".format(r["written_total"])),
          "  integrity hashes recomputed from your file and matched: %d of %d" % (r["written_files"] - len(r["hash_bad"]), r["written_files"]),
          ]
    if r["unmatched"]:
        L.append("  text or dates the tool did NOT take from your file (file, message #, first words, timestamp):")
        for name, n, first, ts in r["unmatched"][:50]:
            L.append("    %s  #%d  %r  %s" % (name, n, first, ts))
        if len(r["unmatched"]) > 50:
            L.append("    ... and %d more" % (len(r["unmatched"]) - 50))
    if r["hash_bad"]:
        L.append("  files whose integrity hash does not match your file: " + ", ".join(r["hash_bad"][:20]))
    L += ["",
          "VERDICT: %s" % ("ALL THREE HELD -- what the tool wrote is your file, whole, with no call out and nothing added" if held else "NOT HELD -- see the lines above; the receipt says where"),
          "",
          "How to re-check any line: open the file named, find the message, compare. The hashes are SHA-256; any tool computes them.",
          "The tool's own closing words follow, as it printed them:",
          ] + ["  " + ln for ln in tool_out.strip().splitlines()[-12:]]
    return "\n".join(L) + "\n", held


def run_receipt(export_dir, out_dir=None, quiet=False):
    export_dir = Path(export_dir).expanduser().resolve()
    out_dir = Path(out_dir).resolve() if out_dir else export_dir / "output_v2_receipt"
    tool = _tool()
    del NET_ATTEMPTS[:]
    code, secs, tool_out = run_tool_shut(export_dir, out_dir)
    r = check(export_dir, out_dir, tool)
    text, held = receipt_text(export_dir, out_dir, r, code, secs, tool_out)
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "RECEIPT.txt").write_text(text, encoding="utf-8")
    if not quiet:
        print(text)
        print("written: %s" % (out_dir / "RECEIPT.txt"))
    return held, text, r


# ---------------------------------------------------------------- the check, checked
def self_test():
    n = [0, 0]
    def ok(name, cond):
        n[1] += 1; n[0] += 1 if cond else 0
        print("  %s  %s" % ("PASS" if cond else "FAIL", name))
    d = Path(tempfile.mkdtemp(prefix="receipt_"))
    # a ChatGPT-shaped export: one conversation with a regenerated branch, a tool message, an empty system node; one empty conversation
    def node(id_, role, parts, t, parent=None, children=()):
        return {"id": id_, "parent": parent, "children": list(children), "message": {"id": id_, "author": {"role": role}, "create_time": t, "content": {"content_type": "text", "parts": parts}, "metadata": {}}}
    gpt = [{"title": "Two branches", "create_time": 1700000000, "update_time": 1700000100, "mapping": {
                "root": {"id": "root", "parent": None, "children": ["s0"], "message": None},
                "s0": node("s0", "system", [""], 1700000001, "root", ["u1"]),
                "u1": node("u1", "user", ["what is a receipt"], 1700000002, "s0", ["a1", "a2"]),
                "a1": node("a1", "assistant", ["a receipt is proof, first try"], 1700000003, "u1"),
                "a2": node("a2", "assistant", ["a receipt is proof, regenerated"], 1700000004, "u1", ["t1"]),
                "t1": node("t1", "tool", ["{\"ran\": true}"], 1700000005, "a2")}},
           {"title": "Empty one", "create_time": 1700000200, "update_time": 1700000200, "mapping": {"root": {"id": "root", "parent": None, "children": [], "message": None}}}]
    (d / "conversations.json").write_text(json.dumps(gpt), encoding="utf-8")
    held, text, r = run_receipt(d, d / "out", quiet=True)
    ok("a ChatGPT export: both branches, the tool message and no empty node -- 4 messages in the file, 4 written, 1 file, all three held",
       held and r["expected_total"] == 4 and r["written_total"] == 4 and r["written_files"] == 1 and "ALL THREE HELD" in text)
    ok("the receipt names the hashes of the tool and of itself, and counts zero network attempts", "reclaim_v2.py  sha256" in text and "RECEIPT.py v" in text and "attempts to reach the network during the run: 0" in text)
    # a forged message in the output: one word changed
    jf = sorted((d / "out").glob("*.json"))[0]
    p = json.loads(jf.read_text(encoding="utf-8")); p["messages"][0]["body"] = p["messages"][0]["body"] + " (not in your file)"
    jf.write_text(json.dumps(p), encoding="utf-8")
    tool = _tool(); r2 = check(d, d / "out", tool); t2, held2 = receipt_text(d, d / "out", r2, 0, 0.0, "")
    ok("one forged word in the output is caught: NOT HELD, 1 unmatched, the file and message # named", not held2 and len(r2["unmatched"]) == 1 and r2["unmatched"][0][1] == 1 and "NEVER LIES -- NOT HELD" in t2)
    # a message deleted from the output: the count differs
    p["messages"] = p["messages"][1:]; jf.write_text(json.dumps(p), encoding="utf-8")
    r3 = check(d, d / "out", tool); t3, held3 = receipt_text(d, d / "out", r3, 0, 0.0, "")
    ok("a message missing from the output is caught: counts 4 -> 3 named, NOT HELD", not held3 and r3["count_bad"] and r3["count_bad"][0][1] == 4 and r3["count_bad"][0][2] == 3 and "NEVER FORGETS -- NOT HELD" in t3)
    # the network door: shut means shut, and counted
    del NET_ATTEMPTS[:]
    o = shut_the_network()
    try:
        try:
            socket.create_connection(("example.com", 80), timeout=1)
        except OSError:
            pass
        try:
            socket.socket()
        except OSError:
            pass
    finally:
        open_the_network(o)
    ok("with the doors shut, a reach for the network is refused and counted (2 attempts, 2 doors named), and the doors are given back after", NET_ATTEMPTS == ["socket.create_connection", "socket.socket"] and socket.socket is o["socket"])
    del NET_ATTEMPTS[:]
    # a Claude-shaped export: text blocks kept, a tool_use block is not text, the flat text field is a fallback only
    cl = [{"uuid": "c1", "name": "Claude one", "created_at": "2026-05-01T10:00:00Z", "updated_at": "2026-05-01T10:01:00Z", "chat_messages": [
            {"uuid": "m1", "sender": "human", "created_at": "2026-05-01T10:00:00Z", "text": "hello there", "content": [{"type": "text", "text": "hello there"}]},
            {"uuid": "m2", "sender": "assistant", "created_at": "2026-05-01T10:00:30Z", "text": "RENDERED", "content": [{"type": "tool_use", "name": "x"}, {"type": "text", "text": "hi, from the blocks"}]},
            {"uuid": "m3", "sender": "human", "created_at": "2026-05-01T10:01:00Z", "text": "", "content": [], "attachments": [{"id": "019d0000-0000-7000-8000-000000000001", "file_name": "photo.png", "file_type": "image/png"}]}]}]
    d2 = Path(tempfile.mkdtemp(prefix="receipt_cl_")); (d2 / "conversations.json").write_text(json.dumps(cl), encoding="utf-8")
    held4, t4, r4 = run_receipt(d2, d2 / "out", quiet=True)
    ok("a Claude export: 2 text messages from the content blocks plus 1 message that is only an uploaded file -- 3 counted, 3 written, all three held", held4 and r4["platform"] == "claude" and r4["expected_total"] == 3 and r4["written_total"] == 3)
    print("\n  SELF-TEST: %d of %d" % (n[0], n[1]))
    return 0 if n[0] == n[1] else 1


if __name__ == "__main__":
    a = sys.argv[1:]
    if "--self-test" in a:
        sys.exit(self_test())
    if not a or a[0].startswith("-"):
        print(__doc__); sys.exit(2)
    out = None
    if "--output" in a:
        out = a[a.index("--output") + 1]
    held, _t, _r = run_receipt(a[0], out)
    sys.exit(0 if held else 1)
