""""Where's my answer?" lookup for the PoA Status Board: per-address answer, grading and payout status.

Zero network calls. Reads only files already on disk:
  data/epochs_live_raw.json   raw keyless GET /api/epochs reply for the live chain segment, saved by queue_panel.py
                              (it re-downloads only after a new close)
  ../clusters/data/index.json public answer index (txid -> author, epoch, ts, seg) kept by Cluster Lens
Writes a static, client-side site to lookup_site/ (index.html, meta.json, shards/NN.json). Browsers never call
minepoa.com: they fetch one meta file and one small shard for the address typed in.

Per answer and close (all straight from the public close record):
  pending  = the txid is a key in basis.pending at that close; panel = len(panel), grades = len(grades)
  settled  = the txid appears in basis.settled at that close (passed true/false, jury size)
  dropped  = pending at the previous close, neither pending nor settled now
Authors: settled rows carry "from"; pending rows do not, so their author comes from the public answer index.
Rows with no known author are counted on the page, never guessed.
Usage: python3 lookup.py [--raw FILE] [--index FILE] [--out DIR]
"""
import hashlib, json, os, sys

HERE = os.path.dirname(os.path.abspath(__file__))
POA = os.path.dirname(HERE)
RAW = os.path.join(HERE, "data", "epochs_live_raw.json")
INDEX = os.path.join(POA, "clusters", "data", "index.json")
OUT = os.path.join(HERE, "lookup_site")
SRC = os.path.join(HERE, "lookup_src")
NSHARDS = 64
SCHEMA = "poa-lookup/1"


def shard_of(addr, n=NSHARDS):
    """Same arithmetic as shardOf() in index.html; stays far below 2**53."""
    h = 0
    for ch in addr:
        h = (h * 33 + ord(ch)) % 1000003
    return h % n


