#!/usr/bin/env python3
"""flapjax_watch.py — FLAPJAX PancakeSwap V3 price + liquidity alert bot.

Watches the two FLAPJAX V3 pools on BSC over public RPC (no keys, no trading):
  - FLAPJAX/WBNB 0xca93ec685b8623688e6b236f9068034b298c2150 (FJ = token0)
  - FLAPJAX/BTCB 0x740a3b4a91cb8fa47e85d7f9713ca6ec2f8a037e (FJ = token1)

Alerts when:
  - price moves more than X% within a Y-minute window, or
  - in-range liquidity drops more than Z% vs the window peak.

X, Y, Z, poll interval and output are configurable. Stdlib only.

  live:    python3 flapjax_watch.py --x 5 --y 60 --z 10 --interval 60 --out alerts.log
  replay:  python3 flapjax_watch.py --replay 124700000 124701000 50 --x 2 --y 60 --z 5
  once:    python3 flapjax_watch.py --once [--block 124700000]
"""
import argparse, json, sys, time, urllib.request
from collections import deque

FJ = "0x90c8889f428f9ebb77bb8f15cad3a50a9ac680df"
POOLS = [
    {"name": "FLAPJAX/WBNB", "addr": "0xca93ec685b8623688e6b236f9068034b298c2150",
     "peer": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c", "peer_sym": "WBNB"},
    {"name": "FLAPJAX/BTCB", "addr": "0x740a3b4a91cb8fa47e85d7f9713ca6ec2f8a037e",
     "peer": "0x7130d2a12b9bcbfae4f2634d864a1ee1ce3ead9c", "peer_sym": "BTCB"},
]
RPCS = ["https://bsc-dataseed1.defibit.io", "https://bsc-dataseed.binance.org",
        "https://bsc-dataseed2.binance.org", "https://bsc-dataseed.bnbchain.org"]

_sel = {"slot0": "0x3850c7bd", "liq": "0x1a686502", "token0": "0x0dfe1681",
        "token1": "0xd21220a7", "dec": "0x313ce567", "bal": "0x70a08231"}


def rpc(method, params, rpcs):
    body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method,
                       "params": params}).encode()
    last = None
    for u in rpcs:
        try:
            r = urllib.request.urlopen(urllib.request.Request(
                u, data=body,
                headers={"Content-Type": "application/json"}), timeout=15)
            j = json.load(r)
            if "result" in j and j["result"] is not None:
                return j["result"]
            last = j
        except Exception as e:
            last = e
    raise RuntimeError(f"all RPCs failed: {last}")


def eth_call(to, data, blk, rpcs):
    tag = "latest" if blk is None else hex(blk)
    return rpc("eth_call", [{"to": to, "data": data}, tag], rpcs)


def word(res, i):  # i-th 32-byte word of an ABI return blob
    return int(res[2 + i * 64: 2 + (i + 1) * 64], 16)


def sample(pool, blk, rpcs):
    """One snapshot: price(FJ in peer), in-range liquidity, both reserves."""
    s0 = eth_call(pool["addr"], _sel["slot0"], blk, rpcs)
    sqrtp, tick = word(s0, 0), word(s0, 1)
    if tick >= 1 << 255:
        tick -= 1 << 256
    t0 = "0x" + eth_call(pool["addr"], _sel["token0"], blk, rpcs)[-40:]
    d0 = int(eth_call(t0, _sel["dec"], blk, rpcs), 16)
    d1 = int(eth_call("0x" + eth_call(pool["addr"], _sel["token1"], blk, rpcs)[-40:],
                      _sel["dec"], blk, rpcs), 16)
    # price of token0 in token1, human units
    p_t0_in_t1 = (sqrtp / 2 ** 96) ** 2 * 10 ** (d0 - d1)
    fj_is_t0 = t0.lower() == FJ
    price_fj = p_t0_in_t1 if fj_is_t0 else (1.0 / p_t0_in_t1)
    liq = int(eth_call(pool["addr"], _sel["liq"], blk, rpcs), 16)
    def bal(tok):
        d = _sel["bal"] + "0" * 24 + pool["addr"][2:]
        return int(eth_call(tok, d, blk, rpcs), 16) / 1e18
    fj_res = bal(FJ)
    peer_res = bal(pool["peer"])
    return {"block": blk, "tick": tick, "price_fj_in_peer": price_fj,
            "liquidity": liq, "fj_reserve": fj_res, "peer_reserve": peer_res}


def emit(line, out):
    print(line, flush=True)
    if out:
        out.write(line + "\n"); out.flush()


def check_alerts(tag, pool, s, window, args, out):
    """s = fresh sample; window = deque of recent samples (oldest first)."""
    for old in window:
        if old["price_fj_in_peer"] <= 0:
            continue
        chg = (s["price_fj_in_peer"] - old["price_fj_in_peer"]) / old["price_fj_in_peer"] * 100
        if abs(chg) >= args.x:
            emit(f"{tag} ALERT {pool['name']}: price moved {chg:+.2f}% "
                 f"(>= {args.x}%) within window — {old['price_fj_in_peer']:.6e} "
                 f"-> {s['price_fj_in_peer']:.6e} {pool['peer_sym']}/FJ", out)
            break
    peak = max((o["liquidity"] for o in window), default=0)
    if peak and s["liquidity"] < peak * (1 - args.z / 100):
        emit(f"{tag} ALERT {pool['name']}: liquidity dropped "
             f"{(1 - s['liquidity'] / peak) * 100:.2f}% (>= {args.z}%) — "
             f"{peak} -> {s['liquidity']}", out)


