#!/usr/bin/env python3
"""
Validate the canonical structured model; re-derive feeds from depends_on; lint prestige nouns.
depends_on is the SINGLE SOURCE OF TRUTH. feeds is always regenerated. Run after any edit.
Usage: python3 rb_validate.py <structured.yaml>   (exit 0 clean, 1 problems)
"""
import sys, yaml, re


class DupKeyLoader(yaml.SafeLoader):
    pass
def _no_dup(loader, node, deep=False):
    mapping={}
    for k,v in node.value:
        key=loader.construct_object(k, deep=deep)
        if key in mapping:
            raise yaml.constructor.ConstructorError(None,None,f"duplicate key: {key!r}",k.start_mark)
        mapping[key]=loader.construct_object(v, deep=deep)
    return mapping
DupKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_dup)

STATUSES={'settled','correction','neg-only','decomposable-excluded-residual','do-not-assert-yet','reconcile','illustration'}
TYPES={'meta-commitment','core-rule','derived-rule','mechanism-definition','mechanism','transformation-rule','anti-rule','definition'}
REQUIRED=['id','type','status','definition','mechanism','depends_on','supersedes','guards','illustration','disconfirms_if','open','reconcile']

# prestige/storage nouns that must not appear UNQUALIFIED in definition/mechanism.
# allowed only inside guards.rewrite / guards.rewrite_to (naming a frame to exclude).
PRESTIGE = [r'\bmemor(y|ies)\b', r'\bstor(e|ed|age|ing)\b', r'\bretriev(e|ed|al|ing)\b',
            r'\brepresent(s|ed|ation|ations)?\b', r'\bencod(e|ed|ing|es)\b',
            r'\bintelligence\b', r'\bconsciousness\b', r'\brefus\w*\b', r'\breject\w*\b']
# qualifier patterns that make an occurrence OK (the word is being decomposed/excluded right there)
QUALIFIED = re.compile(r'(folk|so-called|not |exclud|decompos|the word|unqualified|instead of|rather than|what .* calls)', re.I)

def text_of(field):
    if field is None: return ""
    if isinstance(field,str): return field
    if isinstance(field,list): return " ".join(str(x) for x in field)
    if isinstance(field,dict): return " ".join(str(v) for v in field.values())
    return str(field)


def ingestion_check(base_path, patch_path):
    """VALIDATE.INGESTION.01: a patch may touch support_layer fields only, never claim_layer."""
    base=yaml.safe_load(open(base_path))
    gov=(base.get('meta',{}) or {}).get('governance',{})
    claim_layer=set((gov.get('layer_partition',{}) or {}).get('claim_layer',[]))
    patch=yaml.safe_load(open(patch_path))
    # patch format: {node_id: {field: value, ...}, ...} OR {nodes:[{id, field:...}]}
    violations=[]
    def scan(node_id, fields):
        for f in fields:
            if f in claim_layer:
                violations.append(f"{node_id}: ingestion patch modifies claim-layer field '{f}'")
    if isinstance(patch, dict) and 'nodes' in patch:
        for n in patch['nodes']:
            scan(n.get('id','?'), [k for k in n if k!='id'])
    elif isinstance(patch, dict):
        for nid, fields in patch.items():
            if isinstance(fields, dict): scan(nid, list(fields))
    if violations:
        print("INGESTION CHECK FAILED:")
        for v in violations: print("  -", v)
        print("\nIngestion writes support. Gated review writes claims.")
        sys.exit(1)
    print("INGESTION CHECK PASSED: patch touches support-layer fields only.")
    sys.exit(0)

