"""Visible deterministic rules for MKN-031 synthetic fixtures."""
from __future__ import annotations

def dependency_closure(fixture):
    targets=set(fixture.get('request',{}).get('target_node_ids') or [])
    edges=(fixture.get('dependency_graph') or {}).get('edges') or []
    out=set(); frontier=list(targets); seen=set(targets)
    while frontier:
        cur=frontier.pop()
        for e in edges:
            if e.get('from')==cur and e.get('to'):
                nxt=e['to']; out.add(nxt)
                if nxt not in seen:
                    seen.add(nxt); frontier.append(nxt)
    return sorted(out)

def inspect(f):
    issues=[]
    if not isinstance(f,dict): return ['INPUT_INVALID']
    canonical=f.get('canonical') or {}; request=f.get('request') or {}; cand=f.get('candidate') or {}; diff=f.get('diff') or {}
    targets=set(request.get('target_node_ids') or []); protected=set(request.get('protected_node_ids') or [])
    if targets & protected: issues.append('SCOPE_CONFLICT')
    if cand.get('generated_from_revision_id')!=canonical.get('revision_id') or cand.get('base_digest')!=canonical.get('digest'):
        issues.append('STALE_BASE')
    if not f.get('source_snapshot') or not f.get('evidence_snapshot') or not cand.get('source_snapshot_ref') or not cand.get('evidence_snapshot_ref'):
        issues.append('EVIDENCE_MISSING')
    changed=set(cand.get('changed_node_ids') or [])
    if not changed.issubset(targets): issues.append('UNAUTHORIZED_CANONICAL_CHANGE')
    nodes=canonical.get('nodes') or {}
    pd=cand.get('protected_digest_map') or {}
    for nid in protected:
        node=nodes.get(nid) or {}
        if pd.get(nid)!=node.get('digest'):
            issues.append('PROTECTED_NODE_CHANGED'); break
    for nid,snap in (cand.get('node_snapshots') or {}).items():
        base=nodes.get(nid) or {}
        if base and (snap.get('node_type')!=base.get('node_type') or snap.get('semantic_key')!=base.get('semantic_key')):
            issues.append('NODE_ID_SEMANTIC_REUSE'); break
    patch=cand.get('patch') or []
    seen_mutation=False; have_rev=False; have_digest=False
    for op in patch:
        mut=op.get('op') in {'add','remove','replace','move','copy'}
        if mut: seen_mutation=True
        if op.get('op')=='test' and not seen_mutation:
            if op.get('path')=='/canonical/revision_id' and op.get('value')==canonical.get('revision_id'): have_rev=True
            if op.get('path')=='/canonical/digest' and op.get('value')==canonical.get('digest'): have_digest=True
    if not (have_rev and have_digest): issues.append('PRECONDITION_FAILURE')
    expected=set(dependency_closure(f)); declared=set(diff.get('affected_derived_artifacts') or [])
    if expected!=declared: issues.append('DEPENDENCY_IMPACT_MISMATCH')
    if not cand.get('provenance_activity_ref') or not cand.get('generator_ref') or not request.get('generation_policy_version'):
        issues.append('PROVENANCE_MISSING')
    if diff.get('structural_validation_state')!='PASS': issues.append('STRUCTURAL_VALIDATION_FAILURE')
    return sorted(set(issues))

def expected_decision(f):
    issues=inspect(f)
    blocking={'INPUT_INVALID','SCOPE_CONFLICT','STALE_BASE','EVIDENCE_MISSING','UNAUTHORIZED_CANONICAL_CHANGE','PROTECTED_NODE_CHANGED','NODE_ID_SEMANTIC_REUSE','PRECONDITION_FAILURE','DEPENDENCY_IMPACT_MISMATCH','PROVENANCE_MISSING','STRUCTURAL_VALIDATION_FAILURE'}
    if blocking & set(issues): return 'HOLD'
    review=f.get('review') or {}
    if review.get('consequential') and review.get('state')!='APPROVED': return 'REVIEW_REQUIRED'
    return 'PROMOTE_ELIGIBLE_AFTER_REVIEW'
