#!/usr/bin/env python3
"""Agent Periodic Lab 奖池计算脚本（开源，只读链上数据，不需要私钥，不发任何交易）。

规则见 prize.md（build-100.com/lab/prize.md）。本脚本只用 Python 标准库。

  python3 prize_snapshot.py                 # 读到最新区块，打印两个阶段的当前状态和（预估）分配表
  python3 prize_snapshot.py --to-block N    # 只读到区块 N（复核历史状态）
  python3 prize_snapshot.py --csv out/      # 另存 stage1.csv / stage2.csv（地址、.ai 名字、份额、BNB、wei）
  python3 prize_snapshot.py --json          # 输出机器可读 JSON
  python3 prize_snapshot.py --rpc URL       # 指定 RPC（可多次）；默认用公共 BSC 节点轮换

数据来源（全部是链上事件，从部署区块扫到目标区块）：
  ElementNFT  PaidMint（抽卡收入）、TransferSingle/TransferBatch（持卡余额、天然元素铸造进度）
  Synthesis   Synthesized（首位发现者）
  NFTStaking  Staked/Unstaked（反应堆里的卡）
  LabMarket   Listed/Purchased/Cancelled（挂单中的卡，不计分）
  另外读：ElementNFT.weight(id)（权重，配置已锁定）、AgentGate.identityOf(addr)（.ai 身份）、SPACE ID handleOf（名字）

公共 RPC 对 eth_getLogs 有区块跨度和结果数量限制：脚本按跨度切片，失败时自动减半并换节点重试。
扫过的日志缓存在 .prize_logs_<chain>.json，下次只增量读取。
"""
import argparse, json, os, sys, time, urllib.request
from decimal import Decimal, getcontext
getcontext().prec = 60

# ---------------- 主网常量 ----------------
CHAIN = 56
DEPLOY_BLOCK = 129324654
NFT = "0xfe5ab5ce9b1d86a55d70e7d0573a14d559029c18"
SYN = "0xfc84639ae85b3b8fd49172a153510f02df79626b"
STK = "0xfb75069413e00eedb9db957b767a8ced3b9ae93b"
MKT = "0xd69e644dd0dbc22ce20448e1f8646d95e097ccdd"
LAB = "0xef07ea3debe4bdb583b83b504e094c5da1693a38"
GATE = "0x31b511eaf6b62c4419dad61484c66971c80c9fa8"
REG = "0x60b9262456de011f7d15a54cdd8c48baaa8b022f"           # SPACE ID .ai Agent ID
POOL = "0x3a7fc6235fe6f4f8fa45e32d584318b2e4c94963"          # PancakeSwap v3 LAB/BNB
DEPLOYER = "0x1ba7718e8b19f6fe04fc9361fd06533640dabc70"
TREASURY = "0x09b37c9b232b8e904e448163da17c1eedda19c77"
ZERO = "0x" + "0" * 40
TEAM = {DEPLOYER, TREASURY}
CONTRACTS = {NFT, SYN, STK, MKT, LAB, GATE, POOL, ZERO}
EXCLUDED = TEAM | CONTRACTS

PAYOUT_BPS = 9000                       # 90% 还给玩家
# 截止时间：北京时间 2027-06-30 24:00 / 2027-12-31 24:00（到点先到者为准，以截止前最后一个区块结算）
import datetime as _dt
_BJ = _dt.timezone(_dt.timedelta(hours=8))
DEADLINE1 = int(_dt.datetime(2027, 7, 1, tzinfo=_BJ).timestamp())
DEADLINE2 = int(_dt.datetime(2028, 1, 1, tzinfo=_BJ).timestamp())
BASE_CAP_TOTAL = 88_888                 # 天然元素 1–94 总量
# 阶段一的 46 个目标：22 种化合物 + 95–118 号 24 个超重元素（与 config.json 的 46 条配方产出一致）
STAGE1_TARGETS = list(range(95, 119)) + list(range(1001, 1023))

