#!/usr/bin/env python3
"""validate-dshackle-1to1.py — STRUCTURAL enforcement of the 1:1 proxy invariant.

1 dshackle = 1 node per chain: a dshackle MUST NOT route two upstreams for the same chain
(any kind or client). We cannot attribute traffic to multiple nodes behind one proxy (no
per-upstream request-count metric; conn-seconds biases it), so the config must be 1:1 — and
the attribution model (splits_node_revenue) + the placement planner both assume it.
See /root/proxy-1to1-invariant-plan.md and the /management skill ("1 dshackle = 1 node/chain").

This is the STRUCTURAL gate (vs the planner's runtime gate): it refuses to ACTIVATE a
dshackle config that violates 1:1, so a drifting config can never go live. Wire it into
reload_dshackle.sh BEFORE the dshackle reload (reject -> no reload -> old config stays):

    python3 /opt/.../rpc/validate-dshackle-1to1.py /root/rpc/main_configs || {
        echo "REFUSING reload: 1:1 invariant violated (see above)"; exit 1; }

Why host-local, not in update.sh: the vibe-node repo generates NODE composes (each declares
ONE chain via x-upstreams) but does not track which nodes attach to which dshackle — that
assignment is made at deploy/config-assembly time. So 1:1 can only be checked against the
ASSEMBLED dshackle config (cluster.upstreams), which lives here on the gateway host.

Usage: python3 validate-dshackle-1to1.py [config_dir_or_file]   (default /root/rpc/main_configs)
Exit 0 = OK; exit 1 = violation (do not reload).
"""
import sys, os, glob, collections

try:
    import yaml
except ImportError:
    print("validate-1to1: PyYAML required (pip install pyyaml)", file=sys.stderr)
    sys.exit(2)

DEFAULT = '/root/rpc/main_configs'


def _upstream_chains(doc):
    """Yield each upstream's `chain` in a dshackle config doc.

    Handles the shapes dshackle configs use:
      - cluster.upstreams: [{id, chain, methods, ...}, ...]   (the standard dshackle cluster)
      - x-upstreams:       [{id, chain, ...}, ...]            (compose extension; node-side def)
    An upstream without a `chain` field (bare id reference) is skipped here — if your config
    references upstreams by id only (chain resolved elsewhere), extend this to map id->chain
    from the x-upstreams defs in the same doc.
    """
    if not isinstance(doc, dict):
        return
    cl = doc.get('cluster') or {}
    if isinstance(cl, dict):
        for key in ('upstreams', 'include'):
            v = cl.get(key)
            if isinstance(v, list):
                for u in v:
                    if isinstance(u, dict) and u.get('chain'):
                        yield u['chain']
    xs = doc.get('x-upstreams')
    if isinstance(xs, list):
        for u in xs:
            if isinstance(u, dict) and u.get('chain'):
                yield u['chain']


def main(argv):
    path = argv[1] if len(argv) > 1 else DEFAULT
    if os.path.isfile(path):
        files = [path]
    else:
        files = sorted(glob.glob(os.path.join(path, '*.yaml')) + glob.glob(os.path.join(path, '*.yml')))
    if not files:
        print(f"validate-1to1: no dshackle configs at {path} (nothing to check)")
        return 0

    violations = []          # (config_file, chain, count)
    checked = 0
    for f in files:
        try:
            docs = list(yaml.safe_load_all(open(f)))
        except Exception as e:
            print(f"validate-1to1: WARN could not parse {f}: {e}", file=sys.stderr)
            continue
        for doc in docs:
            by_chain = collections.Counter(_upstream_chains(doc))
            if not by_chain:
                continue
            checked += 1
            for ch, n in by_chain.items():
                if n > 1:
                    violations.append((f, ch, n))
    if violations:
        print("❌ 1:1 invariant VIOLATIONS — a dshackle routing >1 node for the same chain:")
        for f, ch, n in violations:
            print(f"  {os.path.basename(f)}: chain '{ch}' routed by {n} upstreams "
                  f"— keep ONE node/proxy; for diversity add a dshackle (horizontal), don't stack")
        print("REFUSING to activate this config. Resolve by moving the extra node(s) behind a "
              "different dshackle in the same region.")
        return 1
    print(f"✅ 1:1 invariant OK — {checked} dshackle config(s), each chain ≤1 node per proxy.")
    return 0


if __name__ == '__main__':
    sys.exit(main(sys.argv))
