#!/usr/bin/env python3
"""Validate a Root & Branch Trade Register file.

No dependencies: python3 validate_trade_register.py trade-register-v0.1.json
Exit 0 if the file conforms, 1 if not. Every failure names the term.

The checks that matter are the ones a JSON Schema cannot state: that ids are
permanent and unique, that no label is claimed by two terms, that a variant
chain terminates, and that a null occupation code carries its reason.
"""
import json, re, sys

ID = re.compile(r"^TR-\d{5}$")
SOC = re.compile(r"^\d{2}-\d{4}$")
STATUS = {"current", "variant_of", "proposed", "rejected"}

def validate(path):
    errs, warns = [], []
    try:
        doc = json.load(open(path))
    except Exception as e:
        return [f"{path}: not readable as JSON — {e}"], []

    for f in ("trade_register_version", "as_of", "terms"):
        if f not in doc: errs.append(f"top level: missing '{f}'")
    terms = doc.get("terms", [])
    if not isinstance(terms, list):
        return errs + ["'terms' must be a list"], warns

    domains = {d["id"] for d in doc.get("groups", []) if isinstance(d, dict) and "id" in d}
    seen_id, label_owner = {}, {}

    for i, t in enumerate(terms):
        where = t.get("id") or f"terms[{i}]"
        if not isinstance(t, dict):
            errs.append(f"{where}: not an object"); continue

        tid = t.get("id")
        if not tid or not ID.match(str(tid)):
            errs.append(f"{where}: id must look like TR-00001")
        elif tid in seen_id:
            errs.append(f"{tid}: duplicate id (ids are permanent and unique)")
        else:
            seen_id[tid] = t

        label = t.get("preferred_label")
        if not label or len(str(label)) < 2:
            errs.append(f"{where}: preferred_label missing")

        st = t.get("status")
        if st not in STATUS:
            errs.append(f"{where}: status must be one of {sorted(STATUS)}")

        # every label this term answers to, checked for collisions across terms
        if st in ("current", "variant_of"):
            for lab in [label] + list(t.get("variant_labels") or []):
                if not lab: continue
                k = str(lab).strip().lower()
                if k in label_owner and label_owner[k] != tid:
                    errs.append(f"{where}: label {lab!r} is already claimed by {label_owner[k]} "
                                f"— a label resolves to exactly one term")
                else:
                    label_owner[k] = tid

        dom = t.get("group")
        if domains and dom not in domains:
            errs.append(f"{where}: group {dom!r} is not declared in 'groups'")

        if "soc_code" not in t:
            errs.append(f"{where}: soc_code is required — write null, never omit it")
        else:
            soc = t["soc_code"]
            if soc is None:
                if not str(t.get("soc_absent_reason") or "").strip():
                    errs.append(f"{where}: soc_code is null, so soc_absent_reason is required "
                                f"— an absent code is a finding, not missing data")
            elif not SOC.match(str(soc)):
                errs.append(f"{where}: soc_code {soc!r} is not a federal occupation code")

        if t.get("in_scope") is False and not str(t.get("out_of_scope_reason") or "").strip():
            errs.append(f"{where}: in_scope is false, so out_of_scope_reason is required")
        if st == "rejected" and not str(t.get("rejection_reason") or "").strip():
            errs.append(f"{where}: a rejected term must record why, so the list can be audited")
        if st == "variant_of" and not t.get("variant_of"):
            errs.append(f"{where}: status is variant_of, so variant_of must name the surviving term")
        if st != "variant_of" and t.get("variant_of"):
            errs.append(f"{where}: variant_of is set but status is {st!r}")

    # variant chains must terminate on a current term
    for tid, t in seen_id.items():
        hops, cur = 0, t
        while cur.get("status") == "variant_of":
            nxt = cur.get("variant_of")
            if nxt not in seen_id:
                errs.append(f"{tid}: variant_of points at {nxt!r}, which is not in this file"); break
            if nxt == cur.get("id"):
                errs.append(f"{tid}: variant_of points at itself"); break
            hops += 1
            if hops > 8:
                errs.append(f"{tid}: variant chain does not terminate (possible cycle)"); break
            cur = seen_id[nxt]

    n = doc.get("term_count")
    if n is not None and n != len(terms):
        warns.append(f"term_count says {n}, file holds {len(terms)}")

    return errs, warns

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("usage: validate_trade_register.py <file.json>"); sys.exit(2)
    errs, warns = validate(sys.argv[1])
    for w in warns: print("warning:", w)
    for e in errs: print("error:", e)
    if errs:
        print(f"\n{len(errs)} error(s). The file does not conform."); sys.exit(1)
    doc = json.load(open(sys.argv[1]))
    ts = doc["terms"]
    print(f"{sys.argv[1]}: conforms. {len(ts)} terms, "
          f"{sum(1 for t in ts if t.get('in_scope'))} enrollable, "
          f"{sum(1 for t in ts if t.get('soc_code') is None)} with no federal occupation code.")