T_PAID = "0x08b01fdc54793deffe0d6d425f6442611eba848eeec2311f8803ef9176c39519"
T_TS = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62"
T_TB = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb"
T_SYN = "0x183dbdee062e296a2e1c31b044455ee04b121a69bd8be950434f1925ff16fdef"
T_STK = "0xb4caaf29adda3eefee3ad552a8e85058589bf834c7466cae4ee58787f70589ed"
T_UNS = "0x204fccf0d92ed8d48f204adb39b2e81e92bad0dedb93f5716ca9478cfb57de00"
T_LIST = "0xd0e2632082726e989c9a9cac6d495b05080e0d6836ee21e07618b32d5719a404"
T_BUY = "0xa9afa5eefb06859cd791e3fac7f6177f9d9734ac9c0116a8ce5959be7d0706fb"
T_CXL = "0x26deca31ff8139a06c52453ce8985d34f7648a6d9af1d283c4063d052c355a0f"
SEL_WEIGHT, SEL_IDENT, SEL_HANDLE = "0xf8e27f93", "0xc6345626", "0xc50a1514"
TOPICS = {NFT: [T_PAID, T_TS, T_TB], SYN: [T_SYN], STK: [T_STK, T_UNS], MKT: [T_LIST, T_BUY, T_CXL]}

RPCS = ["https://bsc-rpc.publicnode.com", "https://rpc-bsc.48.club", "https://bsc-dataseed.bnbchain.org"]
HERE = os.path.dirname(os.path.abspath(__file__))

# ---------------- RPC ----------------
_k = [0]
def rpc(method, params, tries=8):
    last = None
    for i in range(tries):
        u = RPCS[_k[0] % len(RPCS)]
        try:
            req = urllib.request.Request(u, json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(),
                                         {"content-type": "application/json", "user-agent": "lab-prize/1"})
            r = json.load(urllib.request.urlopen(req, timeout=30))
            if "error" in r: raise RuntimeError(f"{u}: {r['error']}")
            return r["result"]
        except Exception as e:
            last = e; _k[0] += 1; time.sleep(0.5 * (i + 1))
    raise last

