#!/usr/bin/env python3
"""surgebot A2 — Study A's surge signal as a real-time MEASUREMENT harness.

PAPER ONLY. No keys, no orders, no bot imports — self-contained (baked into
the wwf-surgebot image, must not depend on the repo to boot).

WHY A2 (2026-07-22 post-mortem of the v1 paper book): v1 rehearsed the
$100/5% deployment spec physically — cash-gating meant it attempted ~2% of
triggers, and that subsample was ADVERSELY SELECTED (its 102 chain-graded
fills: −$9/fill at $100-scale, while the sim scored ALL of the same day's
triggers at +$41/fill; the sim run on v1's own triggers agreed with v1's
losses within ~$2 — execution physics validated, sampling condemned).
A cash-gated book cannot measure the signal AND rehearse the spec at once.

A2 splits them: this box attempts EVERY cooldown-passed trigger (flat $100
paper FAK walking the live asks inside p_ref*1.05, partial fills kept) and
appends one line per attempt — including top-5 raw ask levels — to
/data/surge_attempts.jsonl. Any bankroll spec (the $100/5% book, other
banks, caps, tighter slip) is then replayed OFFLINE from that stream
(research/surge_book_replay.py -> research/surge_book.json, nightly).

FROZEN signal semantics (params/study_flow.json — do not tune here):
  informed set   top-150 (fetched from the repo, regenerated nightly)
  trigger        net informed flow >= $300 in 60s, one market
  band           entry price 0.10-0.90 · niches sports+esports
  cooldown       900s per token, set at trigger time
Unchanged from v1: signal code path is verbatim. Changed in A2 (execution/
accounting only): every-trigger attempts (no cash gate, no event cap, no
skip-if-open — those live in the offline replay), $100 book-walk fills
(ledger-comparable; v1 was best-ask at ~$5), attempts log, multi-lot per
asset. THE #16 VERDICT BINDS TO forward_ledger.jsonl ONLY; this harness
measures capturability. Settles are provisional (CLOB closed/winner) until
the nightly CTF payout re-grade (grade_surge.py -> surge_meas_ledger.jsonl;
v1's surge_paper_ledger.jsonl is closed and untouched, as is its state at
/data/surge_state.json). Any semantics-altering fix bumps SEM_VER and
restarts the shakedown clock.

OBSERVATIONAL instrumentation (2026-07-22, NOT semantics — SEM_VER
unchanged): attempts also record top-3 bids (spread context; exit-fill
realism), and every fill schedules book re-reads at +60s/+300s/+1800s whose
best bid/ask land in /data/surge_markouts.jsonl — real exit marks for the
markout-exit study instead of print-inferred guesses. Pending re-reads are
lost on restart (counted, acceptable — observation only).
"""
import heapq
import json
import os
import queue
import re
import ssl
import threading
import time
import urllib.request

import websocket

WS_URL = "wss://ws-live-data.polymarket.com"
CLOB = "https://clob.polymarket.com"
SET_URL = ("https://raw.githubusercontent.com/jaxperro/winning-wallet-finder/"
           "main/research/params/informed_set.json")
STATE = os.environ.get("SURGE_STATE", "/data/surge2_state.json")
ATTEMPTS = os.environ.get("SURGE_ATTEMPTS", "/data/surge_attempts.jsonl")
MARKOUTS = os.environ.get("SURGE_MARKOUTS", "/data/surge_markouts.jsonl")
SETTLES = os.environ.get("SURGE_SETTLES", "/data/surge_settles.jsonl")
SEM_VER = "a2"                    # bump on ANY semantics-altering change
MARKOUT_OFFSETS = (60, 300, 1800)  # observational re-reads per fill
BID_LEVELS = 3

# ── FROZEN — must equal params/study_flow.json / forward.py ────────────────
FLOW_USD = 300.0
WINDOW_S = 60
BAND = (0.10, 0.90)
COOLDOWN_S = 900
NICHES = {"sports", "esports"}
STAKE = 100.0                     # ledger stake (sf.STAKE) — walk the book
FEE_RATE = 0.03
SLIP_CAP = 0.05
# ── LIVE-ONLY (execution/plumbing, never signal) ───────────────────────────
BOOK_WORKERS = 4
BOOK_QUEUE = 16                   # overflow => throttle (counted)
CLOB_PASS_CAP = 40                # settle GETs per pass, round-robin
SETTLED_TRIM = 10000              # nightly ledger is the durable record
SKIPS_TRIM = 500
TOP_LEVELS = 5                    # raw ask levels recorded per attempt

