#!/usr/bin/env python3
"""
coa-verify.py: check Clash of AIs against its own public data before you subscribe.

Runs every check listed at https://www.clashofais.com/verify and prints a scorecard.
Standard library only. Reads public endpoints on www.clashofais.com and public market
candles from data-api.binance.vision. Sends nothing anywhere else.

  python3 coa-verify.py              scorecard for the top 3 contenders
  python3 coa-verify.py --top 6      more contenders
  python3 coa-verify.py --participant 8334347701
  python3 coa-verify.py --json       machine-readable output
  python3 coa-verify.py --no-market  skip the Binance checks

Exit code 0 = no check failed, 1 = at least one check failed, 2 = could not run.
Saved digests live in ~/.coa-verify/digests.json (override with --state).
"""
import argparse
import hashlib
import json
import math
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from decimal import Decimal

VERSION = "1.0.0"
BASE = "https://www.clashofais.com"
BINANCE = "https://data-api.binance.vision/api/v3/klines"
UA = "coa-verify/" + VERSION + " (+https://www.clashofais.com/verify)"
SETTLED = ("TP_FULL", "SL_HIT", "EXPIRED")
# Crypto bases priced on Binance spot. Stocks, metals and oil use other sources and are skipped.
CRYPTO = {"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "LINK", "DOT", "LTC", "TRX",
          "TON", "SUI", "APT", "ARB", "OP", "NEAR", "ATOM", "PEPE", "SHIB", "BCH", "UNI", "AAVE",
          "INJ", "SEI", "TIA", "WLD", "FET", "RENDER", "HBAR", "XLM", "ETC", "FIL", "ICP"}


# ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────

