#!/usr/bin/env python3
"""Visible deterministic AWR-008 evidence-consistency and fixture-binding checker.

The checker binds learner-supplied evidence to shipped synthetic fixtures and
checks the published rules. It never selects an adoption decision and contains
no fixture-specific answer key.
"""
from __future__ import annotations
from typing import Any, Dict, List
from pathlib import Path
import copy, json

DEFAULT_POSITIVE={"USE","PILOT"}
DEFAULT_ALLOWED={"USE","PILOT","NOT_NOW","UNKNOWN","EVIDENCE_INCOMPLETE","OWNER_MISSING","REVIEW_REQUIRED","HOLD"}
HIGH_RISK_TARGET_TOKENS=("BILLING","PRODUCTION_DATA","PRODUCTION SYSTEM","PRODUCTION_SYSTEM")


def _text(v: Any) -> bool: return isinstance(v,str) and bool(v.strip())
def _nonempty_list(v: Any) -> bool: return isinstance(v,list) and any(_text(x) for x in v)
def _err(code: str, rule: str, message: str) -> Dict[str,str]: return {"code":code,"rule":rule,"message":message}

def _rule_config(visible_rules: Dict[str,Any] | None, errors: List[Dict[str,str]]):
    if not isinstance(visible_rules,dict) or visible_rules.get('schema')!='MAKNOW_AWR008_LAB_VISIBLE_RULES_v1.0':
        errors.append(_err('E_RULESET','R11/R12','Published CHECK_RULES.json is missing or has an unexpected schema.'))
        return DEFAULT_POSITIVE, DEFAULT_ALLOWED, {'required_fields':['state','rationale','stop_or_pivot_condition','governance_triggers'],'governance_trigger_types':['REVIEW','MIGRATION','ESCALATION']}
    ids={r.get('id') for r in visible_rules.get('rules',[]) if isinstance(r,dict)}
    needed={f'R{i}' for i in range(1,13)}
    if not needed.issubset(ids):
        errors.append(_err('E_RULESET','R11/R12','Published rule IDs R1–R12 must all be present.'))
    positive=set(visible_rules.get('positive_states',[])) or DEFAULT_POSITIVE
    allowed=set(visible_rules.get('allowed_states',[])) or DEFAULT_ALLOWED
    return positive, allowed, visible_rules.get('decision_contract',{})

def _decision(axis: Dict[str,Any], prefix: str, errors: List[Dict[str,str]], positive:set[str], allowed:set[str], contract:Dict[str,Any]):
    d=axis.get('decision') if isinstance(axis.get('decision'),dict) else {}
    state=d.get('state')
    if state not in allowed:
        errors.append(_err(f'E_{prefix}_DECISION','R10/R11','Choose one allowed state; the checker will not choose it.'))
        return None,False
    missing=[]
    if not _text(d.get('rationale')): missing.append('rationale')
    if not _text(d.get('stop_or_pivot_condition')): missing.append('stop_or_pivot_condition')
    triggers=d.get('governance_triggers')
    allowed_types=set(contract.get('governance_trigger_types') or ['REVIEW','MIGRATION','ESCALATION'])
    if not isinstance(triggers,list) or not triggers:
        missing.append('governance_triggers')
    else:
        valid=False
        for t in triggers:
            if isinstance(t,dict) and t.get('type') in allowed_types and _text(t.get('condition')):
                valid=True
            else:
                errors.append(_err(f'E_{prefix}_GOVERNANCE_TRIGGER','R10','Each governance trigger needs an allowed type and a non-empty condition.'))
        if not valid: missing.append('governance_triggers')
    if missing:
        errors.append(_err(f'E_{prefix}_DECISION_META','R10','Each axis decision requires: '+', '.join(sorted(set(missing)))))
    return state,state in positive

def _deep_merge(base:Dict[str,Any], changes:Dict[str,Any]):
    out=copy.deepcopy(base)
    for k,v in changes.items():
        if isinstance(v,dict) and isinstance(out.get(k),dict): out[k]=_deep_merge(out[k],v)
        else: out[k]=copy.deepcopy(v)
    return out

def _bound_fixture(gate:Dict[str,Any], visible_rules:Dict[str,Any] | None, lab_root:Path | None, errors:List[Dict[str,str]]):
    if lab_root is None: return None
    if not isinstance(visible_rules,dict): return None
    manifest_rel=visible_rules.get('fixture_binding_manifest')
    if not _text(manifest_rel):
        errors.append(_err('E_RULESET','R12','Published rules do not declare a fixture binding manifest.')); return None
    try:
        manifest=json.loads((lab_root/manifest_rel).read_text(encoding='utf-8'))
    except Exception:
        errors.append(_err('E_FIXTURE_REF','R12','Fixture binding manifest cannot be loaded.')); return None
    ref=gate.get('fixture_ref')
    spec=manifest.get('bindings',{}).get(ref)
    if not isinstance(spec,dict):
        errors.append(_err('E_FIXTURE_REF','R12','fixture_ref does not resolve to a shipped synthetic fixture.')); return None
    try:
        if spec['kind']=='BASE':
            fixture=json.loads((lab_root/spec['path']).read_text(encoding='utf-8'))
        elif spec['kind']=='MUTATION':
            fixture=json.loads((lab_root/spec['base']).read_text(encoding='utf-8'))
            mutation=json.loads((lab_root/spec['path']).read_text(encoding='utf-8'))
            axis_key='virtual_cast' if mutation.get('axis')=='VIRTUAL_CAST' else 'repair_agent'
            fixture[axis_key]=_deep_merge(fixture[axis_key],mutation.get('changes',{}))
            fixture['fixture_id']=mutation.get('mutation_id')
        else: raise ValueError('kind')
    except Exception:
        errors.append(_err('E_FIXTURE_REF','R12','Resolved fixture files are invalid or unavailable.')); return None
    return fixture