NICHE_PATTERNS = [
    ("esports", ["lol:", "dota", "cs2", "csgo", "valorant", "esports",
                 "bilibili", "map ", "game 1", "game 2", "game 3"]),
    ("tennis", ["tennis", "atp", "wta", "wimbledon", "set winner"]),
    ("sports", [" vs. ", " vs ", " @ ", "mlb", "nba", "nhl", "ufc",
                "world cup", "f1", "grand prix", "fifa"]),
]
SSL_CTX = ssl._create_unverified_context()


def log(m):
    print(f"{time.strftime('%H:%M:%S')}  {m}", flush=True)


def niche(title):
    t = (title or "").lower()
    for label, pats in NICHE_PATTERNS:
        if any(p in t for p in pats):
            return label
    return "other"


def event_key(slug):
    m = re.match(r"(.*?\d{4}-\d{2}-\d{2})", slug or "")
    return m.group(1) if m else (slug or None)


def get_json(url, timeout=8):
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
    with urllib.request.urlopen(req, timeout=timeout, context=SSL_CTX) as r:
        return json.loads(r.read().decode())


def iso_ts(s):
    """CLOB end_date_iso -> epoch (None on any parse trouble)."""
    try:
        import calendar
        return calendar.timegm(time.strptime(
            s.replace("+00:00", "Z")[:20], "%Y-%m-%dT%H:%M:%SZ"))
    except Exception:
        return None


def walk_asks(asks, cap, stake_usd=STAKE):
    """FAK against a CLOB ask list: consume ascending price levels <= cap
    until stake_usd is spent; partial fills kept (that IS what a real FAK
    gets). Returns top raw levels too so ANY smaller stake / tighter cap can
    be replayed offline. asks arrive UNSORTED from /book."""
    lv = sorted(((float(a["price"]), float(a["size"])) for a in asks or []))
    top = [[p, s] for p, s in lv[:TOP_LEVELS]]
    if not lv:
        return {"filled": False, "best_ask": None, "top": top}
    best = lv[0][0]
    spent = shares = 0.0
    levels = 0
    for pxx, sz in lv:
        if pxx > cap or spent >= stake_usd - 1e-9:
            break
        take = min(sz, (stake_usd - spent) / pxx)
        if take <= 0:
            break
        shares += take
        spent += take * pxx
        levels += 1
    if not shares:
        return {"filled": False, "best_ask": best, "top": top}
    return {"filled": True, "vwap": spent / shares, "shares": shares,
            "usd": spent, "levels": levels, "best_ask": best, "top": top,
            "partial": spent < stake_usd - 1e-9}


def fresh_state():
    return {
        "sem_ver": SEM_VER, "boot_ts": int(time.time()),
        "open": {}, "settled": [], "skips": [],
        "pnl_realized": 0.0,
        "counters": {"triggers": 0, "attempts": 0, "fills": 0, "craters": 0,
                     "throttle_skip": 0, "book_fail": 0,
                     "settled_total": 0, "settle_clob": 0}}