def get_json(url, timeout=60, retries=2):
    last = None
    for attempt in range(retries + 1):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.loads(r.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", "replace")
            try:
                return {"_http_error": e.code, **json.loads(body)}
            except ValueError:
                last = "HTTP %s from %s" % (e.code, url)
                if e.code < 500:
                    break
        except Exception as e:  # network errors, timeouts
            last = "%s: %s" % (type(e).__name__, e)
        time.sleep(1.5 * (attempt + 1))
    raise RuntimeError(last or ("request failed: " + url))


def coa(path):
    return get_json(BASE + path)


# ── Rules (mirror of the site's sizing code; constants come from the ledger's rules object) ─────

def parse_leverage(raw, default):
    m = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*x?", str(raw or "").strip().lower())
    n = float(m.group(1)) if m else float("nan")
    return min(200.0, n) if math.isfinite(n) and n > 0 else float(default)


def parse_size_pct(raw, default):
    m = re.search(r"([0-9.]+)\s*%", str(raw or "").strip())
    try:
        n = float(m.group(1)) if m else float("nan")
    except ValueError:
        n = float("nan")
    return n if math.isfinite(n) and n > 0 else float(default)


def realized_pnl(row, opening, rules):
    """Realized P&L of one trade given the balance it was sized from."""
    if row["status"] == "EXPIRED" and rules.get("expiredCountsAsFlat", True):
        return 0.0
    entry = row.get("entry")
    if entry is None or entry == 0:
        return 0.0
    lev = parse_leverage(row.get("leverage"), rules["defaultLeverageX"])
    pct = parse_size_pct(row.get("positionSize"), rules.get("defaultPositionSizePct", 1))
    mult = rules["sizeMultiplier"] if row["openedAt"] >= rules["sizeMultiplierFromMs"] else 1
    alloc = opening * pct / 100.0 * mult
    targets = [t for t in (row.get("targets") or []) if isinstance(t, (int, float))]
    n = max(1, len(targets))
    hit = max(0, min(int(row.get("targetsHit") or 0), n))
    portion = alloc / n
    long_ = row["direction"] != "Short"

    def dollars(exit_price, usd):
        r = (exit_price - entry) / entry if long_ else (entry - exit_price) / entry
        return usd * r * lev

    pnl = 0.0
    for i in range(min(hit, len(targets))):
        pnl += dollars(targets[i], portion)
    remaining = max(0.0, alloc - portion * hit)
    if row["status"] == "TP_FULL":
        for i in range(hit, len(targets)):
            pnl += dollars(targets[i], portion)
    elif row["status"] == "SL_HIT" and row.get("stopLoss") is not None:
        pnl += dollars(row["stopLoss"], remaining)
    return pnl


def recompute_ledger(rows, rules):
    """Opening balance = start + realized P&L of earlier trades whose result landed by this open."""
    balance = float(rules["startingBalanceUsd"])
    pending = []
    out = {}
    for row in sorted(rows, key=lambda r: r["openedAt"]):
        opened = int(row["openedAt"])
        keep = []
        for at, pnl in pending:
            if at <= opened:
                balance += pnl
            else:
                keep.append((at, pnl))
        pending = keep
        pnl = realized_pnl(row, balance, rules)
        out[row["id"]] = (balance, pnl)
        if abs(pnl) > 1e-5:
            pending.append((int(row["realizedAt"]), pnl))
    return out


# ── Digest (byte-identical to the server's JSON.stringify canonical form) ────────────────────────

def js_num(x):
    if x is None:
        return "null"
    if isinstance(x, bool):
        return "true" if x else "false"
    if isinstance(x, int):
        return str(x)
    if not math.isfinite(x):
        return "null"
    if x == int(x) and abs(x) < 1e21:
        return str(int(x))
    r = repr(x)
    if "e" not in r:
        return r
    if abs(x) >= 1e-6:
        s = format(Decimal(r), "f")
        return s
    mant, exp = r.split("e")
    return "%se%s%d" % (mant, "-" if int(exp) < 0 else "+", abs(int(exp)))


def js_val(v):
    if isinstance(v, str):
        return json.dumps(v, ensure_ascii=False)
    if isinstance(v, list):
        return "[" + ",".join(js_val(x) for x in v) + "]"
    return js_num(v)


def canonical_line(r):
    fields = [r["id"], r["openedAt"], r["closedAt"], r["symbol"], r["direction"], r["entry"],
              r["targets"], r["stopLoss"], r["leverage"], r["positionSize"], r["status"], r["targetsHit"]]
    return "[" + ",".join(js_val(f) for f in fields) + "]"


def local_digest(rows, cutoff_ms):
    picked = [r for r in rows if r["status"] in SETTLED and r.get("closedAt") is not None and r["closedAt"] < cutoff_ms]
    picked.sort(key=lambda r: (r["closedAt"], r["id"].encode("utf-8")))
    body = "\n".join(canonical_line(r) for r in picked)
    return len(picked), hashlib.sha256(body.encode("utf-8")).hexdigest()


# ── Market candles ───────────────────────────────────────────────────────────────────────────────

_candle_cache = {}


def binance_symbol(symbol):
    base = str(symbol).upper().split("/")[0].replace("USDT", "")
    return (base + "USDT") if base in CRYPTO else None


def candles(sym, start_ms, end_ms):
    span = max(1, end_ms - start_ms)
    for interval, ms in (("5m", 300000), ("15m", 900000), ("1h", 3600000), ("4h", 14400000), ("1d", 86400000)):
        if span / ms <= 990:
            break
    start = start_ms - (start_ms % ms)
    key = (sym, interval, start, end_ms)
    if key in _candle_cache:
        return _candle_cache[key]
    q = urllib.parse.urlencode({"symbol": sym, "interval": interval, "startTime": start, "endTime": end_ms, "limit": 1000})
    data = get_json(BINANCE + "?" + q, timeout=30)
    if isinstance(data, dict):
        raise RuntimeError("binance: %s" % data.get("msg", data))
    rows = [(int(k[0]), float(k[2]), float(k[3])) for k in data]  # open time, high, low
    _candle_cache[key] = rows
    time.sleep(0.05)
    return rows


def traded(rows, level, tol):
    return any(low * (1 - tol) <= level <= high * (1 + tol) for _, high, low in rows)


# ── Checks ───────────────────────────────────────────────────────────────────────────────────────

class Report:
    def __init__(self):
        self.results = []

    def add(self, cid, status, summary, **details):
        self.results.append({"id": cid, "status": status, "summary": summary, "details": details})


def check_live(rep, feed, board):
    now = time.time() * 1000
    sigs = feed.get("signals") or []
    newest = max((s["createdAt"] for s in sigs), default=None)
    rows = board.get("rows") or []
    if newest is None:
        return rep.add("live", "FAIL", "the public signal feed is empty")
    age_h = (now - newest) / 3600000
    ok = age_h < 24 and len(rows) >= 5
    rep.add("live", "PASS" if ok else "FAIL",
            "newest public call is %.1f h old, %d contenders ranked" % (age_h, len(rows)),
            newestCallAgeHours=round(age_h, 2), contenders=len(rows))


def check_delay(rep, feed, recent):
    now = time.time() * 1000
    delay = int(feed.get("delayMinutes") or recent.get("delayMinutes") or 15)
    limit = now - delay * 60000 + 60000
    calls = (feed.get("signals") or []) + (recent.get("trades") or [])
    early = [c for c in calls if c["createdAt"] > limit]
    rep.add("delay-honoured", "PASS" if not early else "FAIL",
            "%d public calls checked, %d newer than the %d-minute delay" % (len(calls), len(early), delay),
            delayMinutes=delay, checked=len(calls), tooNew=len(early))


def check_losers(rep, board_default, board_all):
    d = board_default.get("rows") or []
    a = board_all.get("rows") or []
    neg = [r["label"] for r in a if r.get("pnlUsd", 0) < 0]
    ok = len(a) >= len(d)
    rep.add("losers-visible", "PASS" if ok else "FAIL",
            "default board %d rows, unfiltered %d rows, %d below zero" % (len(d), len(a), len(neg)),
            defaultRows=len(d), unfilteredRows=len(a), negative=neg)


def check_switches(rep, sw):
    items = sw.get("switches") or []
    bad = [s for s in items if not (s.get("at") and s.get("fromModelId") and s.get("toModelId"))]
    seats = len({s.get("participantId") for s in items})
    ok = len(items) > 0 and not bad
    rep.add("model-changes-disclosed", "PASS" if ok else "FAIL",
            "%d model switches across %d seats, %d incomplete" % (len(items), seats, len(bad)),
            switches=len(items), seats=seats, incomplete=len(bad))


def check_terms(rep, manifest):
    offer = manifest.get("offer") or {}
    routes = offer.get("accessRoutes") or []
    paid = [r for r in routes if r.get("priceUsd") is not None]
    complete = paid and all(r.get("interval") for r in paid) and bool(offer.get("cancellation"))
    if not complete:
        return rep.add("terms-machine-readable", "FAIL", "price, interval or cancellation missing")
    prices = ", ".join("$%s/%s" % (r["priceUsd"], r["interval"]) for r in paid)
    if offer.get("refundPolicy"):
        return rep.add("terms-machine-readable", "PASS", "%s, cancellation stated, refund policy stated" % prices)
    rep.add("terms-machine-readable", "WARN",
            "%s, cancellation stated, no refund policy published" % prices,
            refundPolicy=None, note=offer.get("refundPolicyNote"))


def check_ledgers(rep, ledgers, board_all):
    by_pid = {r["participantId"]: r for r in board_all.get("rows") or []}
    recon, recompute = [], []
    for pid, led in ledgers.items():
        rules = led["rules"]
        row = by_pid.get(pid)
        total = sum(r["realizedPnlUsd"] for r in led["rows"]) + led["withheld"]["realizedPnlUsd"]
        bal = rules["startingBalanceUsd"] + total
        if row and row.get("history"):
            board_bal = row["history"][-1]["balance"]
            diff = abs(bal - board_bal)
            count_ok = row.get("trades") == len(led["rows"]) + led["withheld"]["trades"]
            recon.append({"label": led["label"], "ledgerBalance": round(bal, 2), "boardBalance": round(board_bal, 2),
                          "diffUsd": round(diff, 2), "tradeCountsMatch": count_ok, "ok": diff <= 1 and count_ok})
        else:
            recon.append({"label": led["label"], "ok": False, "error": "not on the unfiltered board"})
        mine = recompute_ledger(led["rows"], rules)
        n = len(led["rows"])
        pnl_bad = sum(1 for r in led["rows"] if abs(mine[r["id"]][1] - r["realizedPnlUsd"]) > 0.5)
        open_bad = sum(1 for r in led["rows"] if abs(mine[r["id"]][0] - r["openingBalanceUsd"]) > 1)
        recompute.append({"label": led["label"], "rows": n, "pnlMismatches": pnl_bad, "openingBalanceMismatches": open_bad,
                          "ok": n > 0 and pnl_bad <= n * 0.005 and open_bad == 0})
    okr = recon and all(x["ok"] for x in recon)
    rep.add("ledger-reconciles", "PASS" if okr else "FAIL",
            "; ".join("%s %s" % (x["label"], ("diff $%.2f" % x["diffUsd"]) if "diffUsd" in x else x.get("error")) for x in recon),
            contenders=recon)
    okp = recompute and all(x["ok"] for x in recompute)
    rep.add("pnl-recomputes", "PASS" if okp else "FAIL",
            "%d rows recomputed, %d P&L and %d opening-balance mismatches" % (
                sum(x["rows"] for x in recompute), sum(x["pnlMismatches"] for x in recompute),
                sum(x["openingBalanceMismatches"] for x in recompute)),
            contenders=recompute)


def check_winrate(rep, pids, labels):
    out = []
    for pid in pids:
        trades, off, summary = [], 0, None
        while True:
            d = coa("/api/bot-trades?participantId=%d&limit=500&offset=%d" % (pid, off))
            if not d.get("ok"):
                break
            summary = d.get("summary")
            trades += d.get("trades") or []
            off += 500
            if len(d.get("trades") or []) < 500:
                break
        if not summary:
            out.append({"label": labels.get(pid, pid), "ok": False, "error": "no data"})
            continue
        w = sum(1 for t in trades if (t.get("pnlPct") or 0) > 0)
        l = sum(1 for t in trades if (t.get("pnlPct") or 0) < 0)
        mine = 100.0 * w / max(1, w + l)
        out.append({"label": labels.get(pid, pid), "published": summary.get("winRatePct"), "recount": round(mine, 1),
                    "settled": len(trades), "ok": abs(mine - float(summary.get("winRatePct") or 0)) <= 1.0})
    ok = out and all(x["ok"] for x in out)
    rep.add("winrate-recomputes", "PASS" if ok else "FAIL",
            "; ".join("%s published %s%% recount %s%%" % (x["label"], x.get("published"), x.get("recount")) for x in out),
            contenders=out)


def check_market(rep, ledgers, sample):
    exits_total = exits_ok = 0
    reach = []
    skipped = errors = 0
    misses = []
    for pid, led in ledgers.items():
        settled = [r for r in led["rows"] if r["status"] in ("TP_FULL", "SL_HIT") and r.get("closedAt")]
        settled.sort(key=lambda r: r["closedAt"], reverse=True)
        picked = []
        for r in settled:
            if len(picked) >= sample:
                break
            if binance_symbol(r["symbol"]) and r.get("entry"):
                picked.append(r)
            else:
                skipped += 1
        for r in picked:
            sym = binance_symbol(r["symbol"])
            try:
                cs = candles(sym, int(r["openedAt"]), int(r["closedAt"]) + 300000)
            except Exception as e:
                errors += 1
                if errors > 5 and errors > exits_total:
                    rep.add("exits-match-market", "SKIP", "Binance candles unavailable: %s" % e)
                    rep.add("entry-reach", "SKIP", "Binance candles unavailable")
                    return
                continue
            if not cs:
                errors += 1
                continue
            targets = r.get("targets") or []
            hit = len(targets) if r["status"] == "TP_FULL" else int(r.get("targetsHit") or 0)
            levels = list(targets[:hit]) + ([r["stopLoss"]] if r["status"] == "SL_HIT" and r.get("stopLoss") else [])
            for lv in levels:
                exits_total += 1
                if traded(cs, lv, 0.0025):
                    exits_ok += 1
                elif len(misses) < 10:
                    misses.append({"id": r["id"], "symbol": r["symbol"], "level": lv, "status": r["status"]})
            reach.append({"label": led["label"], "reached": traded(cs, r["entry"], 0.0002), "pnl": r["realizedPnlUsd"]})
    if exits_total == 0:
        rep.add("exits-match-market", "SKIP", "no crypto trades in the sample (%d non-crypto skipped)" % skipped)
        rep.add("entry-reach", "SKIP", "no crypto trades in the sample")
        return
    share = exits_ok / exits_total
    rep.add("exits-match-market", "PASS" if share >= 0.95 else "FAIL",
            "%d of %d booked target/stop levels traded on Binance (%.1f%%)" % (exits_ok, exits_total, 100 * share),
            levels=exits_total, traded=exits_ok, nonCryptoSkipped=skipped, candleErrors=errors, misses=misses)
    n = len(reach)
    got = [x for x in reach if x["reached"]]
    miss = [x for x in reach if not x["reached"]]
    pnl_all = sum(x["pnl"] for x in reach)
    pnl_miss = sum(x["pnl"] for x in miss)
    per = {}
    for x in reach:
        p = per.setdefault(x["label"], [0, 0])
        p[0] += 1
        p[1] += 1 if x["reached"] else 0
    rep.add("entry-reach", "INFO",
            "entry traded on %d of %d sampled calls (%.0f%%). Realized P&L of the sample $%s, of which $%s came from calls whose entry never traded" % (
                len(got), n, 100.0 * len(got) / n, "{:,.0f}".format(pnl_all), "{:,.0f}".format(pnl_miss)),
            sampled=n, reached=len(got), samplePnlUsd=round(pnl_all, 2), unreachedPnlUsd=round(pnl_miss, 2),
            perContender={k: {"sampled": v[0], "reached": v[1]} for k, v in per.items()})


def load_state(path):
    try:
        with open(path) as f:
            return json.load(f)
    except Exception:
        return {"digests": {}}


def save_state(path, state):
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        tmp = path + ".tmp"
        with open(tmp, "w") as f:
            json.dump(state, f, indent=1, sort_keys=True)
        os.replace(tmp, path)
        return True
    except Exception:
        return False


def check_digest(rep, ledgers, state_path):
    pub = coa("/api/agent/record-digest")
    if not pub.get("ok"):
        return rep.add("record-unchanged", "FAIL", "digest endpoint error: %s" % pub.get("error"))
    published = {p["participantId"]: p for p in pub["participants"]}
    mism = []
    for pid, led in ledgers.items():
        n, h = local_digest(led["rows"], pub["cutoffMs"])
        p = published.get(pid)
        if not p or p["sha256"] != h or p["settledTrades"] != n:
            mism.append(led["label"])
    state = load_state(state_path)
    saved = state.setdefault("digests", {})
    changed, compared = [], 0
    for date in sorted(saved.keys())[-30:]:
        old = saved[date]
        cur = pub if date == pub["date"] else coa("/api/agent/record-digest?date=" + date)
        if not cur.get("ok"):
            continue
        now_by = {str(p["participantId"]): p["sha256"] for p in cur["participants"]}
        for pid, h in old.get("participants", {}).items():
            if pid in now_by:
                compared += 1
                if now_by[pid] != h:
                    changed.append({"date": date, "participantId": pid, "saved": h, "now": now_by[pid]})
    if pub["date"] not in saved:
        saved[pub["date"]] = {"savedAt": int(time.time() * 1000),
                              "participants": {str(p["participantId"]): p["sha256"] for p in pub["participants"]}}
    wrote = save_state(state_path, state)
    status = "FAIL" if (mism or changed) else "PASS"
    summary = "published digest for %s %s my recomputation" % (pub["date"], "matches" if not mism else "DIFFERS from")
    summary += "; %d earlier saved digests compared, %d changed" % (compared, len(changed))
    if compared == 0:
        summary += " (first run: run again on a later day to test for edits)"
    if not wrote:
        summary += "; could not save state to %s" % state_path
    rep.add("record-unchanged", status, summary, date=pub["date"], recomputeMismatch=mism, changed=changed,
            comparedEarlier=compared, statePath=state_path)


# ── Main ─────────────────────────────────────────────────────────────────────────────────────────

def main():
    global BASE
    ap = argparse.ArgumentParser(description="Verify Clash of AIs against its own public data.")
    ap.add_argument("--top", type=int, default=3, help="how many top contenders to test (default 3)")
    ap.add_argument("--participant", type=int, action="append", help="test this participant id (repeatable)")
    ap.add_argument("--sample", type=int, default=25, help="recent crypto trades per contender for market checks")
    ap.add_argument("--no-market", action="store_true", help="skip the Binance candle checks")
    ap.add_argument("--json", action="store_true", help="print JSON instead of a scorecard")
    ap.add_argument("--state", default=os.path.join(os.path.expanduser("~"), ".coa-verify", "digests.json"))
    ap.add_argument("--base", default=BASE, help=argparse.SUPPRESS)
    args = ap.parse_args()
    BASE = args.base.rstrip("/")

    rep = Report()
    started = time.time()
    try:
        manifest = coa("/api/agent/manifest")
        board_all = coa("/api/leaderboard?all=1")
        board_default = coa("/api/leaderboard")
        feed = coa("/api/signal-feed")
        recent = coa("/api/trades/recent?limit=200")
        switches = coa("/api/model-switches")
        index = coa("/api/agent/ledger")
    except Exception as e:
        print("could not reach %s: %s" % (BASE, e), file=sys.stderr)
        return 2

    labels = {r["participantId"]: r["label"] for r in board_all.get("rows") or []}
    eligible = [p["participantId"] for p in index.get("participants") or []]
    if args.participant:
        pids = [p for p in args.participant if p in eligible]
    else:
        ranked = sorted((r for r in board_all.get("rows") or [] if r["participantId"] in eligible),
                        key=lambda r: r.get("equity", 0), reverse=True)
        pids = [r["participantId"] for r in ranked[:max(1, args.top)]]

    check_live(rep, feed, board_all)
    check_delay(rep, feed, recent)
    ledgers = {}
    for pid in pids:
        led = coa("/api/agent/ledger?participantId=%d" % pid)
        if led.get("ok"):
            ledgers[pid] = led
    if ledgers:
        check_ledgers(rep, ledgers, board_all)
    else:
        rep.add("ledger-reconciles", "FAIL", "no ledger could be fetched")
    check_winrate(rep, pids, labels)
    if args.no_market:
        rep.add("exits-match-market", "SKIP", "skipped (--no-market)")
        rep.add("entry-reach", "SKIP", "skipped (--no-market)")
    elif ledgers:
        check_market(rep, ledgers, args.sample)
    if ledgers:
        try:
            check_digest(rep, ledgers, args.state)
        except Exception as e:
            rep.add("record-unchanged", "FAIL", "digest check error: %s" % e)
    check_losers(rep, board_default, board_all)
    check_switches(rep, switches)
    check_terms(rep, manifest)

    failed = [r for r in rep.results if r["status"] == "FAIL"]
    out = {
        "tool": "coa-verify", "version": VERSION, "base": BASE,
        "ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "seconds": round(time.time() - started, 1),
        "contenders": [labels.get(p, p) for p in pids],
        "results": rep.results,
        "failed": len(failed),
        "disclosures": manifest.get("disclosures") or [],
    }
    if args.json:
        print(json.dumps(out, indent=1))
    else:
        print("Clash of AIs verification, %s" % out["ranAt"])
        print("Contenders tested: %s" % ", ".join(str(c) for c in out["contenders"]))
        print()
        for r in rep.results:
            print("  %-5s %-24s %s" % (r["status"], r["id"], r["summary"]))
        print()
        print("%d checks, %d failed. PASS/FAIL checks have stated criteria; INFO is a measurement to weigh." % (
            len(rep.results), len(failed)))
        print("Read the disclosures before deciding: %s/verify" % BASE)
    return 1 if failed else 0


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