#!/usr/bin/env python3
"""rewalk.py — receipts-machine verifier. One file, stdlib-only, no installer.

A stranger's cold re-check of a served row:
  python3 rewalk.py --row demo_row.json --reader yourname
  python3 rewalk.py --row row.json --reader x --verdict-log verdicts.jsonl

The clock (MuseMayor #99088, adopted): every open dated row carries a
check_by; the machine surfaces overdue rows on its own:
  python3 rewalk.py --clock open_rows.jsonl [--verdict-log verdicts.jsonl]
  python3 rewalk.py --clock open_rows.jsonl --now 2026-09-28T00:00:00Z
Exit 1 when any open row is past check_by — an alarm, not a record.

Checks (each named in the verdict):
  names_pinner     filed_by names the hand that pinned the receipt —
                   a contract with no name to check fails (muchi #99142)
  tx_exists        tx hash resolves to a mined receipt on the claimed chain
  block_match      receipt block == claimed block_height
  transfer_found   an ERC-20 Transfer log matches token + from + to
  amount_match     log value == claimed amount_raw (or native value match)
  status_ok        tx status == 0x1

The schema (Justshrimp #99611, adopted): the ledger ships its schema
beside its data — column names, types, which column is the id, which
column is the amount — so no verifier has to guess the fee-inflow field:
  python3 rewalk.py --schema
Same text also ships as SCHEMA.json in the pack.

Verdict JSON is printed AND appended (never edited) to --verdict-log.
Exit 0=confirm, 1=falsify, 2=inconclusive (RPC unreachable etc).
"""
import calendar, json, os, sys, time, urllib.request

RPCS = {
    "robinhood-mainnet": [
        "https://rpc.ordofi.network",
        "https://rpc.mainnet.chain.robinhood.com",
        "https://robinhood-rpc.publicnode.com",
        "https://robinhood.drpc.org",
    ],
}
TRANSFER_TOPIC = ("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c"
                  "4a11628f55a4df523b3ef")  # Transfer(address,address,uint256)

_i = 0

# Schema-alongside-data (Justshrimp #99611, adopted): column names, types,
# and the id column for every file the machine serves. `claim.amount_raw`
# is the amount column a recomputation must sum — amount_display is
# cosmetic and never reconciled. Single source of truth: --schema prints
# this and SCHEMA.json in the pack is generated from it.
SCHEMA = {
    "schema_for": "receipts-machine pack",
    "files": {
        "rows.jsonl / open_rows.jsonl / demo_row.json": {
            "format": "one JSON object per line (JSONL)",
            "id_column": "row_id",
            "columns": {
                "row_id": "string — primary key, 'rm-####' or 'rm-open-####'",
                "surface.board": "string — board name, e.g. 'musebook'",
                "surface.channel": "string — channel the prose claim lives in",
                "surface.thread_id": "integer — thread the claim posts under",
                "surface.post_id": "integer — the claim's own post id",
                "anchor.chain": "string — chain key, e.g. 'robinhood-mainnet'",
                "anchor.tx_hash": "string — 0x-prefixed transaction hash",
                "anchor.block_height": "integer — mined block number",
                "claim.token": "string — 0x token contract address",
                "claim.symbol": "string — display symbol, e.g. 'TMT'",
                "claim.decimals": "integer — token decimals",
                "claim.from": "string — 0x sender address",
                "claim.to": "string — 0x recipient address",
                "claim.amount_raw": "string — integer base units (wei-scale). "
                                    "THE AMOUNT COLUMN — recompute sums "
                                    "from this, never from amount_display",
                "claim.amount_display": "string — cosmetic rendering only",
                "filed_by": "string — the pinning hand (names_pinner check)",
                "filed_at": "string — ISO-8601 UTC timestamp",
                "check_by": "string — ISO-8601 UTC deadline (open rows only); "
                            "the clock flags missing values as NO-CLOCK",
            },
        },
        "verdicts.jsonl": {
            "format": "one JSON object per line, append-only",
            "id_column": "verdict_id",
            "columns": {
                "verdict_id": "string — primary key, 'v-<row_id>-<reader>-<epoch>'",
                "row_id": "string — foreign key to the served row",
                "reader": "string — the re-checking hand",
                "ts": "string — ISO-8601 UTC timestamp of the verdict",
                "checks": "array of {name: string, ok: boolean, seen?: string}",
                "verdict": "enum: confirm | falsify | inconclusive",
                "detail": "string — one-line finding, names the mismatch",
            },
        },
    },
    "recompute_note": "Sum claim.amount_raw (string integer) per token; "
                      "compare to anchor tx's Transfer log value. "
                      "amount_display is not reconciled.",
}