class Surge:
    def __init__(self):
        self.state = fresh_state()
        if os.path.exists(STATE):
            try:
                loaded = json.load(open(STATE))
                if loaded.get("sem_ver") != SEM_VER:
                    log(f"⚠ SEM_VER {loaded.get('sem_ver')} -> {SEM_VER} — "
                        f"semantics changed; shakedown clock restarts here")
                    loaded["sem_ver"] = SEM_VER
                self.state = loaded
                log(f"resumed: pnl {self.state['pnl_realized']:+.2f} · "
                    f"{len(self.state['open'])} open · "
                    f"{self.state['counters']['fills']} lifetime fills")
            except Exception as e:
                log(f"⚠ state load failed ({e}) — fresh book")
        self.informed = set()
        self.set_meta = {}
        self.win = {}                  # asset -> [(ts, ±usd)]
        self.last_trig = {}
        self.lock = threading.Lock()
        self.book_q = queue.Queue(BOOK_QUEUE)
        self.mo_heap = []              # (due_ts, asset, fill_id, offset)

    # ── config ─────────────────────────────────────────────────────────────
    def load_set(self):
        try:
            d = get_json(SET_URL, timeout=15)
            self.informed = {w.lower() for w in d["wallets"]}
            self.set_meta = {"generated_at": d.get("generated_at"),
                             "n": len(self.informed)}
            age_h = (time.time() - (d.get("generated_at") or 0)) / 3600
            log(f"informed set: {len(self.informed)} wallets "
                f"({age_h:.0f}h old)" + (" ⚠ STALE >48h" if age_h > 48 else ""))
        except Exception as e:
            log(f"⚠ informed set fetch failed ({e}) — "
                f"keeping {len(self.informed)} cached")

    # ── signal — FROZEN, verbatim v1 path ──────────────────────────────────
    def on_trade(self, p):
        w = (p.get("proxyWallet") or "").lower()
        if w not in self.informed:
            return
        title = p.get("title") or ""
        if niche(title) not in NICHES:
            return
        asset = p.get("asset")
        px = float(p.get("price") or 0)
        usd = px * float(p.get("size") or 0)
        if p.get("side") == "SELL":
            usd = -usd
        now = time.time()
        with self.lock:
            buf = self.win.setdefault(asset, [])
            buf.append((now, usd))
            while buf and buf[0][0] < now - WINDOW_S:
                buf.pop(0)
            flow = sum(u for _, u in buf)
            if flow < FLOW_USD or not (BAND[0] <= px <= BAND[1]):
                return
            if now - self.last_trig.get(asset, 0) < COOLDOWN_S:
                return
            self.last_trig[asset] = now
            c = self.state["counters"]
            c["triggers"] += 1
            # A2: EVERY trigger becomes an attempt (no cash/event/open gate —
            # bankroll specs are replayed offline from the attempts log)
            job = {"asset": asset, "cond": p.get("conditionId"),
                   "event": event_key(p.get("eventSlug") or p.get("slug")
                                      or ""),
                   "title": title[:60], "outcome": p.get("outcome"),
                   "niche": niche(title), "ts": round(now, 3),
                   "p_ref": px, "flow": round(flow)}
            try:
                self.book_q.put_nowait(job)
                c["attempts"] += 1
            except queue.Full:
                c["throttle_skip"] += 1
                self._skip(job, "throttle", None)
                self.log_attempt({**job, "filled": False, "why": "throttle"})

    # ── paper execution (workers; network OUT of the lock) ─────────────────
    def book_worker(self):
        while True:
            job = self.book_q.get()
            try:
                self.attempt(job)
            except Exception as e:
                log(f"⚠ attempt error: {e}")

    def attempt(self, job):
        t_req = time.time()
        try:
            book = get_json(f"{CLOB}/book?token_id={job['asset']}")
        except Exception:
            with self.lock:
                self.state["counters"]["book_fail"] += 1
                self._skip(job, "book_fail", None)
            self.log_attempt({**job, "filled": False, "why": "book_fail"})
            return
        lat_ms = int((time.time() - t_req) * 1000)
        cap = min(job["p_ref"] * (1 + SLIP_CAP), 0.99)
        r = walk_asks(book.get("asks"), cap)
        bid_top = sorted(((float(b["price"]), float(b["size"]))
                          for b in book.get("bids") or []),
                         reverse=True)[:BID_LEVELS]
        end_ts = None                    # expected resolution (dashboard ETA)
        if r["filled"]:
            try:
                m = get_json(f"{CLOB}/markets/{job.get('cond')}", timeout=5)
                end_ts = iso_ts(m.get("end_date_iso") or "")
            except Exception:
                pass
        rec = {**job, "cap": round(cap, 4), "latency_ms": lat_ms,
               "top": r["top"], "bid_top": [[p, s] for p, s in bid_top],
               "best_ask": r["best_ask"], "filled": r["filled"]}
        with self.lock:
            c = self.state["counters"]
            if not r["filled"]:
                c["craters"] += 1
                self._skip(job, "crater", r["best_ask"])
                ba = "—" if r["best_ask"] is None else f"{r['best_ask']:.3f}"
                log(f"CRATER {job['title'][:38]} ref {job['p_ref']:.3f} "
                    f"ask {ba} flow ${job['flow']} ({lat_ms}ms)")
            else:
                fee = FEE_RATE * r["shares"] * min(r["vwap"], 1 - r["vwap"])
                lot = {**job, "end_ts": end_ts, "price": round(r["vwap"], 5),
                       "best_ask": r["best_ask"],
                       "shares": round(r["shares"], 4),
                       "cost": round(r["usd"], 2), "fee": round(fee, 4),
                       "levels": r["levels"], "partial": r["partial"],
                       "latency_ms": lat_ms}
                self.state["open"][f"{job['asset']}:{job['ts']}"] = lot
                c["fills"] += 1
                rec.update({"price": lot["price"], "shares": lot["shares"],
                            "usd": lot["cost"], "fee": lot["fee"],
                            "partial": lot["partial"], "end_ts": end_ts})
                for off in MARKOUT_OFFSETS:   # observational exit marks
                    if end_ts and job["ts"] + off > end_ts:
                        continue
                    heapq.heappush(self.mo_heap,
                                   (job["ts"] + off, job["asset"],
                                    f"{job['asset']}:{job['ts']}", off))
                log(f"FILL {job['outcome']} · {job['title'][:38]} @ "
                    f"{lot['price']:.3f} (${lot['cost']:.2f}"
                    f"{' partial' if lot['partial'] else ''}, "
                    f"flow ${job['flow']}, {lat_ms}ms)")
            self.persist()
        self.log_attempt(rec)

    def log_attempt(self, rec):
        """Append-only full attempt stream — the offline-replay dataset.
        One JSON line per attempt (fills, craters, fails, throttles)."""
        try:
            with open(ATTEMPTS, "a") as fh:
                fh.write(json.dumps(rec) + "\n")
        except Exception as e:
            log(f"⚠ attempts log write failed: {e}")

    def markout_worker(self):
        """Observational: due book re-reads -> best bid/ask marks. Never
        touches the signal path; pending marks die on restart (counted)."""
        while True:
            due = None
            with self.lock:
                if self.mo_heap and self.mo_heap[0][0] <= time.time():
                    due = heapq.heappop(self.mo_heap)
            if due is None:
                time.sleep(2)
                continue
            due_ts, asset, fid, off = due
            rec = {"fill_id": fid, "asset": asset, "offset_s": off,
                   "due_ts": round(due_ts, 1),
                   "read_ts": round(time.time(), 3)}
            try:
                book = get_json(f"{CLOB}/book?token_id={asset}")
                bids = sorted(((float(b["price"]), float(b["size"]))
                               for b in book.get("bids") or []), reverse=True)
                asks = sorted(((float(a["price"]), float(a["size"]))
                               for a in book.get("asks") or []))
                rec["bid"], rec["bid_sz"] = (bids[0] if bids else (None, None))
                rec["ask"], rec["ask_sz"] = (asks[0] if asks else (None, None))
                key = "mo_done"
            except Exception as e:
                rec["err"] = str(e)[:40]
                key = "mo_err"
            with self.lock:
                c = self.state["counters"]
                c[key] = c.get(key, 0) + 1
            try:
                with open(MARKOUTS, "a") as fh:
                    fh.write(json.dumps(rec) + "\n")
            except Exception as e:
                log(f"⚠ markouts log write failed: {e}")

    def _skip(self, job, why, ba):     # under lock
        self.state["skips"].append(
            {"ts": int(job["ts"]), "asset": job["asset"], "why": why,
             "p_ref": job["p_ref"], "flow": job["flow"], "ba": ba,
             "title": job["title"]})
        del self.state["skips"][:-SKIPS_TRIM]

    # ── provisional settles (nightly re-grades with chain truth) ───────────
    def settle_clob_pass(self):
        with self.lock:
            now = time.time()
            due = list(self.state["open"].keys())
            due.sort(key=lambda lid: self.state["open"][lid].get("clob_ck", 0))
            due = due[:CLOB_PASS_CAP]
            snap = {}
            for lid in due:
                lot = self.state["open"][lid]
                lot["clob_ck"] = now   # round-robin stamp
                snap[lid] = (lot["cond"], lot["asset"], lot.get("end_ts"))
        verdicts = {}
        ends = {}
        for lid, (cond, asset, end_ts) in snap.items():
            try:
                m = get_json(f"{CLOB}/markets/{cond}")
            except Exception:
                continue
            if end_ts is None:         # backfill ETAs for pre-ETA fills
                ends[lid] = iso_ts(m.get("end_date_iso") or "")
            if not m.get("closed"):
                continue
            for t in m.get("tokens") or []:
                if str(t.get("token_id")) == str(asset):
                    verdicts[lid] = 1.0 if t.get("winner") else 0.0
        with self.lock:
            for lid, e in ends.items():
                if lid in self.state["open"]:
                    self.state["open"][lid]["end_ts"] = e
            n = 0
            for lid, pay in verdicts.items():
                lot = self.state["open"].get(lid)
                if lot:
                    self._settle(lid, lot, pay)
                    n += 1
            if n or ends:
                self.persist()

    def _settle(self, lid, lot, pay):  # under lock
        pnl = round(lot["shares"] * pay - lot["cost"] - lot["fee"], 2)
        self.state["pnl_realized"] = round(self.state["pnl_realized"] + pnl, 2)
        c = self.state["counters"]
        c["settled_total"] += 1
        c["settle_clob"] += 1
        rec = {**lot, "payout": pay, "provisional": True,
               "settled_ts": int(time.time()), "pnl": pnl}
        self.state["settled"].append(rec)
        del self.state["settled"][:-SETTLED_TRIM]
        del self.state["open"][lid]
        try:                            # durable append-log: SETTLED_TRIM can
            with open(SETTLES, "a") as fh:   # never rotate a settle away
                fh.write(json.dumps(rec) + "\n")
        except Exception as e:
            log(f"⚠ settles log write failed: {e}")
        word = "WON" if pay == 1.0 else "refund" if pay == 0.5 else "lost"
        log(f"SETTLE {word} {lot['title'][:36]} {pnl:+.2f} · "
            f"realized {self.state['pnl_realized']:+.2f}")

    # ── plumbing ───────────────────────────────────────────────────────────
    def persist(self):
        tmp = STATE + ".tmp"
        json.dump(self.state, open(tmp, "w"))
        os.replace(tmp, STATE)

    def feed_dict(self):               # under lock
        stt = self.state
        return {
            "mode": "paper-measurement", "study": "surge-A (#16)",
            "sem_ver": SEM_VER, "updated": int(time.time()),
            "boot_ts": stt["boot_ts"],
            "frozen": {"flow_usd": FLOW_USD, "window_s": WINDOW_S,
                       "band": BAND, "cooldown_s": COOLDOWN_S,
                       "stake": STAKE, "fee_rate": FEE_RATE,
                       "slip_cap": SLIP_CAP},
            "counters": stt["counters"], "pnl_realized": stt["pnl_realized"],
            "informed": self.set_meta, "open_n": len(stt["open"]),
            "open": [{**l, "id": lid}
                     for lid, l in list(stt["open"].items())[-200:]],
            "settled": stt["settled"][-200:], "skips": stt["skips"][-100:],
            "note": "verdict binds to forward_ledger.jsonl (#16); this feed "
                    "measures capturability — bankroll specs are replayed "
                    "offline from the attempts log (surge_book.json)"}


