#!/usr/bin/env python3
"""Validate a plan against the vault and the churn limits. Prints the plan on
success (as JSON), exits 1 with a reason on failure. Nothing is sent when this
fails; the vault holds.

  validate.py <plan.json> <vault.json> [MAX_SWAPS] [MAX_SHIFT_BPS] [MAX_NAME_BPS] [universe.json]

Targets may name any asset in the universe (the shared venue registry); the
vault's holdings only matter for the churn arithmetic.
"""
import json, sys

plan_p, vault_p = sys.argv[1], sys.argv[2]
MAX_SWAPS = int(sys.argv[3]) if len(sys.argv) > 3 else 2
MAX_SHIFT = int(sys.argv[4]) if len(sys.argv) > 4 else 1500
MAX_NAME = int(sys.argv[5]) if len(sys.argv) > 5 else 4000
UNI_P = sys.argv[6] if len(sys.argv) > 6 else None

raw = json.load(open(plan_p))
plan = raw.get("structured_output") or raw.get("result") or raw
if isinstance(plan, str):
    t = plan.strip(); t = t[t.find("{"):t.rfind("}") + 1] if "{" in t else t
    plan = json.loads(t)
vault = json.load(open(vault_p))

def fail(msg):
    print("REJECTED: " + msg, file=sys.stderr); sys.exit(1)

current = {h["symbol"].upper(): int(h.get("weight_bps") or 0) for h in vault.get("holdings", [])}
allowed = set(current)
if UNI_P:
    try:
        u = json.load(open(UNI_P)); u = u.get("assets", u)
        allowed |= {str(x.get("symbol", "")).upper() for x in u if x.get("symbol")}
    except Exception as e:
        fail("cannot read the universe: %s" % e)
decision = plan.get("decision")
if decision not in ("hold", "rebalance"):
    fail("decision must be hold or rebalance")
targets = {str(k).upper(): int(v) for k, v in (plan.get("targets") or {}).items()}
for k, v in targets.items():
    if k not in allowed:
        fail("%s is not in the venue registry" % k)
    if v < 0 or v > MAX_NAME:
        fail("%s weight %d outside 0..%d" % (k, v, MAX_NAME))
if sum(targets.values()) > 10000:
    fail("weights sum to %d bps, max 10000" % sum(targets.values()))
held = {k for k, v in current.items() if v > 0}
want = {k for k, v in targets.items() if v > 0}
swaps = len(held ^ want)
shift = sum(abs(targets.get(k, 0) - current.get(k, 0)) for k in set(current) | set(targets)) // 2
opening = not held          # a vault with no positions is building its first book: churn caps do not apply
if opening:
    if decision == "rebalance" and not (5 <= len(want) <= 8):
        fail("an opening book needs 5 to 8 names, got %d" % len(want))
else:
    if swaps > MAX_SWAPS:
        fail("%d names added or removed, max %d" % (swaps, MAX_SWAPS))
    if shift > MAX_SHIFT:
        fail("%d bps moved, max %d" % (shift, MAX_SHIFT))
for text in [plan.get("note", ""), plan.get("last_run_review", "")] + list(plan.get("research") or []):
    if "—" in str(text):
        fail("em dash in text")
if not (40 <= len(str(plan.get("note", "")).split()) <= 220):
    fail("note length out of range")
plan["targets"] = targets
plan["_churn"] = {"swaps": swaps, "shift_bps": shift, "opening": opening}
print(json.dumps(plan))