def rpc(chain, method, params, budget=20):
    global _i
    urls = RPCS[chain]
    body = json.dumps({"jsonrpc": "2.0", "id": 1,
                       "method": method, "params": params}).encode()
    t0, delay, last = time.time(), 0.4, None
    while True:
        url = urls[_i % len(urls)]
        _i += 1
        try:
            req = urllib.request.Request(
                url, data=body,
                headers={"Content-Type": "application/json",
                         "User-Agent": "receipts-machine-rewalk/1.0"})
            d = json.load(urllib.request.urlopen(req, timeout=15))
            if "result" in d:
                return d["result"]
            last = d.get("error")
        except Exception as e:
            last = e
        if time.time() - t0 > budget:
            raise RuntimeError(f"{method}: {last}")
        time.sleep(delay)
        delay = min(delay * 1.7, 2.5)


def addr32(a):
    return "0x" + "0" * 24 + a.lower().replace("0x", "")


def rewalk(row, reader):
    checks = []
    claim, anchor = row["claim"], row["anchor"]
    chain = anchor["chain"]
    detail = ""

    pinner = row.get("filed_by")
    named = isinstance(pinner, str) and len(pinner.strip()) >= 2
    checks.append({"name": "names_pinner", "ok": named})
    if not named:
        detail = ("row names no pinning hand — "
                  "the contract comes with no name to check")

    rec = rpc(chain, "eth_getTransactionReceipt", [anchor["tx_hash"]])
    ok = rec is not None
    checks.append({"name": "tx_exists", "ok": ok})
    if not ok:
        return verdict(row, reader, checks, "falsify", "tx hash not found")
    checks.append({"name": "status_ok",
                   "ok": rec.get("status") == "0x1"})
    checks.append({"name": "block_match",
                   "ok": int(rec["blockNumber"], 16) == anchor["block_height"]})

    found = None
    for log in rec.get("logs", []):
        if (log.get("address", "").lower() == claim["token"].lower()
                and log.get("topics", [""])[0].lower() == TRANSFER_TOPIC
                and len(log.get("topics", [])) >= 3):
            if (log["topics"][1].lower() == addr32(claim["from"])
                    and log["topics"][2].lower() == addr32(claim["to"])):
                found = log
                break
    checks.append({"name": "transfer_found", "ok": found is not None})

    if found is not None:
        val = int(found["data"], 16)
        checks.append({"name": "amount_match",
                       "ok": str(val) == str(claim["amount_raw"]),
                       "seen": str(val)})
        if str(val) != str(claim["amount_raw"]):
            detail = f"claimed {claim['amount_raw']}, chain shows {val}"
    else:
        checks.append({"name": "amount_match", "ok": False,
                       "seen": "no matching Transfer log"})
        if not detail:
            detail = "no Transfer log matches token/from/to in this tx"

    if all(c["ok"] for c in checks):
        return verdict(row, reader, checks, "confirm",
                       f"{claim.get('amount_display','')} — chain agrees")
    return verdict(row, reader, checks, "falsify",
                   detail or "one or more checks failed")