SUB = json.dumps({"action": "subscribe", "subscriptions": [
    {"topic": "activity", "type": "trades", "filters": ""}]})


def run_conn(tag, bot):
    backoff = 2
    while True:
        state = {"fresh": time.time()}

        def on_open(ws):
            ws.send(SUB)
            state["fresh"] = time.time()
            log(f"ws[{tag}]: connected")

            def ping():
                while ws.keep_running:
                    time.sleep(5)
                    try:
                        ws.send('{"action":"ping"}')
                    except Exception:
                        break
                    if time.time() - state["fresh"] > 20:
                        try:
                            ws.close()
                        except Exception:
                            pass
                        break
            threading.Thread(target=ping, daemon=True).start()

        def on_message(ws, raw):
            state["fresh"] = time.time()
            try:
                m = json.loads(raw)
            except Exception:
                return
            if m.get("topic") == "activity" and m.get("type") == "trades":
                try:
                    bot.on_trade(m.get("payload") or {})
                except Exception as e:
                    log(f"⚠ on_trade error: {e}")

        try:
            app = websocket.WebSocketApp(WS_URL, on_open=on_open,
                                         on_message=on_message)
            app.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE})
        except Exception as e:
            log(f"ws[{tag}]: {str(e)[:60]}")
        time.sleep(backoff + (1 if tag == "b" else 0))
        backoff = min(backoff * 2, 30)
        if time.time() - state["fresh"] < 60:
            backoff = 2