def _amt(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return 0.0


def _epochs(doc):
    if isinstance(doc, dict):
        doc = doc.get("epochs") or []
    return sorted([e for e in doc if isinstance(e, dict) and "epoch" in e], key=lambda e: e["epoch"])


def build(doc, index, segment):
    """Raw close records + answer index -> (meta, {addr: record})."""
    eps = _epochs(doc)
    recs = {}

    def rec(a):
        r = recs.get(a)
        if r is None:
            r = recs[a] = {"answers": {}, "closes": {}}
        return r

    author, since = {}, {}
    for e in eps:
        for s in (e.get("basis") or {}).get("settled") or []:
            if isinstance(s, dict) and s.get("from"):
                author[s.get("answer")] = s["from"]
        for t, p in ((e.get("basis") or {}).get("pending") or {}).items():
            if isinstance(p, dict) and isinstance(p.get("since"), int):
                since.setdefault(t, p["since"])
    for t, v in index.items():
        if isinstance(v, dict) and v.get("from") and (v.get("seg") in (None, segment)) and t not in author:
            author.setdefault(t, v["from"])

    closes, prev, all_ids, unmapped_ids = [], None, set(), set()
    for e in eps:
        n, b = e["epoch"], e.get("basis") or {}
        pending = {t: p for t, p in (b.get("pending") or {}).items() if isinstance(p, dict)}
        settled = [s for s in (b.get("settled") or []) if isinstance(s, dict)]
        sids = {s.get("answer") for s in settled}
        dropped = (set(prev) - set(pending) - sids) if prev is not None else set()
        rewards = {k: _amt(v) for k, v in (e.get("rewards") or {}).items()}
        att = b.get("attestations") or {}
        est = set(b.get("established_after") or [])
        elig = set(b.get("challenge_eligible") or [])
        ref2 = ((b.get("referral2") or {}).get("paid")) or {}
        seats = {}
        for s in settled:
            for j in s.get("jury") or []:
                if isinstance(j, dict) and j.get("grader"):
                    seats[j["grader"]] = seats.get(j["grader"], 0) + 1
        npanel = sum(1 for p in pending.values() if p.get("panel"))
        ctx = {"epoch": n, "ts": e.get("ts"), "pending": len(pending), "pending_no_panel": len(pending) - npanel,
               "pending_panel": npanel, "settled": len(settled),
               "passed": sum(1 for s in settled if s.get("passed") is True),
               "failed": sum(1 for s in settled if s.get("passed") is False), "dropped": len(dropped),
               "paid": sum(1 for v in rewards.values() if v > 0), "established_after": len(est),
               "challenge_eligible": len(elig), "unmapped_rows": 0}
        rows = [(t, "p", p) for t, p in pending.items()] + [(s.get("answer"), "s", s) for s in settled] + \
               [(t, "d", None) for t in sorted(dropped)]
        for t, kind, x in rows:
            all_ids.add(t)
            a = author.get(t)
            if not a:
                ctx["unmapped_rows"] += 1
                unmapped_ids.add(t)
                continue
            ans = rec(a)["answers"].setdefault(t, {"since": since.get(t), "states": {}})
            if kind == "p":
                st = {"s": "pending", "panel": len(x.get("panel") or []), "grades": len(x.get("grades") or [])}
            elif kind == "s":
                st = {"s": "passed" if x.get("passed") else "failed", "jury": len(x.get("jury") or [])}
            else:
                st = {"s": "dropped"}
            ans["states"][str(n)] = st
            if ans["since"] is None:
                iv = index.get(t) or {}
                ans["since"] = iv.get("epoch") if isinstance(iv.get("epoch"), int) and iv.get("seg") == segment else n
        everyone = set(rewards) | set(att) | est | elig | set(ref2) | set(seats)
        for a in everyone:
            c = {}
            if a in rewards:
                c["paid"] = round(rewards[a], 6)
            if a in att:
                c["att"] = att[a]
            if a in est:
                c["est"] = 1
            if a in elig:
                c["elig"] = 1
            if a in ref2:
                c["ref2"] = round(_amt(ref2[a]), 6)
            if a in seats:
                c["seats"] = seats[a]
            rec(a)["closes"][str(n)] = c
        closes.append(ctx)
        prev = pending

    # answers in the public index for this segment that are in no close record yet (current epoch)
    last_ts = eps[-1].get("ts") if eps else None
    waiting = 0
    for t, v in index.items():
        if isinstance(v, dict) and v.get("seg") == segment and t not in all_ids and v.get("from"):
            if last_ts is None or (v.get("ts") or 0) > last_ts:
                rec(v["from"])["answers"][t] = {"since": v.get("epoch"), "states": {}, "ts": v.get("ts"), "open": 1}
                waiting += 1

    meta = {"schema": SCHEMA, "segment": segment, "closes": closes, "shards": NSHARDS,
            "answer_rows": len(all_ids), "unmapped_answers": len(unmapped_ids),
            "open_epoch_answers": waiting, "addresses": len(recs)}
    return meta, recs


def write(meta, recs, out, extra_meta=None):
    os.makedirs(os.path.join(out, "shards"), exist_ok=True)
    shards = {i: {} for i in range(meta["shards"])}
    for a, r in recs.items():
        shards[shard_of(a, meta["shards"])][a] = r
    changed = 0
    for i, d in shards.items():
        p = os.path.join(out, "shards", "%02d.json" % i)
        body = json.dumps(d, separators=(",", ":"), sort_keys=True)
        if not os.path.exists(p) or open(p).read() != body:
            open(p + ".tmp", "w").write(body)
            os.replace(p + ".tmp", p)
            changed += 1
    m = dict(meta, **(extra_meta or {}))
    body = json.dumps(m, separators=(",", ":"), sort_keys=True)
    p = os.path.join(out, "meta.json")
    if not os.path.exists(p) or open(p).read() != body:
        open(p, "w").write(body)
    return changed


def main(argv):
    raw, idxp, out = RAW, INDEX, OUT
    for flag in ("--raw", "--index", "--out"):
        if flag in argv:
            v = argv[argv.index(flag) + 1]
            raw, idxp, out = (v, idxp, out) if flag == "--raw" else (raw, v, out) if flag == "--index" else (raw, idxp, v)
    doc = json.load(open(raw))
    segment = doc.get("segment") if isinstance(doc, dict) else None
    segment = segment or "poa-mainnet-1-segment2"
    index = json.load(open(idxp)) if os.path.exists(idxp) else {}
    meta, recs = build(doc, index, segment)
    extra = {"records_read_ts": doc.get("read_ts") if isinstance(doc, dict) else None,
             "index_ts": int(os.stat(idxp).st_mtime) if os.path.exists(idxp) else None,
             "index_rows_this_segment": sum(1 for v in index.values() if isinstance(v, dict) and v.get("seg") == segment)}
    changed = write(meta, recs, out, extra)
    os.makedirs(os.path.join(out, "src"), exist_ok=True)
    for f in ("index.html",):
        body = open(os.path.join(SRC, f)).read()
        p = os.path.join(out, f)
        if not os.path.exists(p) or open(p).read() != body:
            open(p, "w").write(body)
    for f in ("lookup.py", os.path.join("tests", "test_lookup.py")):
        src = os.path.join(HERE, f)
        if os.path.exists(src):
            open(os.path.join(out, "src", os.path.basename(f)), "w").write(open(src).read())
    print("lookup: %d addresses, %d answer rows (%d without author), %d open-epoch answers, %d shard(s) changed"
          % (meta["addresses"], meta["answer_rows"], meta["unmapped_answers"], meta["open_epoch_answers"], changed))
    return 0


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