def get_logs(address, topics, frm, to, span=4900):
    """按跨度切片读取；节点报错（跨度/结果过多/限流）时跨度减半并换节点，直到 1 个区块。"""
    out, a = [], frm
    while a <= to:
        b = min(to, a + span - 1)
        try:
            out += rpc("eth_getLogs", [{"address": address, "topics": [topics], "fromBlock": hex(a), "toBlock": hex(b)}], tries=3)
            a = b + 1
            if span < 4900: span = min(4900, span * 2)
        except Exception as e:
            if span == 1: raise RuntimeError(f"区块 {a} 的日志读取失败：{e}")
            span = max(1, span // 2)
    return out

def call(to, data, block="latest"):
    return rpc("eth_call", [{"to": to, "data": data}, block])

def word(x): return x[2:].rjust(64, "0") if isinstance(x, str) else hex(x)[2:].rjust(64, "0")
def u256(h, i=0): return int(h[2 + 64 * i: 2 + 64 * (i + 1)], 16)
def addr_topic(t): return "0x" + t[-40:]

# ---------------- 日志（带缓存） ----------------
def load_logs(to_block, cache_path):
    c = {"hi": DEPLOY_BLOCK - 1, "logs": []}
    if cache_path and os.path.exists(cache_path):
        try: c = json.load(open(cache_path))
        except Exception: pass
    if c["hi"] < to_block:
        sys.stderr.write(f"读取日志 {c['hi'] + 1} → {to_block} …\n")
        addrs = list(TOPICS); tps = [t for v in TOPICS.values() for t in v]
        if True:
            for lg in get_logs(addrs, tps, c["hi"] + 1, to_block):
                c["logs"].append([int(lg["blockNumber"], 16), int(lg["logIndex"], 16), lg["address"].lower(), lg["topics"], lg["data"], lg["transactionHash"]])
        c["hi"] = to_block
        if cache_path: json.dump(c, open(cache_path, "w"))
    logs = [l for l in c["logs"] if l[0] <= to_block]
    logs.sort(key=lambda l: (l[0], l[1]))
    seen, uniq = set(), []
    for l in logs:
        k = (l[0], l[1])
        if k not in seen: seen.add(k); uniq.append(l)
    return uniq

# ---------------- 状态重建 ----------------
def replay(logs, stop_block=None, d1_block=None, d2_block=None):
    """按 (区块, logIndex) 顺序重放事件。stop_block 之后的事件不计入余额/质押/挂单（用于阶段二快照）。"""
    S = {"draws": [], "first": {}, "t1": None, "natural_minted": 0, "t2": None,
         "bal": {}, "staked": {}, "listings": {}}
    tgt = set(STAGE1_TARGETS)
    def add(a, i, v):
        if v: d = S["bal"].setdefault(a, {}); d[i] = d.get(i, 0) + v
    for bn, li, ad, tp, data, tx in logs:
        t0 = tp[0]
        if ad == NFT and t0 == T_PAID:
            paid_in_token = u256(data, 1) != 0
            if not paid_in_token: S["draws"].append((bn, u256(data, 2), addr_topic(tp[1])))
            continue
        if ad == SYN and t0 == T_SYN:
            user, out = addr_topic(tp[1]), u256(data, 0)
            if user in EXCLUDED or out not in tgt or out in S["first"]: continue
            if d1_block is not None and bn > d1_block: continue   # 阶段一已截止
            S["first"][out] = {"user": user, "bn": bn, "li": li, "tx": tx}
            if S["t1"] is None and len(S["first"]) == len(tgt): S["t1"] = bn
            continue
        frozen = stop_block is not None and bn > stop_block
        if ad == NFT and t0 in (T_TS, T_TB):
            frm, to = addr_topic(tp[2]), addr_topic(tp[3])
            if t0 == T_TS: pairs = [(u256(data, 0), u256(data, 1))]
            else:
                o_ids, o_vals = u256(data, 0) // 32, u256(data, 1) // 32
                n = u256(data, o_ids); ids = [u256(data, o_ids + 1 + k) for k in range(n)]
                m = u256(data, o_vals); vals = [u256(data, o_vals + 1 + k) for k in range(m)]
                pairs = list(zip(ids, vals))
            for i, v in pairs:
                if frm == ZERO and 1 <= i <= 94:
                    S["natural_minted"] += v
                    if S["t2"] is None and S["natural_minted"] >= BASE_CAP_TOTAL: S["t2"] = bn
                if frozen: continue
                if frm != ZERO: add(frm, i, -v)
                if to != ZERO: add(to, i, v)
            continue
        if frozen: continue
        if ad == STK and t0 in (T_STK, T_UNS):
            user, i, v = addr_topic(tp[1]), u256(data, 0), u256(data, 1)
            d = S["staked"].setdefault(user, {}); d[i] = d.get(i, 0) + (v if t0 == T_STK else -v)
        elif ad == MKT and t0 == T_LIST:
            S["listings"][int(tp[1], 16)] = {"seller": addr_topic(tp[2]), "id": int(tp[3], 16), "amount": u256(data, 0)}
        elif ad == MKT and t0 == T_BUY:
            l = S["listings"].get(int(tp[1], 16))
            if l: l["amount"] = max(0, l["amount"] - u256(data, 1))
        elif ad == MKT and t0 == T_CXL:
            l = S["listings"].get(int(tp[1], 16))
            if l: l["amount"] = 0
    if S["t1"] is None and d1_block is not None: S["t1"] = d1_block; S["t1_deadline"] = True
    if d2_block is not None and (S["t2"] is None or S["t2"] > d2_block): S["t2"] = d2_block; S["t2_deadline"] = True
    return S

def block_ts(n): return int(rpc("eth_getBlockByNumber", [hex(n), False])["timestamp"], 16)
def last_block_before(ts, lo, hi):
    """时间戳 < ts 的最后一个区块（二分）"""
    while lo < hi:
        mid = (lo + hi + 1) // 2
        if block_ts(mid) < ts: lo = mid
        else: hi = mid - 1
    return lo

# ---------------- 链上读取（带缓存） ----------------
_w, _id, _nm = {}, {}, {}
def weight(i):
    if i not in _w: _w[i] = int(call(NFT, SEL_WEIGHT + word(i)), 16)
    return _w[i]
def identity(a, block):
    if a not in _id:
        try: r = call(GATE, SEL_IDENT + word(a), hex(block))
        except Exception:
            sys.stderr.write("提示：节点不支持历史状态查询，.ai 身份按最新区块读取\n"); r = call(GATE, SEL_IDENT + word(a))
        _id[a] = r[-64:]
    return _id[a]
def name(a):
    if a not in _nm:
        try:
            r = call(REG, SEL_HANDLE + word(a)); n = int(r[66:130], 16)
            h = bytes.fromhex(r[130:130 + 2 * n]).decode() if n else ""
        except Exception: h = ""
        _nm[a] = h + ".ai" if h else ""
    return _nm[a]

def bnb(wei): return f"{Decimal(wei) / Decimal(10**18):.6f}"

def split(pool_wei, rows):
    """rows: [(key, score)] → [(key, score, share, wei)]，wei 向下取整，余数（几 wei）留在金库。"""
    tot = sum(s for _, s in rows)
    if not tot: return []
    return [(k, s, Decimal(s) / Decimal(tot), pool_wei * s // tot) for k, s in rows]

def stage1_split(pool_wei, rows):
    """rows: [(out, weight)] → [(out, weight, share, wei)]。一半平分，一半按权重。"""
    if not rows: return []
    n, tw = len(rows), sum(w for _, w in rows)
    even, byw = pool_wei // 2, pool_wei - pool_wei // 2
    out = []
    for o, w in rows:
        wei = even // n + byw * w // tw
        out.append((o, w, Decimal(1) / (2 * n) + Decimal(w) / (2 * tw), wei))
    return out

# ---------------- 主程序 ----------------
def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--to-block", type=int); ap.add_argument("--csv"); ap.add_argument("--json", action="store_true")
    ap.add_argument("--rpc", action="append"); ap.add_argument("--no-cache", action="store_true")
    a = ap.parse_args()
    if a.rpc: RPCS[:] = a.rpc
    latest = int(rpc("eth_blockNumber", []), 16)
    to_block = min(a.to_block or latest, latest)
    cache = None if a.no_cache else os.path.join(HERE, f".prize_logs_{CHAIN}.json")
    logs = load_logs(to_block, cache)
    tip_ts = block_ts(to_block)
    d1b = last_block_before(DEADLINE1, DEPLOY_BLOCK, to_block) if tip_ts >= DEADLINE1 else None
    d2b = last_block_before(DEADLINE2, DEPLOY_BLOCK, to_block) if tip_ts >= DEADLINE2 else None
    S = replay(logs, d1_block=d1b, d2_block=d2b)
    t1, t2 = S["t1"], S["t2"]
    rev_all = sum(w for _, w, _ in S["draws"])
    rev1 = sum(w for bn, w, _ in S["draws"] if t1 is None or bn <= t1)
    rev2 = sum(w for bn, w, _ in S["draws"] if t1 is not None and bn > t1 and (t2 is None or bn <= t2))
    pool1, pool2 = rev1 * PAYOUT_BPS // 10000, rev2 * PAYOUT_BPS // 10000

    # 阶段一：50% 在全部首位发现者之间平分，50% 按产出权重分
    st1 = stage1_split(pool1, [(o, weight(o)) for o in sorted(S["first"], key=lambda o: (S["first"][o]["bn"], S["first"][o]["li"]))])
    by_user1 = {}
    for o, w_, sh, wei in st1:
        u = S["first"][o]["user"]; r = by_user1.setdefault(u, [0, Decimal(0), []]); r[0] += wei; r[1] += sh; r[2].append(o)

    # 阶段二：快照（已触发 → 触发区块；未触发 → 当前区块的预览）
    snap = t2 if t2 is not None else to_block
    S2 = replay(logs, stop_block=snap, d1_block=d1b, d2_block=d2b) if t2 is not None else S
    listed = {}
    for l in S2["listings"].values():
        if l["amount"] > 0: d = listed.setdefault(l["seller"], {}); d[l["id"]] = d.get(l["id"], 0) + l["amount"]
    addrs = (set(S2["bal"]) | set(S2["staked"])) - EXCLUDED
    per_ident, no_ident = {}, []
    for ad in sorted(addrs):
        sc = 0
        for i, v in S2["bal"].get(ad, {}).items():
            v -= min(v, listed.get(ad, {}).get(i, 0))
            if v > 0: sc += weight(i) * v
        for i, v in S2["staked"].get(ad, {}).items():
            if v > 0: sc += weight(i) * v
        if sc <= 0: continue
        idn = identity(ad, snap)
        if int(idn, 16) == 0: no_ident.append((ad, sc)); continue
        r = per_ident.setdefault(idn, {"addrs": [], "score": 0}); r["addrs"].append(ad); r["score"] += sc
    st2 = split(pool2, [(k, v["score"]) for k, v in sorted(per_ident.items(), key=lambda kv: -kv[1]["score"])])

    res = {
        "chainId": CHAIN, "toBlock": to_block, "drawsBNB": len(S["draws"]), "revenueWei": str(rev_all),
        "stage1": {"unlocked": t1 is not None, "triggerBlock": t1, "found": len(S["first"]), "targets": len(STAGE1_TARGETS),
                   "revenueWei": str(rev1), "poolWei": str(pool1),
                   "rows": [{"out": o, "weight": w_, "user": S["first"][o]["user"], "name": name(S["first"][o]["user"]), "block": S["first"][o]["bn"],
                             "tx": S["first"][o]["tx"], "share": str(sh), "wei": str(wei)} for o, w_, sh, wei in st1]},
        "stage2": {"triggered": t2 is not None, "triggerBlock": t2, "snapshotBlock": snap, "naturalMinted": S["natural_minted"], "naturalTotal": BASE_CAP_TOTAL,
                   "revenueWei": str(rev2), "poolWei": str(pool2),
                   "rows": [{"identity": "0x" + k, "addrs": per_ident[k]["addrs"], "name": name(per_ident[k]["addrs"][0]), "score": s, "share": str(sh), "wei": str(wei)} for k, s, sh, wei in st2],
                   "excludedNoIdentity": [{"addr": x, "score": s} for x, s in no_ident]},
    }
    if a.json: print(json.dumps(res, ensure_ascii=False, indent=1)); return

    P = print
    P(f"Agent Periodic Lab 奖池 · 读到区块 {to_block}")
    P(f"付费抽卡（BNB）{len(S['draws'])} 次，收入 {bnb(rev_all)} BNB，其中 90% 进奖池\n")
    P(f"【阶段一 · 解锁奖池】{('已截止（2027-06-30 24:00 北京时间），结算区块 ' if S.get('t1_deadline') else '已解锁，触发区块 ') + str(t1) if t1 else '未解锁，截止 2027-06-30 24:00 北京时间'}  已发现 {len(S['first'])}/{len(STAGE1_TARGETS)}")
    P(f"  计入收入 {bnb(rev1)} BNB → 奖池 {bnb(pool1)} BNB{'' if t1 else '（还在增长；下表是“如果现在解锁”的预览，只含已发现的配方）'}")
    for o, w_, sh, wei in st1:
        f = S["first"][o]; P(f"  #{o:<5} 权重 {w_:<5} {name(f['user']) or f['user']:<28} 份额 {float(sh) * 100:7.3f}%  {bnb(wei)} BNB  区块 {f['bn']}")
    if by_user1:
        P("  按地址合计：")
        for u, (wei, sh, outs) in sorted(by_user1.items(), key=lambda kv: -kv[1][0]):
            P(f"    {name(u) or '-':<24} {u}  {bnb(wei)} BNB（{len(outs)} 项）")
    P(f"\n【阶段二 · 抽完奖池】{('已截止，快照区块 ' if S.get('t2_deadline') else '已触发，快照区块 ') + str(t2) if t2 else '未触发（下表为当前区块的预览），截止 2027-12-31 24:00 北京时间'}  天然元素 {S['natural_minted']:,}/{BASE_CAP_TOTAL:,}")
    P(f"  计入收入 {bnb(rev2)} BNB → 奖池 {bnb(pool2)} BNB{'' if t1 else '（阶段一解锁之后的收入才进这里）'}")
    for k, s, sh, wei in st2[:50]:
        r = per_ident[k]; P(f"  {name(r['addrs'][0]) or r['addrs'][0]:<28} 积分 {s:<8} 份额 {float(sh) * 100:7.3f}%  {bnb(wei)} BNB")
    if len(st2) > 50: P(f"  …共 {len(st2)} 个身份，完整表用 --csv")
    if no_ident: P(f"  另有 {len(no_ident)} 个地址持卡但没有 .ai 身份，不计入")

    if a.csv:
        os.makedirs(a.csv, exist_ok=True)
        with open(os.path.join(a.csv, "stage1.csv"), "w") as f:
            f.write("out,weight,discoverer,name,block,tx,share,bnb,wei\n")
            for o, w_, sh, wei in st1:
                x = S["first"][o]; f.write(f"{o},{w_},{x['user']},{name(x['user'])},{x['bn']},{x['tx']},{sh:.12f},{bnb(wei)},{wei}\n")
        with open(os.path.join(a.csv, "stage2.csv"), "w") as f:
            f.write("identity,addresses,name,score,share,bnb,wei\n")
            for k, s, sh, wei in st2:
                r = per_ident[k]; f.write(f"0x{k},{' '.join(r['addrs'])},{name(r['addrs'][0])},{s},{sh:.12f},{bnb(wei)},{wei}\n")
        P(f"\nCSV 已写入 {a.csv}")

if __name__ == "__main__":
    main()