def serve_feed(bot, port=8080):
    """Read-only public feed for the /test dashboard (paper data only —
    nothing here can place an order or mutate state). CORS-open."""
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

    class H(BaseHTTPRequestHandler):
        def log_message(self, *a):
            pass

        def do_GET(self):
            if self.path.split("?")[0] != "/feed":
                self.send_response(404)
                self.end_headers()
                return
            with bot.lock:
                body = json.dumps(bot.feed_dict()).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Access-Control-Allow-Origin", "*")
            self.send_header("Cache-Control", "no-store")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

    ThreadingHTTPServer(("0.0.0.0", port), H).serve_forever()


def selftest():
    """Boot gate — hard exit on failure."""
    assert niche("LoL: T1 vs GenG map 2") == "esports"
    assert niche("Pittsburgh Pirates vs. New York Yankees: O/U 9.5") == "sports"
    assert niche("Will it rain in NYC") == "other"
    assert event_key("mlb-pit-nyy-2026-07-22-game") == "mlb-pit-nyy-2026-07-22"
    r = walk_asks([{"price": "0.60", "size": "200"},
                   {"price": "0.55", "size": "100"}], cap=0.62)
    assert r["filled"] and abs(r["usd"] - 100.0) < 1e-6, r
    assert r["levels"] == 2 and r["best_ask"] == 0.55 and not r["partial"], r
    assert r["top"][0] == [0.55, 100.0] and len(r["top"]) == 2, r
    r = walk_asks([{"price": "0.55", "size": "10"}], cap=0.60)
    assert r["filled"] and r["partial"] and abs(r["usd"] - 5.5) < 1e-9, r
    r = walk_asks([{"price": "0.70", "size": "10"}], cap=0.60)
    assert not r["filled"] and r["best_ask"] == 0.70, r
    assert not walk_asks([], 0.5)["filled"]
    log("selftest OK")


