#!/usr/bin/env python3
"""welshdag-verify — check a chain-1404 node without installing anything.

Answers the five questions an operator actually has:

  1. Which branch am I on?      two networks share chain ID 1404
  2. Am I synced?
  3. Will a restart corrupt my state?   the shutdown-timeout trap
  4. Is my node.conf sane?
  5. Do my host scripts resolve to a LIVE container?

Read-only. Talks to an RPC endpoint and, optionally, the local Docker socket.
It changes nothing.

    ./welshdag-verify                          # local node on 127.0.0.1:8545
    ./welshdag-verify --rpc http://host:8545
    ./welshdag-verify --json

Exit 0 = all checks passed, 1 = at least one FAIL, 2 = could not reach the RPC.
"""
import argparse, json, os, subprocess, sys, urllib.request

CHAIN_ID = 1404
GENESIS = "0x3fb19ea409ac7399ad7b988b88e0d436722c967137e51e834ccefd7611a592ef"

# Measured 2026-09-13 by binary search between our archive and rpc.bdagscan.com,
# then confirmed directly. Re-checkable with two eth_getBlockByNumber calls.
FORK = {
    "last_common": (316001,
                    "0xb4ee7f82e9d79949a36922a69e02832eb0da5ee544ba5073fbd0367e7a9db300"),
    "first_diff": 316002,
    "welshdag": "0xcd4d2568e9cba6725329e8cf6d96217ace7acb03d71d9b519c8b2560bb6bb781",
    "bdagscan": "0xe2c7a9b0ff6206e6ac93f2cceead3992e081a4be64f5a5975d6e2158cc02a824",
}

RESULTS = []
GREEN, RED, YELL, DIM, OFF = "\033[32m", "\033[31m", "\033[33m", "\033[2m", "\033[0m"


def rec(status, name, detail):
    RESULTS.append({"check": name, "status": status, "detail": detail})