def _strip_decision(axis:Dict[str,Any]): return {k:v for k,v in axis.items() if k!='decision'}

def _check_binding(gate:Dict[str,Any], fixture:Dict[str,Any] | None, errors:List[Dict[str,str]]):
    if fixture is None: return
    mismatches=[]
    if gate.get('artifact_id')!=fixture.get('artifact_id'): mismatches.append('artifact_id')
    for axis in ('virtual_cast','repair_agent'):
        g=gate.get(axis) if isinstance(gate.get(axis),dict) else {}
        f=fixture.get(axis) if isinstance(fixture.get(axis),dict) else {}
        if _strip_decision(g)!=_strip_decision(f): mismatches.append(axis)
    if mismatches:
        errors.append(_err('E_FIXTURE_BINDING','R12','Learner evidence does not exactly bind to the declared shipped fixture fields: '+', '.join(mismatches)))

def validate_gate(gate:Dict[str,Any], visible_rules:Dict[str,Any] | None=None, lab_root:Path | str | None=None)->List[Dict[str,str]]:
    errors=[]
    positive,allowed,decision_contract=_rule_config(visible_rules,errors)
    if isinstance(lab_root,str): lab_root=Path(lab_root)
    fixture=_bound_fixture(gate,visible_rules,lab_root,errors)
    _check_binding(gate,fixture,errors)
    vc=gate.get('virtual_cast') if isinstance(gate.get('virtual_cast'),dict) else {}
    ra=gate.get('repair_agent') if isinstance(gate.get('repair_agent'),dict) else {}
    vc_state,vc_positive=_decision(vc,'VC',errors,positive,allowed,decision_contract)
    ra_state,ra_positive=_decision(ra,'RA',errors,positive,allowed,decision_contract)

    if vc_positive:
        required=("problem","trigger","identity_rights_owner","rights_status","asset_pipeline","capture_requirement","animation_requirement","render_target","quality_target","quality_evidence","production_owner","simpler_alternative","review_due")
        missing=[k for k in required if not _text(vc.get(k))]
        if not isinstance(vc.get('character_count'),int) or vc.get('character_count',0)<=0: missing.append('character_count')
        if not _nonempty_list(vc.get('evidence_refs')): missing.append('evidence_refs')
        cost=vc.get('cost_risk') if isinstance(vc.get('cost_risk'),dict) else {}
        if not isinstance(cost.get('budget_units'),(int,float)) or not isinstance(cost.get('estimated_units'),(int,float)) or not _text(cost.get('evidence_ref')): missing.append('cost_risk')
        if missing: errors.append(_err('E_VC_REQUIRED','R1','Positive virtual-cast state is missing: '+', '.join(sorted(set(missing)))))
        if not _text(vc.get('identity_rights_owner')) or not _text(vc.get('production_owner')) or vc.get('rights_status') in {'MISSING','UNKNOWN','DISPUTED'}:
            errors.append(_err('E_VC_OWNER','R2','Positive virtual-cast state requires documented rights and production ownership.'))
        if isinstance(cost.get('budget_units'),(int,float)) and isinstance(cost.get('estimated_units'),(int,float)) and cost['estimated_units']>cost['budget_units']:
            errors.append(_err('E_VC_BUDGET','R3','Declared render/animation estimate exceeds the target budget.'))

    if ra_positive:
        required=("problem","trigger","repair_target","failure_signature","write_scope","patch_plan","side_effect_class","guardrail_state","test_fixture","evaluation_result","rollback_plan","audit_trace","production_owner","simpler_alternative","review_due")
        missing=[k for k in required if not _text(ra.get(k))]
        if not _nonempty_list(ra.get('tool_scope')): missing.append('tool_scope')
        if not _nonempty_list(ra.get('evidence_refs')): missing.append('evidence_refs')
        if missing: errors.append(_err('E_RA_CONTROL_EVIDENCE','R4/R6/R9','Positive repair-agent state is missing: '+', '.join(sorted(set(missing)))))
        if not isinstance(ra.get('approval_required'),bool):
            errors.append(_err('E_RA_APPROVAL_REQUIRED_TYPE','R8','Positive repair-agent state requires explicit boolean approval_required.'))
        elif ra.get('approval_required') is True:
            if ra.get('approval_state')!='CONFIGURED' or not _text(ra.get('approval_owner')):
                errors.append(_err('E_RA_APPROVAL','R8','Required approval must be configured with an explicit approval owner.'))
        else:
            if ra.get('approval_state')!='NOT_REQUIRED':
                errors.append(_err('E_RA_APPROVAL','R8','When approval_required=false, approval_state must explicitly be NOT_REQUIRED; the visible decision rationale carries the bounded justification.'))
        if ra_state=='USE' and ra.get('write_scope')=='PROPOSE_ONLY': errors.append(_err('E_RA_USE_SCOPE','R5','USE is incompatible with PROPOSE_ONLY write scope in this gate.'))
        target=str(ra.get('repair_target','')).upper()
        if any(t in target for t in HIGH_RISK_TARGET_TOKENS): errors.append(_err('E_RA_HIGH_RISK_TARGET','R7','Billing-code or production-data targets require a typed non-success/review state in this offline awareness lab.'))
        if ra.get('evaluation_result')!='PASS': errors.append(_err('E_RA_EVAL','R9','Positive repair-agent state requires PASS evaluation evidence.'))
        if not _text(ra.get('test_fixture')) or not _text(ra.get('rollback_plan')): errors.append(_err('E_RA_CONTROL_EVIDENCE','R6/R9','Repository/sandbox write authority requires deterministic tests and rollback evidence.'))
    return errors