def main():
    selftest()
    bot = Surge()
    bot.load_set()
    threading.Thread(target=serve_feed, args=(bot,), daemon=True).start()
    for _ in range(BOOK_WORKERS):
        threading.Thread(target=bot.book_worker, daemon=True).start()
    for _ in range(2):
        threading.Thread(target=bot.markout_worker, daemon=True).start()
    for tag in ("a", "b"):
        threading.Thread(target=run_conn, args=(tag, bot), daemon=True).start()
    last_set = time.time()
    n = 0
    while True:
        time.sleep(60)
        n += 1
        now = time.time()
        with bot.lock:
            bot.last_trig = {a: t for a, t in bot.last_trig.items()
                             if now - t < COOLDOWN_S}
            # trim flow windows for tokens that went quiet
            bot.win = {a: b for a, b in bot.win.items()
                       if b and b[-1][0] > now - 2 * WINDOW_S}
        if n % 2 == 0:
            bot.settle_clob_pass()
        if n % 5 == 0:
            with bot.lock:
                bot.persist()
        if time.time() - last_set > 6 * 3600:
            bot.load_set()
            last_set = time.time()
        with bot.lock:
            c = bot.state["counters"]
            msg = (f"paper pnl {bot.state['pnl_realized']:+.2f} · "
                   f"open {len(bot.state['open'])} · trig {c['triggers']} "
                   f"att {c['attempts']} fill {c['fills']} "
                   f"crat {c['craters']} thr {c['throttle_skip']} · "
                   f"settled {c['settled_total']}")
        log(msg)


if __name__ == "__main__":
    main()