def rpc(url, method, params=None, timeout=20):
    req = urllib.request.Request(
        url,
        data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": method,
                         "params": params or []}).encode(),
        headers={"content-type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        d = json.load(r)
    if "error" in d:
        raise RuntimeError(d["error"])
    return d.get("result")


def block_hash(url, n):
    b = rpc(url, "eth_getBlockByNumber", [hex(n), False])
    return b["hash"] if b else None


def check_chain(url):
    try:
        cid = int(rpc(url, "eth_chainId"), 16)
    except Exception as e:
        rec("FAIL", "rpc reachable", f"{url}: {e}")
        return False
    rec("PASS" if cid == CHAIN_ID else "FAIL", "chain id",
        f"{cid}" + ("" if cid == CHAIN_ID else f" (expected {CHAIN_ID})"))

    try:
        g = block_hash(url, 0)
        rec("PASS" if g == GENESIS else "FAIL", "genesis hash",
            g if g == GENESIS else f"{g} != {GENESIS}")
    except Exception as e:
        rec("WARN", "genesis hash", str(e))
    return True


def check_branch(url):
    """Which side of the 316,002 split is this node on?"""
    n_common, h_common = FORK["last_common"]
    try:
        got_common = block_hash(url, n_common)
        got_fork = block_hash(url, FORK["first_diff"])
    except Exception as e:
        rec("WARN", "branch", f"cannot read history: {e}")
        return
    if got_common is None or got_fork is None:
        rec("WARN", "branch",
            f"node has no block {FORK['first_diff']} (not synced far enough, "
            "or pruned)")
        return
    if got_common != h_common:
        rec("FAIL", "branch",
            f"block {n_common} is {got_common}, which is neither known chain")
        return
    if got_fork == FORK["welshdag"]:
        rec("PASS", "branch", f"WelshDAG branch (block {FORK['first_diff']} = "
                              f"{got_fork[:14]}…)")
    elif got_fork == FORK["bdagscan"]:
        rec("FAIL", "branch",
            f"bdagscan branch — this node does NOT share history with "
            f"rpc.welshdag.co.uk from block {FORK['first_diff']} onward")
    else:
        rec("FAIL", "branch",
            f"block {FORK['first_diff']} is {got_fork}, neither known chain")


def check_sync(url):
    try:
        s = rpc(url, "eth_syncing")
        head = int(rpc(url, "eth_blockNumber"), 16)
    except Exception as e:
        rec("WARN", "sync", str(e)); return
    if s:
        cur = int(s.get("currentBlock", "0x0"), 16)
        hi = int(s.get("highestBlock", "0x0"), 16)
        rec("WARN", "sync", f"syncing {cur} / {hi} ({hi - cur} behind)")
    else:
        rec("PASS", "sync", f"not syncing, head {head}")

    try:
        b = rpc(url, "eth_getBlockByNumber", ["latest", False])
        import time
        age = int(time.time()) - int(b["timestamp"], 16)
        rec("PASS" if age < 300 else "FAIL", "head freshness",
            f"tip is {age}s old" + ("" if age < 300 else " — node is stalled"))
    except Exception as e:
        rec("WARN", "head freshness", str(e))


def _compose_env(container, key):
    try:
        out = subprocess.run(
            ("docker", "inspect", "-f",
             "{{range .Config.Env}}{{println .}}{{end}}", container),
            capture_output=True, text=True, timeout=15)
    except (OSError, subprocess.SubprocessError):
        return None
    for line in out.stdout.splitlines():
        if line.startswith(key + "="):
            return line.split("=", 1)[1]
    return None


def _secs(v):
    if not v:
        return None
    v = v.strip().lower()
    try:
        if v.endswith("ms"):
            return float(v[:-2]) / 1000
        if v.endswith("s"):
            return float(v[:-1])
        if v.endswith("m"):
            return float(v[:-1]) * 60
        return float(v)
    except ValueError:
        return None


def check_local():
    """Docker-side checks. Skipped cleanly when there is no Docker."""
    here = os.path.dirname(os.path.abspath(__file__))
    sys.path.insert(0, here)
    try:
        from welshdag_topology import resolve_container, active_project
    except Exception:
        rec("WARN", "topology", "welshdag_topology.py not alongside this script")
        return

    proj = active_project()
    if not proj:
        rec("WARN", "topology",
            "no running BlockDAG stack found (fine if this is a remote check)")
        return
    rec("PASS", "topology", f"compose project {proj}")

    node = resolve_container("node")
    if not node:
        rec("FAIL", "node container", f"no RUNNING node in project {proj}")
        return
    rec("PASS", "node container", node)

    # The trap: 60s kills the node mid state-trie flush and still exits 0.
    wt = _secs(_compose_env(node, "BDAG_NODEWORKER_SHUTDOWN_TIMEOUT"))
    try:
        st = subprocess.run(("docker", "inspect", "-f", "{{.Config.StopTimeout}}",
                             node), capture_output=True, text=True, timeout=15)
        grace = int((st.stdout or "0").strip() or 0)
    except Exception:
        grace = 0

    if wt is None:
        rec("FAIL", "shutdown timeout",
            "BDAG_NODEWORKER_SHUTDOWN_TIMEOUT unset — upstream default is 60s, "
            "which corrupts EVM state on restart")
    elif wt < 300:
        rec("FAIL", "shutdown timeout",
            f"{wt:.0f}s is too short; the state-trie flush routinely exceeds it "
            "and the node is killed mid-write (and still exits 0). Want >= 600s")
    else:
        rec("PASS", "shutdown timeout", f"{wt:.0f}s")

    if grace and wt and grace <= wt:
        rec("FAIL", "stop grace period",
            f"{grace}s <= worker timeout {wt:.0f}s — Docker will kill the "
            "container while the worker is still flushing")
    elif grace:
        rec("PASS", "stop grace period", f"{grace}s")
    else:
        rec("WARN", "stop grace period", "not set on the container")

    tx = _compose_env(node, "BDAG_NODE_MINING_NO_PENDING_TX")
    if tx is None:
        rec("WARN", "transaction inclusion",
            "BDAG_NODE_MINING_NO_PENDING_TX unset — it DEFAULTS TO ON, meaning "
            'blocks you mine exclude transactions. Set "0" to include them')
    elif tx.strip() in ("0", "false", "no"):
        rec("PASS", "transaction inclusion", "transactions included")
    else:
        rec("FAIL", "transaction inclusion",
            f'set to "{tx}" — your blocks are empty and you forfeit every fee')


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--rpc", default="http://127.0.0.1:8545")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--no-local", action="store_true",
                    help="skip Docker checks")
    a = ap.parse_args()

    ok = check_chain(a.rpc)
    if ok:
        check_branch(a.rpc)
        check_sync(a.rpc)
    if not a.no_local:
        check_local()

    if a.json:
        print(json.dumps({"rpc": a.rpc, "results": RESULTS}, indent=2))
    else:
        print(f"\n  welshdag-verify  {DIM}{a.rpc}{OFF}\n")
        for r in RESULTS:
            c = {"PASS": GREEN, "FAIL": RED, "WARN": YELL}.get(r["status"], "")
            print(f"  {c}{r['status']:4s}{OFF}  {r['check']:22s} {DIM}{r['detail']}{OFF}")
        fails = sum(1 for r in RESULTS if r["status"] == "FAIL")
        warns = sum(1 for r in RESULTS if r["status"] == "WARN")
        print(f"\n  {len(RESULTS)} checks · {fails} failed · {warns} warnings\n")

    if not ok:
        return 2
    return 1 if any(r["status"] == "FAIL" for r in RESULTS) else 0


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