def main():
    if len(sys.argv)>=4 and sys.argv[1]=='--ingestion-check':
        ingestion_check(sys.argv[2], sys.argv[3]); return
    path=sys.argv[1]
    try:
        doc=yaml.load(open(path), Loader=DupKeyLoader)
    except yaml.constructor.ConstructorError as ce:
        print('PARSE ERROR (duplicate key):', ce); sys.exit(1)
    nodes={n['id']:n for n in doc['nodes']}; ids=set(nodes)
    errs, lint=[], []

    for nid,n in nodes.items():
        for f in REQUIRED:
            if f not in n: errs.append(f"{nid}: missing field '{f}'")
        if n.get('status') not in STATUSES: errs.append(f"{nid}: bad status '{n.get('status')}'")
        if n.get('type') not in TYPES: errs.append(f"{nid}: bad type '{n.get('type')}'")
        g=n.get('guards') or {}
        if not isinstance(g,dict) or 'rewrite' not in g or 'rewrite_to' not in g:
            errs.append(f"{nid}: guards must have rewrite[] and rewrite_to{{}}")
        ill=n.get('illustration')
        if isinstance(ill,dict) and ill.get('carries_claim',False):
            errs.append(f"{nid}: illustration carries_claim must be false")
        for dep in (n.get('depends_on') or []):
            if dep not in ids: errs.append(f"{nid}.depends_on -> unknown {dep}")
        # prestige lint: scan definition + mechanism only (NOT guards, where exclusions live)
        body = text_of(n.get('definition')) + " || " + text_of(n.get('mechanism'))
        for pat in PRESTIGE:
            for mobj in re.finditer(pat, body, re.I):
                # window around the hit
                s=max(0,mobj.start()-40); e=min(len(body),mobj.end()+40)
                window=body[s:e]
                if not QUALIFIED.search(window):
                    lint.append(f"{nid}: unqualified prestige noun '{mobj.group(0)}' in definition/mechanism -> '{window.strip()}'")


    # --- meta staleness / structure checks ---
    meta=doc.get('meta',{})
    if 'composition_order' in meta:
        errs.append("meta.composition_order is stale (primitive does not belong in substrate); use process_graphs")
    pg=meta.get('process_graphs',{})
    sub=pg.get('substrate_composition',[])
    if 'primitive' in sub:
        errs.append("process_graphs.substrate_composition contains 'primitive' (it is a process product, not a substrate tier)")
    # prose filename existence
    gp=meta.get('generated_prose')
    if gp:
        import os
        if not os.path.exists(os.path.join(os.path.dirname(path) or '.', gp)):
            lint.append(f"meta.generated_prose '{gp}' not found next to this file (regenerate or fix name)")
    # --- precise lint: a definition must not restate its own excluded frame ---
    for nid,n in nodes.items():
        d=(n.get('definition','') or '').lower()
        for bad in ((n.get('guards') or {}).get('rewrite') or []):
            # crude overlap: if 5+ consecutive content words of the rewrite-target appear in the definition
            bw=[w for w in re.findall(r'[a-z]+', bad.lower()) if len(w)>3]
            if len(bw)>=4:
                joined=' '.join(bw[:5])
                if joined and joined in re.sub(r'[^a-z ]',' ',d):
                    lint.append(f"{nid}: definition restates an excluded frame: '{bad}'")


    # --- vector-language guard: meta must carry the metaphor disclaimer if vector language is used ---
    vlang=any(re.search(r'\bvector', text_of(n.get('definition'))+text_of(n.get('mechanism')), re.I) for n in nodes.values())
    if vlang and 'vector_language_guard' not in (doc.get('meta') or {}):
        errs.append("vector language used but meta.vector_language_guard missing (must state vector=metabolic metaphor, not literal encoding)")


    # --- build-time rail checks ---
    meta=doc.get('meta',{})
    ev_scale=set(meta.get('evidence_scale',[]))
    pv_scale=set(meta.get('provenance_scale',[]))
    OPEN_STATUSES={'do-not-assert-yet','reconcile','neg-only'}
    for nid,n in nodes.items():
        # evidence/provenance present and in-enum
        if 'evidence' not in n: errs.append(f"{nid}: missing evidence field")
        elif ev_scale and n['evidence'] not in ev_scale: errs.append(f"{nid}: evidence '{n['evidence']}' not in evidence_scale")
        if 'provenance' not in n: errs.append(f"{nid}: missing provenance field")
        elif pv_scale and n['provenance'] not in pv_scale: errs.append(f"{nid}: provenance '{n['provenance']}' not in provenance_scale")
        if 'closed_by' not in n: errs.append(f"{nid}: missing closed_by field")
        # GATED PROMOTION: a node that is settled but whose evidence implies it was an open item
        # cannot claim settled-from-open without closed_by. We enforce the general rule:
        # if status==settled AND evidence in (unrated,none,model-inference) AND node is in an
        # attractor watch list for premature-closure, require closed_by. Lighter rule: warn when
        # a settled node has evidence 'unrated' (visible, shrinking over time, non-blocking).
        if n.get('status')=='settled' and n.get('evidence')=='unrated':
            lint.append(f"{nid}: settled but evidence unrated (assign evidence tier when touched)")
        # hard rule: open-status nodes must NOT carry closed_by unless also promoted, and a
        # settled node that supersedes an open framing must cite closed_by
    # premature-closure guard: any node listed in the premature-closure attractor that is now
    # settled must have closed_by
    for atk in meta.get('known_attractors',[]):
        if atk.get('name')=='premature-closure':
            for wid in atk.get('watch_nodes',[]):
                w=nodes.get(wid)
                if w and w.get('status')=='settled' and not w.get('closed_by'):
                    errs.append(f"{wid}: promoted to settled without closed_by (premature-closure guard)")

    # rebuild feeds
    for n in nodes.values(): n['feeds']=[]
    for nid,n in nodes.items():
        for dep in (n.get('depends_on') or []):
            if dep in nodes: nodes[dep]['feeds'].append(nid)
    for n in nodes.values(): n['feeds']=sorted(set(n['feeds']))

    # cycles
    g={nid:set(d for d in (n.get('depends_on') or []) if d in ids) for nid,n in nodes.items()}
    color={n:0 for n in g}; cyc=[]
    def dfs(u,st):
        color[u]=1
        for v in g[u]:
            if color[v]==1: cyc.append(st+[u,v])
            elif color[v]==0: dfs(v,st+[u])
        color[u]=2
    for n in g:
        if color[n]==0: dfs(n,[])
    for c in cyc: errs.append("cycle: "+" -> ".join(c))

    if not errs:
        def keyf(x):
            l=x[0]; r=x[1:]; num=''.join(c for c in r if c.isdigit()); suf=''.join(c for c in r if not c.isdigit())
            return (l,int(num) if num else 0,suf)
        doc['nodes']=[nodes[i] for i in sorted(nodes,key=keyf)]
        yaml.dump(doc,open(path,'w'),sort_keys=False,default_flow_style=False,width=100,allow_unicode=True)

    retr=[(nid,n['supersedes']) for nid,n in nodes.items() if n.get('supersedes')]
    opens=[(nid,n['status'],n.get('open') or n.get('reconcile')) for nid,n in nodes.items()
           if n['status'] in ('do-not-assert-yet','reconcile','neg-only') or n.get('open') or n.get('reconcile')]

    print(f"{len(nodes)} nodes.",
          {s:sum(1 for n in nodes.values() if n['status']==s) for s in sorted(STATUSES) if any(n['status']==s for n in nodes.values())})
    print()
    print("ERRORS:", "none" if not errs else "")
    for e in errs: print("  -",e)
    print(f"\nLINT ({len(lint)}):", "clean" if not lint else "")
    for l in lint: print("  -",l)
    print(f"\nRETRACTIONS [{len(retr)}], OPEN/RECONCILE/NEG-ONLY [{len(opens)}]")

    print(f"\nKNOWN ATTRACTORS [{len(meta.get('known_attractors',[]))}]:")
    for a in meta.get('known_attractors',[]):
        print(f"  - {a['name']} (caught x{a.get('caught_times',0)}) watch: {','.join(a.get('watch_nodes',[]))}")
    # evidence distribution
    from collections import Counter
    ev=Counter(n.get('evidence','?') for n in nodes.values())
    print(f"\nEVIDENCE DISTRIBUTION: {dict(ev)}")

    sys.exit(1 if errs else 0)

if __name__=='__main__': main()
