#!/usr/bin/env python3
"""ONE-COMMAND live capture for the seller-facing sold-row A/B.

Run while FIFA 17 sits on the Transfer List against the STAGING stack. Captures,
labels and cross-checks everything at once, so a variant can never be half-recorded
or mixed with the other:

  1. staging wire surfaces (/tradePile, /tradePile/counts, /trade/status);
  2. the client's OWN auction record, decoded read-only out of /proc/<pid>/mem --
     STATE, YOURBID, COINS_AWARDED, MIN_CREDITS, IS_GLOW, INBOX, CARD_OFFERSTATE;
  3. the staging host's route-log DELTA since the previous capture, which is how a
     client-issued `DELETE .../trade/sold` is OBSERVED rather than assumed;
  4. a WIRE-vs-MEMORY cross-check of every shared field, plus the native IS_GLOW /
     INBOX formulas recomputed from the wire.

Point 4 is the discipline that matters. It validates the observation mechanism
against a known-positive in the same run: if the wire says `bidState: "highest"` and
the client's memory decodes `2(highest)`, the probe is demonstrably reading the right
struct THIS time. Earlier sessions were misled twice by unvalidated negatives -- a
sampler bug that printed "countdown NO", and empty auction containers read while the
Transfer List was not bound. An empty read is not an empty pile.

    python3 scripts/sold-live-capture.py --variant A_highest --label pre-clear
"""
import argparse
import hashlib
import http.client
import json
import os
import re
import subprocess
import sys
import time

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STAGING_DIR = "/home/alex/openfut-sold-staging"
HOST_PORT = 8299
CLIENT = "alex@10.10.0.105"
PROBE = "/tmp/auction_record_probe.py"
CURSOR = os.path.join(STAGING_DIR, ".capture-cursor")
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}

SURFACES = {
    "tradePile": "/ut/game/fifa17/tradePile",
    "counts": "/ut/game/fifa17/tradePile/counts",
    "trade_status_unfiltered": "/ut/game/fifa17/trade/status",
}
TRADE_STATE = {"active": 1, "inactive": 2, "expired": 3, "closed": 4}
BID_STATE = {"none": 0, "outbid": 1, "highest": 2, "buyNow": 3}
ITEM_STATE = {"invalid": 0, "free": 1, "forSale": 5, "offered": 6}


def get(path):
    assert HOST_PORT not in FORBIDDEN, "refusing to contact a production port"
    c = http.client.HTTPConnection("127.0.0.1", HOST_PORT, timeout=20)
    c.request("GET", path, headers={"X-OpenFUT-Game": "fifa17"})
    r = c.getresponse()
    raw = r.read()
    c.close()
    body = json.loads(raw)
    sha = hashlib.sha256(
        json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
    return {"status": r.status, "body": body, "sha256": sha}


def probe_client():
    """Decode the client's live auction records. Never fabricates: no client, or an
    unbound container, is reported as-is."""
    r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT,
                        f"cd /tmp && python3 {PROBE} 2>&1"],
                       capture_output=True, text=True, timeout=120)
    text = r.stdout + r.stderr
    rows = []
    for m in re.finditer(
            r"\[\d+\]\s+rec=(\S+)\s+tradeId=(\d+)\s+state=(-?\d+)\((\w+)\)\s+"
            r"bid=(-?\d+)\((\w+)\)\s+buyNow=(\d+)\s+start=(\d+)\s+cur=(\d+)\s+"
            r"expires=(-?\d+)\s+coins=(\d+)\s+glow=(\d+)\s+inbox=(\d+)\s+watch=(\d+)"
            r"(?:.*?\n\s+item=\S+\s+CARD_ID=(\d+)\s+cardType=(\d+)\s+tradeable=(\d+)\s+"
            r"itemState=(-?\d+)\((\S+?)\)\s+rating=(\d+))?",
            text, re.S):
        g = m.groups()
        rows.append({
            "record": g[0], "tradeId": int(g[1]),
            "STATE_tradeState": f"{g[2]}({g[3]})",
            "YOURBID_bidState": f"{g[4]}({g[5]})",
            "MAX_CREDITS_buyNowPrice": int(g[6]),
            "RESERVEDPRICE_startingBid": int(g[7]),
            "MIN_CREDITS_currentBid": int(g[8]),
            "TIME_REMAINING_expires": int(g[9]),
            "COINS_AWARDED_coinsProcessed": int(g[10]),
            "IS_GLOW": int(g[11]),
            "INBOX": int(g[12]),
            "IS_WATCHED": int(g[13]),
            "CARD_ID": int(g[14]) if g[14] else None,
            "CARD_TYPE": int(g[15]) if g[15] else None,
            "tradeable": int(g[16]) if g[16] else None,
            "CARD_OFFERSTATE_itemState": f"{g[17]}({g[18]})" if g[17] else None,
            "rating": int(g[19]) if g[19] else None,
        })
    return {
        "client_running": "not running" not in text,
        "slide_control": "MATCH" if "FNV control=MATCH" in text else "UNVERIFIED",
        "rows": rows,
        "raw": text.strip(),
    }


