#!/usr/bin/env python3
"""
Go Shoot 一番賞 — 可驗證公平 驗證器（單檔、零依賴，玩家可自行下載執行）
演算法版本：go-shoot-fairness-v1

信任性質（對應 SPEC 第 6 章）：
  1. commit = SHA256(server_seed)        → 證明 server_seed 事後沒被換
  2. 賞位對應由 server_seed + drand 公共信標 + 賞品結構 三者綁定推導
     → 平台無法事前選號（seed 先承諾、drand 開盒前不可預測），也無法偷換賞品結構
  3. 完抽後公開 server_seed，任何人重跑本程式即可驗算（含大賞位置）

派生公式：
  structure_hash = SHA256( canonical(prize_structure) )
  key  = HMAC_SHA256( key=server_seed, msg = drand_randomness || structure_hash )
  洗牌：以 key 驅動的 HMAC keystream 做 Fisher–Yates（拒絕取樣，無模偏差）
  assignment[position_1..N] = shuffled(expanded prize multiset)

使用：
  python verify.py record.json            # 離線驗算（commit + 重算賞位）
  python verify.py record.json --online    # 另外向 drand 公開 API 核對 randomness
  python verify.py --selftest              # 自我測試（含竄改偵測）
  python verify.py --make-sample out.json  # 產生一份內部一致的示範 record
"""
from __future__ import annotations

import argparse
import hashlib
import hmac
import json
import os
import sys
import urllib.request

ALGO = "go-shoot-fairness-v1"


