#!/usr/bin/env python3
"""alpenglow-watch v0.1 — Solana mainnet finality observatory (no wallet, public RPC only).

Measures two things per provider:
  A) tx finality: discover a fresh memo-program tx at "confirmed" commitment,
     then poll its signature status until "finalized".
     Primary metric: confirmed->finalized (ms). Censored samples (first poll
     already finalized -> true value <= poll interval) are counted, not guessed.
  B) slot gap: getSlot(processed) vs getSlot(finalized) sampled repeatedly;
     gap x 200ms = chain-level finalized lag.

Output: data/raw_<provider>_<ts>.jsonl + stats.json + markdown table on stdout.
Usage: python3 measure.py [--samples 20] [--poll 0.25] [--providers all]
"""
import argparse
import json
import statistics
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

# memo program: cheap txs every second, ideal as a finality probe
PROBE_ADDRESS = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"
SLOT_MS = 200  # mainnet slot time since 2026-10-09

PROVIDERS = {
    "official": "https://api.mainnet-beta.solana.com",
    "publicnode": "https://solana-rpc.publicnode.com",
}


def rpc(url, method, params, timeout=10):
    """JSON-RPC over curl: this host's TLS-intercepting proxy plus provider
    TLS-fingerprint filtering block python-urllib, while curl passes."""
    body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
    out = subprocess.run(
        ["curl", "-s", "--max-time", str(timeout), "-X", "POST", url,
         "-H", "Content-Type: application/json", "-d", body],
        capture_output=True, text=True, timeout=timeout + 5,
    ).stdout
    if not out:
        raise RuntimeError("empty response")
    parsed = json.loads(out)
    if "error" in parsed:
        raise RuntimeError(parsed["error"])
    return parsed["result"]


def pct(sorted_vals, p):
    if not sorted_vals:
        return None
    k = min(len(sorted_vals) - 1, int(round((p / 100) * (len(sorted_vals) - 1))))
    return sorted_vals[k]


def fmt_ms(v):
    return "-" if v is None else f"{v:,.0f}"


def measure_tx_finality(url, samples_target, poll_s, log):
    """Returns (rows, errors): one row per sampled transaction."""
    rows, errors = [], 0
    err_logged = False
    prev_sig, collected = None, 0
    deadline = time.time() + 20 * samples_target  # hard cap
    while collected < samples_target and time.time() < deadline:
        try:
            # NB: this method rejects commitment "processed" — earliest is "confirmed".
            newest = rpc(url, "getSignaturesForAddress",
                         [PROBE_ADDRESS, {"limit": 1, "commitment": "confirmed"}])
        except Exception as e:  # noqa: BLE001
            errors += 1
            if not err_logged:
                log(f"  discover error: {e}"); err_logged = True
            time.sleep(1.0)
            continue
        if not newest:
            time.sleep(0.5)
            continue
        sig = newest[0]["signature"]
        block_time = newest[0].get("blockTime")
        if sig == prev_sig:
            time.sleep(0.3)
            continue
        prev_sig = sig
        row = {"sig": sig[:16] + "…", "block_time": block_time,
               "t_discover": time.time(), "status_seen": {}}
        # poll until finalized (or 30s give-up)
        give_up = time.time() + 30
        while time.time() < give_up:
            try:
                # NB: getSignatureStatuses config accepts no commitment field;
                # the response reports the signature's highest current status.
                val = rpc(url, "getSignatureStatuses", [[sig]])["value"][0]
            except Exception as e:  # noqa: BLE001
                errors += 1
                if not err_logged:
                    log(f"  status error: {e}"); err_logged = True
                time.sleep(0.5)
                continue
            now = time.time()
            if val:
                st = val.get("confirmationStatus")
                if st and st not in row["status_seen"]:
                    row["status_seen"][st] = round((now - row["t_discover"]) * 1000)
            if val and val.get("confirmationStatus") == "finalized":
                break
            time.sleep(poll_s)
        if "finalized" not in row["status_seen"]:
            errors += 1
            continue  # gave up on this tx (rpc lag/prune) - skip
        row["ms_discover_to_final"] = row["status_seen"]["finalized"]
        row["ms_confirmed_to_final"] = (
            row["status_seen"]["finalized"] - row["status_seen"]["confirmed"]
        ) if "confirmed" in row["status_seen"] else None
        row["ms_processed_to_final"] = (
            row["status_seen"]["finalized"] - row["status_seen"]["processed"]
        ) if "processed" in row["status_seen"] else None
        row["censored"] = row["ms_confirmed_to_final"] is None
        rows.append(row)
        collected += 1
        log(f"  [{collected:>2}/{samples_target}] {row['sig']} "
            f"conf→final {fmt_ms(row['ms_confirmed_to_final'])}ms"
            + (" (censored)" if row["censored"] else ""))
    return rows, errors


def probe_rtt(url, n=8):
    """Median curl+RPC round-trip on a trivial call (getBlockHeight)."""
    rtts = []
    for _ in range(n):
        t0 = time.time()
        try:
            rpc(url, "getBlockHeight", [])
            rtts.append((time.time() - t0) * 1000)
        except Exception:  # noqa: BLE001
            continue
        time.sleep(0.1)
    return statistics.median(rtts) if rtts else None