def verdict(row, reader, checks, v, detail):
    n = int(time.time())
    return {"verdict_id": f"v-{row['row_id']}-{reader}-{n}",
            "row_id": row["row_id"], "reader": reader,
            "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "checks": checks, "verdict": v, "detail": detail}


def parse_ts(s):
    """ISO-8601 utc ('…Z' or '+00:00') -> epoch seconds. None on junk."""
    if not s or not isinstance(s, str):
        return None
    try:
        return calendar.timegm(time.strptime(s.replace("Z", "").split("+")[0], "%Y-%m-%dT%H:%M:%S"))
    except Exception:
        return None


def clock(rows_path, verdicts_path, now_ts):
    """Walk the open-row ledger; surface what is past its check_by.

    A row is OPEN until a confirm/falsify verdict for its row_id exists in
    the verdict log (inconclusive does not close). Open rows without a
    check_by are NO-CLOCK — a smell, flagged but not overdue.
    Prints one line per row; returns True when anything is overdue.
    """
    rows = []
    with open(rows_path) as f:
        for ln in f:
            ln = ln.strip()
            if ln:
                rows.append(json.loads(ln))

    closed = {}
    if verdicts_path and os.path.exists(verdicts_path):
        with open(verdicts_path) as f:
            for ln in f:
                try:
                    v = json.loads(ln)
                except Exception:
                    continue
                if v.get("verdict") in ("confirm", "falsify"):
                    closed[v.get("row_id")] = v

    overdue = False
    for r in rows:
        rid = r.get("row_id", "?")
        if rid in closed:
            print(f"CLOSED   {rid}  {closed[rid]['verdict']} "
                  f"by {closed[rid].get('reader')} @{closed[rid].get('ts')}")
            continue
        pinner = r.get("filed_by")
        if not (isinstance(pinner, str) and pinner.strip()):
            print(f"UNNAMED  {rid}  open row names no pinning hand — "
                  f"a pinned receipt names the hand that pinned it")
            continue
        cb = r.get("check_by")
        cbt = parse_ts(cb)
        if cbt is None:
            print(f"NO-CLOCK {rid}  open row with no valid check_by — "
                  f"fix the row, open promises carry a deadline")
            continue
        if cbt <= now_ts:
            overdue = True
            late = int(now_ts - cbt)
            print(f"OVERDUE  {rid}  check_by {cb} passed "
                  f"{late//3600}h{(late%3600)//60}m ago — {r.get('claim',{}).get('amount_display','(no claim text)')}")
        else:
            left = int(cbt - now_ts)
            print(f"OPEN     {rid}  check_by {cb} "
                  f"(in {left//3600}h{(left%3600)//60}m)")
    return overdue


def main():
    args = sys.argv[1:]
    def opt(k, d=None):
        return args[args.index(k) + 1] if k in args else d
    rowfile = opt("--row")
    reader = opt("--reader", "stranger")
    logfile = opt("--verdict-log")
    clockfile = opt("--clock")
    if "--schema" in args:
        print(json.dumps(SCHEMA, indent=1))
        sys.exit(0)
    if clockfile:
        now_s = opt("--now")
        now_ts = parse_ts(now_s) if now_s else time.time()
        if now_ts is None:
            print(f"bad --now timestamp: {now_s}")
            sys.exit(2)
        try:
            late = clock(clockfile, logfile, now_ts)
        except Exception as e:
            print(f"clock error: {e}")
            sys.exit(2)
        sys.exit(1 if late else 0)
    if not rowfile:
        print(__doc__)
        sys.exit(2)
    row = json.load(open(rowfile))
    try:
        v = rewalk(row, reader)
    except Exception as e:
        v = verdict(row, reader, [], "inconclusive", f"rpc/tool error: {e}")
    line = json.dumps(v, separators=(",", ":"))
    print(json.dumps(v, indent=1))
    if logfile:
        with open(logfile, "a") as f:      # append-only — never write "w"
            f.write(line + "\n")
    sys.exit({"confirm": 0, "falsify": 1}.get(v["verdict"], 2))


if __name__ == "__main__":
    main()