# ── 基礎密碼學 ───────────────────────────────────────────
def sha256_hex(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def canonical_structure(prize_structure: list[dict]) -> str:
    """把賞品結構序列化成穩定字串（綁進派生，防偷換）。順序即承諾順序。"""
    parts = []
    for p in prize_structure:
        parts.append(f"{p['grade']}|{p['name']}|{int(p['quantity'])}|{int(bool(p.get('is_last', False)))}")
    return ";".join(parts)


def derive_key(server_seed_hex: str, drand_randomness_hex: str, prize_structure: list[dict]) -> bytes:
    structure_hash = hashlib.sha256(canonical_structure(prize_structure).encode("utf-8")).digest()
    msg = bytes.fromhex(drand_randomness_hex) + structure_hash
    return hmac.new(bytes.fromhex(server_seed_hex), msg, hashlib.sha256).digest()


class KeyStream:
    """確定性 CSPRNG keystream：HMAC-SHA256(key, counter)。跨語言可重現。"""
    def __init__(self, key: bytes):
        self.key = key
        self.counter = 0
        self.buf = b""

    def _refill(self):
        block = hmac.new(self.key, self.counter.to_bytes(8, "big"), hashlib.sha256).digest()
        self.counter += 1
        self.buf += block

    def read(self, n: int) -> bytes:
        while len(self.buf) < n:
            self._refill()
        out, self.buf = self.buf[:n], self.buf[n:]
        return out

    def randbelow(self, n: int) -> int:
        """[0, n) 均勻整數，拒絕取樣避免模偏差。"""
        if n <= 1:
            return 0
        k = (n - 1).bit_length()
        nbytes = (k + 7) // 8
        mask = (1 << k) - 1
        while True:
            r = int.from_bytes(self.read(nbytes), "big") & mask
            if r < n:
                return r


def deterministic_shuffle(items: list, key: bytes) -> list:
    """以 key 驅動的 Fisher–Yates。相同 key → 相同結果。"""
    a = list(items)
    rng = KeyStream(key)
    for i in range(len(a) - 1, 0, -1):
        j = rng.randbelow(i + 1)
        a[i], a[j] = a[j], a[i]
    return a


def expand_items(prize_structure: list[dict]) -> list[dict]:
    """依承諾順序展開賞品多重集合（每個賞依數量複製）。"""
    items = []
    for p in prize_structure:
        for _ in range(int(p["quantity"])):
            items.append({"grade": p["grade"], "name": p["name"], "is_last": bool(p.get("is_last", False))})
    return items


def compute_assignment(server_seed_hex: str, drand_randomness_hex: str,
                       prize_structure: list[dict]) -> list[dict]:
    """回傳 [{position, grade, name, is_last}, ...]（position 從 1 起）。封盒與驗算共用此函式。"""
    items = expand_items(prize_structure)
    key = derive_key(server_seed_hex, drand_randomness_hex, prize_structure)
    shuffled = deterministic_shuffle(items, key)
    return [{"position": i + 1, **it} for i, it in enumerate(shuffled)]


# ── drand 公開核對（選用，需網路）────────────────────────
def fetch_drand_randomness(chain_hash: str, rnd: int, timeout: float = 8.0) -> str:
    url = f"https://api.drand.sh/{chain_hash}/public/{rnd}"
    with urllib.request.urlopen(url, timeout=timeout) as r:
        data = json.loads(r.read().decode())
    return data["randomness"]


# ── 驗證主流程 ──────────────────────────────────────────
class Result:
    def __init__(self):
        self.checks: list[tuple[str, bool, str]] = []
        self.ok = True

    def add(self, name: str, passed: bool, detail: str = ""):
        self.checks.append((name, passed, detail))
        if not passed:
            self.ok = False


def verify_record(rec: dict, online: bool = False) -> Result:
    res = Result()

    # 0) 演算法版本
    res.add("演算法版本", rec.get("algo") == ALGO, rec.get("algo", "(缺)"))

    commit = rec["commit"]
    structure = rec["prize_structure"]
    randomness = rec["drand"]["randomness"]

    # 還在抽的盒子本來就不會公開 server_seed（公開＝可反推剩下哪一位是大賞）。
    # 這不是資料壞掉，是設計如此——驗能驗的部分，然後告訴玩家抽完再回來。
    if "reveal" not in rec or not (rec.get("reveal") or {}).get("server_seed"):
        res.pending_reveal = True
        res.add("承諾值已公布", bool(commit), f"{str(commit)[:16]}…")
        total_qty = sum(int(p["quantity"]) for p in structure)
        declared = rec.get("total_positions", total_qty)
        res.add("總賞位數一致", total_qty == declared, f"結構合計 {total_qty} vs 宣告 {declared}")
        if online:
            try:
                on = fetch_drand_randomness(rec["drand"]["chain_hash"], rec["drand"]["round"])
                res.add("drand randomness 與官方一致", on == randomness,
                        f"round {rec['drand']['round']}")
            except Exception as e:  # noqa: BLE001
                res.add("drand 線上核對", False, f"取得失敗：{e}（可改離線驗算）")
        return res

    server_seed = rec["reveal"]["server_seed"]

    # 1) commit 對應 server_seed
    recomputed_commit = sha256_hex(bytes.fromhex(server_seed))
    res.add("commit = SHA256(server_seed)", recomputed_commit == commit,
            f"重算 {recomputed_commit[:16]}… vs 公布 {commit[:16]}…")

    # 2) 總賞位數一致
    total_qty = sum(int(p["quantity"]) for p in structure)
    declared = rec.get("total_positions", total_qty)
    res.add("總賞位數一致", total_qty == declared, f"結構合計 {total_qty} vs 宣告 {declared}")

    # 3) （選用）drand 公開核對
    if online:
        try:
            on = fetch_drand_randomness(rec["drand"]["chain_hash"], rec["drand"]["round"])
            res.add("drand randomness 與官方一致", on == randomness,
                    f"round {rec['drand']['round']}")
        except Exception as e:  # noqa: BLE001
            res.add("drand 線上核對", False, f"取得失敗：{e}（可改離線驗算）")

    # 4) 重算賞位對應
    assignment = compute_assignment(server_seed, randomness, structure)
    by_pos = {a["position"]: a for a in assignment}

    # 5) 與平台公布的逐抽結果交叉比對（若有）
    drawn = rec.get("drawn")
    if drawn:
        mism = [d for d in drawn if by_pos.get(d["position"], {}).get("grade") != d["grade"]]
        res.add("逐抽結果與重算一致", len(mism) == 0,
                "全部相符" if not mism else f"{len(mism)} 筆不符（例：{mism[:3]}）")

    # 6) 大賞位置
    last_positions = [a["position"] for a in assignment if a["is_last"]]
    res.last_positions = last_positions
    res.assignment = assignment
    return res


def print_result(rec: dict, res: Result):
    title = rec.get("set_title", f"set#{rec.get('set_id','?')}")
    print(f"\n  套組：{title}")
    print(f"  演算法：{ALGO}\n")
    for name, ok, detail in res.checks:
        mark = "✅" if ok else "❌"
        print(f"  {mark} {name}" + (f"  — {detail}" if detail else ""))

    if getattr(res, "pending_reveal", False):
        # 未開獎的盒只驗得了「承諾已公布、結構相符」，還沒驗到位置——
        # 這時候印 PASS 等於幫平台講了證據還沒到的話，所以另立一種結論。
        print("\n  ── 尚未開獎 ⏳ ──")
        print("  這盒還在抽，洗牌密碼（server_seed）依規定尚未公開：")
        print("  現在公開，等於讓人反推剩下哪一位是大賞，對還在抽的人不公平。")
        print("  上面幾項可以先存證。等這盒抽完，回到同一個網址重新下載一次，")
        print("  再跑一次本程式，就能驗證整盒每個位置。\n")
        return

    print(f"\n  大賞位置：{getattr(res, 'last_positions', [])}")
    verdict = "PASS ✅  此盒可驗證為公平" if res.ok else "FAIL ❌  驗算不通過，請洽客服／提出質疑"
    print(f"\n  ── {verdict} ──\n")


# ── 自我測試 ─────────────────────────────────────────────
SAMPLE_STRUCTURE = [
    {"grade": "LAST", "name": "烈焰天翔・絕版金證陀螺", "quantity": 1, "is_last": True},
    {"grade": "A", "name": "究極競技擂台 Pro", "quantity": 2},
    {"grade": "B", "name": "高階光速發射器套組", "quantity": 4},
    {"grade": "C", "name": "改裝零件強化包", "quantity": 8},
    {"grade": "D", "name": "隨機戰鬥陀螺 ×1", "quantity": 15},
    {"grade": "E", "name": "收藏展示底座", "quantity": 20},
]


def _build_sample() -> dict:
    server_seed = os.urandom(32).hex()
    # 示範用 randomness（正式環境為 drand 該輪真值）
    randomness = os.urandom(32).hex()
    commit = sha256_hex(bytes.fromhex(server_seed))
    assignment = compute_assignment(server_seed, randomness, SAMPLE_STRUCTURE)
    drawn = [{"position": a["position"], "grade": a["grade"]} for a in assignment]
    return {
        "set_id": 1042,
        "set_title": "烈焰系列・限定一番賞",
        "algo": ALGO,
        "commit": commit,
        "drand": {
            "chain_hash": "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971",
            "round": 0,
            "randomness": randomness,
        },
        "prize_structure": SAMPLE_STRUCTURE,
        "total_positions": sum(p["quantity"] for p in SAMPLE_STRUCTURE),
        "reveal": {"server_seed": server_seed},
        "drawn": drawn,
        "_note": "round=0 為示範值；正式環境 randomness 來自 drand 指定輪次，可用 --online 核對。",
    }


def selftest() -> bool:
    print("\n[selftest] 開始")
    rec = _build_sample()

    # A) 正常驗證應 PASS
    r1 = verify_record(rec, online=False)
    assert r1.ok, "正常 record 應通過"
    print("  ✅ 正常 record 通過")

    # B) 可重現性：同輸入重算結果一致
    a1 = compute_assignment(rec["reveal"]["server_seed"], rec["drand"]["randomness"], rec["prize_structure"])
    a2 = compute_assignment(rec["reveal"]["server_seed"], rec["drand"]["randomness"], rec["prize_structure"])
    assert a1 == a2, "可重現性失敗"
    print("  ✅ 可重現（同輸入 → 同賞位）")

    # C) 竄改 server_seed → commit 不符 + 賞位改變
    bad = json.loads(json.dumps(rec))
    ss = bytearray(bytes.fromhex(bad["reveal"]["server_seed"]))
    ss[0] ^= 0x01
    bad["reveal"]["server_seed"] = ss.hex()
    rb = verify_record(bad, online=False)
    assert not rb.ok, "竄改 seed 應被偵測"
    print("  ✅ 竄改 server_seed 被偵測（commit 不符）")

    # D) 偷換賞品結構 → 賞位改變（逐抽比對不符）
    bad2 = json.loads(json.dumps(rec))
    bad2["prize_structure"][1]["quantity"] = 3  # 偷加一個 A 賞
    bad2["total_positions"] = sum(p["quantity"] for p in bad2["prize_structure"])
    rb2 = verify_record(bad2, online=False)
    assert not rb2.ok, "偷換結構應被偵測"
    print("  ✅ 偷換賞品結構被偵測（重算賞位與公布不符）")

    # E) 偷換 drand randomness → 賞位改變
    bad3 = json.loads(json.dumps(rec))
    rnd = bytearray(bytes.fromhex(bad3["drand"]["randomness"]))
    rnd[5] ^= 0xFF
    bad3["drand"]["randomness"] = rnd.hex()
    rb3 = verify_record(bad3, online=False)
    assert not rb3.ok, "偷換 randomness 應被偵測"
    print("  ✅ 偷換 drand randomness 被偵測")

    print("[selftest] 全數通過 ✅\n")
    return True


# ── CLI ─────────────────────────────────────────────────
def main(argv=None):
    ap = argparse.ArgumentParser(description="Go Shoot 可驗證公平 驗證器")
    ap.add_argument("record", nargs="?", help="record JSON 路徑")
    ap.add_argument("--online", action="store_true", help="另向 drand 公開 API 核對 randomness")
    ap.add_argument("--selftest", action="store_true", help="自我測試")
    ap.add_argument("--make-sample", metavar="PATH", help="產生示範 record")
    args = ap.parse_args(argv)

    if args.selftest:
        return 0 if selftest() else 1

    if args.make_sample:
        rec = _build_sample()
        with open(args.make_sample, "w", encoding="utf-8") as f:
            json.dump(rec, f, ensure_ascii=False, indent=2)
        print(f"已寫入示範 record：{args.make_sample}")
        return 0

    if not args.record:
        ap.print_help()
        return 2

    with open(args.record, encoding="utf-8") as f:
        rec = json.load(f)
    res = verify_record(rec, online=args.online)
    print_result(rec, res)
    return 0 if res.ok else 1


if __name__ == "__main__":
    raise SystemExit(main())