def measure_slot_gap(url, n, log):
    """gap = processed - finalized slots, sampled back-to-back per tick.
    Also records age of the latest finalized block (blockTime, 1s granularity)."""
    gaps_c, gaps_f, ages = [], [], []
    for i in range(n):
        try:
            p = rpc(url, "getSlot", [{"commitment": "processed"}])
            c = rpc(url, "getSlot", [{"commitment": "confirmed"}])
            f = rpc(url, "getSlot", [{"commitment": "finalized"}])
            block = rpc(url, "getBlock", [f, {"transactionDetails": "none", "rewards": False}])
        except Exception:  # noqa: BLE001
            time.sleep(1.0)
            continue
        if p and c and f:
            gaps_c.append(max(0, p - c))
            gaps_f.append(max(0, p - f))
        if block and block.get("blockTime"):
            ages.append(round(time.time() - block["blockTime"], 2))
        time.sleep(0.7)
    return gaps_c, gaps_f, ages


def stats_for(rows):
    unc = sorted(r["ms_confirmed_to_final"] for r in rows if not r["censored"])
    unf = sorted(r["ms_processed_to_final"] for r in rows if r["ms_processed_to_final"] is not None)
    und = sorted(r["ms_discover_to_final"] for r in rows)
    return {
        "n": len(rows),
        "n_uncensored_conf_final": len(unc),
        "censored_pct": round(100 * (len(rows) - len(unc)) / max(1, len(rows)), 1),
        "conf_final_ms_p50": pct(unc, 50), "conf_final_ms_p95": pct(unc, 95),
        "conf_final_ms_max": unc[-1] if unc else None,
        "proc_final_ms_p50": pct(unf, 50), "proc_final_ms_p95": pct(unf, 95),
        "disc_final_ms_p50": pct(und, 50), "disc_final_ms_p95": pct(und, 95),
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--samples", type=int, default=20)
    ap.add_argument("--poll", type=float, default=0.12)
    ap.add_argument("--gaps", type=int, default=15)
    ap.add_argument("--providers", type=str, default="all",
                    help="comma-separated provider keys or 'all'")
    args = ap.parse_args()

    keys = list(PROVIDERS) if args.providers == "all" else [
        k.strip() for k in args.providers.split(",") if k.strip() in PROVIDERS]
    data_dir = Path(__file__).parent / "data"
    data_dir.mkdir(exist_ok=True)
    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    all_stats = {"started_utc": ts, "slot_ms": SLOT_MS, "providers": {}}

    def log(msg):
        print(msg, flush=True)

    for key in keys:
        url = PROVIDERS[key]
        log(f"\n=== provider: {key} ({url})")
        t0 = time.time()
        rtt = probe_rtt(url)
        log(f"  rtt median: {fmt_ms(rtt)}ms")
        rows, tx_errors = measure_tx_finality(url, args.samples, args.poll, log)
        log("  slot-gap sampling…")
        gaps_c, gaps_f, ages = measure_slot_gap(url, args.gaps, log)
        st = stats_for(rows)
        st["errors"] = tx_errors
        st["rtt_ms_median"] = round(rtt, 1) if rtt else None
        st["duration_s"] = round(time.time() - t0, 1)
        if gaps_f:
            st["gap_processed_finalized_slots_p50"] = statistics.median(gaps_f)
            st["gap_processed_finalized_slots_max"] = max(gaps_f)
            st["gap_processed_finalized_ms_p50"] = round(statistics.median(gaps_f) * SLOT_MS)
        if gaps_c:
            st["gap_processed_confirmed_slots_p50"] = statistics.median(gaps_c)
            st["gap_processed_confirmed_ms_p50"] = round(statistics.median(gaps_c) * SLOT_MS)
        if ages:
            st["finalized_block_age_s_p50"] = statistics.median(ages)
            st["finalized_block_age_s_max"] = max(ages)
        all_stats["providers"][key] = st
        out = data_dir / f"raw_{key}_{ts}.jsonl"
        with out.open("w") as fh:
            for r in rows:
                fh.write(json.dumps(r) + "\n")
        log(f"  → {out.name}  n={st['n']} censored={st['censored_pct']}%")

    (Path(__file__).parent / "stats.json").write_text(json.dumps(all_stats, indent=2))

    log("\n## summary (discover(confirmed)→finalized upper bound, ms)")
    log("| provider | n | fin@1stpoll% | disc→fin p50 | p95 | rtt | gap p→f p50 | gap p→f max | finblock age p50/max |")
    log("|---|---|---|---|---|---|---|---|---|")
    for key, st in all_stats["providers"].items():
        log(f"| {key} | {st['n']} | {st['censored_pct']} "
            f"| {fmt_ms(st['disc_final_ms_p50'])} | {fmt_ms(st['disc_final_ms_p95'])} "
            f"| {fmt_ms(st.get('rtt_ms_median'))} "
            f"| {st.get('gap_processed_finalized_slots_p50', '-')}s/{fmt_ms(st.get('gap_processed_finalized_ms_p50'))}ms "
            f"| {st.get('gap_processed_finalized_slots_max', '-')}s "
            f"| {st.get('finalized_block_age_s_p50', '-')}/{st.get('finalized_block_age_s_max', '-')}s |")
    log("\ndone.")


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