def route_delta():
    """Staging host log lines since the previous capture: how a client-issued DELETE
    is observed rather than inferred."""
    log = os.path.join(STAGING_DIR, "logs", "utas-host.log")
    if not os.path.exists(log):
        return {"available": False, "lines": []}
    start = 0
    if os.path.exists(CURSOR):
        try:
            start = int(open(CURSOR).read().strip())
        except Exception:
            start = 0
    lines = open(log, errors="replace").read().splitlines()
    with open(CURSOR, "w") as f:
        f.write(str(len(lines)))
    new = lines[start:]
    return {
        "available": True, "from_line": start, "to_line": len(lines), "lines": new,
        "requests": [l for l in new if "route=" in l],
        "clear_or_delete": [l for l in new
                            if "clear-sold" in l or "market-cancel" in l],
    }


def cross_check(wire_row, mem_rows):
    out = []
    if not wire_row:
        return [{"field": "<no wire row>", "ok": False,
                 "note": "nothing on the wire to compare against"}]
    mem = next((m for m in mem_rows if m["tradeId"] == wire_row.get("tradeId")), None)
    if mem is None:
        return [{"field": "<no memory row>", "ok": False,
                 "note": "client holds no record for this tradeId -- it is probably not "
                         "on the Transfer List screen. An empty container is NOT an "
                         "empty pile; re-navigate and re-capture."}]

    def cmp(field, wire_val, mem_val, expect=None):
        ok = (mem_val == expect) if expect is not None else (wire_val == mem_val)
        out.append({"field": field, "wire": wire_val, "memory": mem_val, "ok": ok})

    ts, bs = wire_row.get("tradeState"), wire_row.get("bidState")
    cmp("tradeState/STATE", ts, mem["STATE_tradeState"],
        f"{TRADE_STATE.get(ts)}({ts})")
    cmp("bidState/YOURBID", bs, mem["YOURBID_bidState"], f"{BID_STATE.get(bs)}({bs})")
    cmp("currentBid/MIN_CREDITS", wire_row.get("currentBid"), mem["MIN_CREDITS_currentBid"])
    cmp("buyNowPrice/MAX_CREDITS", wire_row.get("buyNowPrice"), mem["MAX_CREDITS_buyNowPrice"])
    cmp("startingBid/RESERVEDPRICE", wire_row.get("startingBid"), mem["RESERVEDPRICE_startingBid"])
    cmp("expires/TIME_REMAINING", wire_row.get("expires"), mem["TIME_REMAINING_expires"])
    cmp("coinsProcessed/COINS_AWARDED", wire_row.get("coinsProcessed"),
        mem["COINS_AWARDED_coinsProcessed"])
    istate = wire_row.get("itemData", {}).get("itemState")
    cmp("itemState/CARD_OFFERSTATE", istate, mem["CARD_OFFERSTATE_itemState"],
        f"{ITEM_STATE.get(istate)}({istate})" if istate else None)
    exp_glow = int(bs != "none") if ts == "closed" else int(bs in ("outbid", "buyNow"))
    exp_inbox = int(bs in ("highest", "buyNow"))
    out.append({"field": "IS_GLOW (native formula)", "expected": exp_glow,
                "memory": mem["IS_GLOW"], "ok": mem["IS_GLOW"] == exp_glow})
    out.append({"field": "INBOX (native formula)", "expected": exp_inbox,
                "memory": mem["INBOX"], "ok": mem["INBOX"] == exp_inbox})
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--variant", required=True, help="A_highest | B_buyNow | C_cp0 | D_cp1")
    ap.add_argument("--label", default="capture", help="pre-clear | post-clear | reentry")
    ap.add_argument("--out", default=os.path.join(
        REPO, "docs/evidence", f"sold-ab-{time.strftime('%Y-%m-%d')}"))
    args = ap.parse_args()
    os.makedirs(args.out, exist_ok=True)

    print("=" * 74)
    print(f"== LIVE CAPTURE  variant={args.variant}  label={args.label}")
    print("=" * 74)
    banner = ""
    hlog = os.path.join(STAGING_DIR, "logs", "utas-host.log")
    if os.path.exists(hlog):
        for l in open(hlog, errors="replace"):
            if "sold-experiment" in l:
                banner = l.strip()
    print(f"  host banner : {banner or '(none)'}")

    surfaces = {n: get(p) for n, p in SURFACES.items()}
    rows = surfaces["tradePile"]["body"].get("auctionInfo", [])
    wire_row = rows[0] if rows else {}
    print(f"  wire        : total={surfaces['tradePile']['body'].get('total')} "
          f"tradeState={wire_row.get('tradeState')} bidState={wire_row.get('bidState')} "
          f"coinsProcessed={wire_row.get('coinsProcessed')}")
    print(f"  counts      : {json.dumps(surfaces['counts']['body'])}")

    mem = probe_client()
    print(f"  client      : running={mem['client_running']} slide={mem['slide_control']} "
          f"records={len(mem['rows'])}")
    for r in mem["rows"]:
        print(f"    tradeId={r['tradeId']} STATE={r['STATE_tradeState']} "
              f"YOURBID={r['YOURBID_bidState']} "
              f"COINS_AWARDED={r['COINS_AWARDED_coinsProcessed']} "
              f"MIN_CREDITS={r['MIN_CREDITS_currentBid']} IS_GLOW={r['IS_GLOW']} "
              f"INBOX={r['INBOX']} CARD_OFFERSTATE={r['CARD_OFFERSTATE_itemState']}")

    xc = cross_check(wire_row, mem["rows"])
    print("  cross-check (wire vs the client's own memory):")
    for c in xc:
        mark = "ok  " if c.get("ok") else "FAIL"
        print(f"    [{mark}] {c['field']:26s} "
              f"wire/exp={c.get('wire', c.get('expected'))!r} mem={c.get('memory')!r} "
              f"{c.get('note', '')}")
    validated = bool(mem["rows"]) and all(c.get("ok") for c in xc)
    print(f"  INSTRUMENTATION {'VALIDATED' if validated else 'NOT VALIDATED'} this run")

    routes = route_delta()
    print(f"  route delta : lines {routes.get('from_line')}..{routes.get('to_line')}, "
          f"{len(routes.get('requests', []))} request line(s)")
    for l in routes.get("requests", [])[-30:]:
        print(f"    {l}")
    if routes.get("clear_or_delete"):
        print("  *** CLEAR/DELETE observed:")
        for l in routes["clear_or_delete"]:
            print(f"    *** {l}")

    snap = {"variant": args.variant, "label": args.label,
            "captured": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "host_banner": banner, "surfaces": surfaces, "client_memory": mem,
            "cross_check": xc, "instrumentation_validated": validated,
            "route_delta": routes}
    path = os.path.join(args.out, f"live-{args.variant}-{args.label}.json")
    with open(path, "w") as f:
        json.dump(snap, f, indent=2, sort_keys=True)
    print(f"\n  wrote {os.path.relpath(path, REPO)}")
    return 0


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