def live(args, out):
    windows = {p["name"]: deque() for p in POOLS}
    horizon = args.y * 60
    emit(f"# live watch x={args.x}% y={args.y}m z={args.z}% interval={args.interval}s", out)
    while True:
        ts = time.time()
        for p in POOLS:
            try:
                s = sample(p, None, args.rpc)
                w = windows[p["name"]]
                tag = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
                emit(f"{tag} {p['name']} blk~live tick={s['tick']} "
                     f"price={s['price_fj_in_peer']:.6e} {p['peer_sym']}/FJ "
                     f"L={s['liquidity']} FJres={s['fj_reserve']:.2f} "
                     f"{p['peer_sym']}res={s['peer_reserve']:.6f}", out)
                check_alerts(tag, p, s, w, args, out)
                w.append(s)
                while w and ts - w[0].get("ts", ts) > horizon:
                    w.popleft()
                s["ts"] = ts
            except Exception as e:
                emit(f"{time.strftime('%H:%M:%S', time.gmtime())} {p['name']} ERR {e}", out)
        time.sleep(args.interval)


def replay(args, out):
    emit(f"# replay blocks {args.replay[0]}..{args.replay[1]} step {args.replay[2]} "
         f"x={args.x}% y={args.y}m z={args.z}% (~3s/block)", out)
    windows = {p["name"]: deque() for p in POOLS}
    max_age_blocks = max(1, int(args.y * 60 / 3))
    for blk in range(args.replay[0], args.replay[1] + 1, args.replay[2]):
        for p in POOLS:
            try:
                s = sample(p, blk, args.rpc)
            except Exception as e:
                emit(f"blk {blk} {p['name']} ERR {e}", out)
                continue
            w = windows[p["name"]]
            emit(f"blk {blk} {p['name']} tick={s['tick']} "
                 f"price={s['price_fj_in_peer']:.6e} {p['peer_sym']}/FJ "
                 f"L={s['liquidity']} FJres={s['fj_reserve']:.2f} "
                 f"{p['peer_sym']}res={s['peer_reserve']:.6f}", out)
            check_alerts(f"blk {blk}", p, s, w, args, out)
            w.append(s)
            while w and blk - (w[0]["block"] or blk) > max_age_blocks:
                w.popleft()


def selftest(args, out):
    """Synthetic samples through the real alert path — proves the logic fires.
    Clearly labeled SYNTHETIC; no chain reads."""
    p = POOLS[0]
    w = deque()
    base = {"block": 1, "tick": 0, "price_fj_in_peer": 1.0e-10,
            "liquidity": 1000, "fj_reserve": 1.0, "peer_reserve": 1.0}
    w.append(dict(base))
    jump = dict(base, block=2, price_fj_in_peer=1.0e-10 * (1 + args.x / 50))
    emit("SYNTHETIC-SELFTEST (not chain data): baseline then +%.1f%% price jump"
         % (args.x / 50 * 100), out)
    check_alerts("SELFTEST", p, jump, w, args, out)
    drain = dict(base, block=3, liquidity=int(1000 * (1 - args.z / 50)))
    check_alerts("SELFTEST", p, drain, deque([dict(base)]), args, out)
    emit("SELFTEST done — expected: one price ALERT + one liquidity ALERT", out)


def once(args, out):
    blk = args.block
    if blk is None:
        blk = int(rpc("eth_blockNumber", [], args.rpc), 16)
    emit(f"# snapshot at block {blk}", out)
    for p in POOLS:
        s = sample(p, blk, args.rpc)
        emit(f"{p['name']} ({p['addr']}) @block {blk}: tick={s['tick']} "
             f"price={s['price_fj_in_peer']:.6e} {p['peer_sym']}/FJ "
             f"liquidity={s['liquidity']} "
             f"FJ_reserve={s['fj_reserve']:.4f} {p['peer_sym']}_reserve={s['peer_reserve']:.6f}", out)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--x", type=float, default=5.0, help="price move %% threshold")
    ap.add_argument("--y", type=float, default=60.0, help="window minutes")
    ap.add_argument("--z", type=float, default=10.0, help="liquidity drop %%")
    ap.add_argument("--interval", type=float, default=60.0, help="poll seconds (live)")
    ap.add_argument("--rpc", action="append", help="extra/override RPC url")
    ap.add_argument("--out", help="append alerts+samples to this file")
    ap.add_argument("--block", type=int, help="pinned block for --once")
    ap.add_argument("--once", action="store_true")
    ap.add_argument("--selftest", action="store_true", help="synthetic alert-path check")
    ap.add_argument("--replay", nargs=3, type=int, metavar=("FROM", "TO", "STEP"))
    args = ap.parse_args()
    args.rpc = (args.rpc or []) + RPCS
    out = open(args.out, "a") if args.out else None
    if args.selftest:
        selftest(args, out)
    elif args.replay:
        replay(args, out)
    elif args.once:
        once(args, out)
    else:
        live(args, out)
    if out:
        out.close()


if __name__ == "__main__":
    main()
