#!/usr/bin/env python3
"""Visible D3D-001 Lab checker with deterministic fixture binding.

CHECK_RULES.json and shipped fixture files are the visible rule/evidence source.
The checker never chooses a learner decision and contains no hidden expected
verdict. It only checks evidence completeness, consistency, and exact binding
for evidence the learner submits.
"""
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List
import json, re

HERE=Path(__file__).resolve().parent
DEFAULT_POSITIVE={"READY","TARGET_SPECIFIC"}
DEFAULT_ALLOWED={"READY","TARGET_SPECIFIC","REWORK_REQUIRED","HOLD","EVIDENCE_INCOMPLETE","PROVENANCE_UNKNOWN","METHOD_UNSUITABLE"}
ALLOWED_TRIGGER_TYPES={"review","migration","escalation","stop","pivot"}
REQUIRED_RULE_IDS={f"R{i}" for i in range(1,13)}


def _text(v: Any) -> bool:
    return isinstance(v,str) and bool(v.strip())

def _err(code:str,rule:str,message:str)->Dict[str,str]:
    return {"code":code,"rule":rule,"message":message}

def _blank(v:Any)->bool:
    return v is None or v=="" or v==[] or v=={}

def _apply_change(root:Any,path:str,value:Any)->None:
    parts=path.split('.')
    cur=root
    for i,part in enumerate(parts):
        m=re.fullmatch(r'([^\[]+)\[([^\]]+)\]',part)
        last=i==len(parts)-1
        if m:
            key,selector=m.groups()
            arr=cur[key]
            item=next((x for x in arr if isinstance(x,dict) and x.get('object')==selector),None)
            if item is None: raise KeyError(path)
            if last:
                raise KeyError('selector must be followed by a field: '+path)
            cur=item
            continue
        if last:
            cur[part]=deepcopy(value)
        else:
            cur=cur[part]

def _resolve_fixture(ref:str,visible_rules:Dict[str,Any]):
    sources=visible_rules.get('fixture_sources') if isinstance(visible_rules,dict) else None
    if not _text(ref) or not isinstance(sources,dict) or ref not in sources:
        return None
    path=HERE/sources[ref]
    if not path.is_file(): return None
    data=json.loads(path.read_text(encoding='utf-8'))
    if isinstance(data,dict) and 'changed_fields' in data:
        base_id=data.get('base_fixture')
        base_rel=sources.get(base_id) if isinstance(sources,dict) else None
        if not base_rel: return None
        expected=json.loads((HERE/base_rel).read_text(encoding='utf-8'))
        for k,v in data.get('changed_fields',{}).items(): _apply_change(expected,k,v)
        expected['_mutation_id']=data.get('mutation_id')
        return expected
    return data

def _collect_mismatches(submitted:Any,expected:Any,path:str='')->List[str]:
    """Compare only evidence the learner actually supplies; missing evidence may remain typed non-success."""
    if _blank(submitted): return []
    if isinstance(submitted,dict):
        if not isinstance(expected,dict): return [path or '<root>']
        out=[]
        for k,v in submitted.items():
            if k not in expected: out.append(f'{path}.{k}' if path else k)
            else: out.extend(_collect_mismatches(v,expected[k],f'{path}.{k}' if path else k))
        return out
    if isinstance(submitted,list):
        return [] if submitted==expected else [path]
    return [] if submitted==expected else [path]


def _path_value(root:Any,path:str):
    cur=root
    for part in path.split('.'):
        if not isinstance(cur,dict) or part not in cur:
            return False,None
        cur=cur[part]
    return True,cur

def _positive_fixture_binding_gaps(submitted:Dict[str,Any],expected:Dict[str,Any],expected_ref:str)->List[str]:
    # P8-critical / positive-required evidence plus explicit target requirements.
    required_paths=[
        'target.target_use','target.target_constraint_scope','target.unit_system',
        'target.reference_scale','target.manifold_requirement','target.geometry_budget_policy',
        'geometry.object_transform_state','geometry.topology_intent','geometry.vertex_count',
        'geometry.triangle_count','geometry.normal_state','geometry.manifold_state',
        'geometry.origin_pivot_state','geometry.validation_view',
        'modifiers.modifier_stack','modifiers.destructive_step_log','modifiers.unreviewed_destructive_step',
        'naming_contract','provenance','known_limitations','interoperability_evidence'
    ]
    gaps=[]
    for path in required_paths:
        eok,ev=_path_value(expected,path)
        if not eok:
            continue
        sok,sv=_path_value(submitted,path)
        if (not sok) or _blank(sv) or sv!=ev:
            gaps.append(path)
    if submitted.get('evidence_ref')!=expected_ref:
        gaps.append('evidence_ref')
    return gaps

def validate_pack(pack:Dict[str,Any],visible_rules:Dict[str,Any]|None=None)->List[Dict[str,str]]:
    errors=[]
    visible_rules=visible_rules if isinstance(visible_rules,dict) else {}
    rule_ids={r.get('id') for r in visible_rules.get('rules',[]) if isinstance(r,dict)}
    missing=sorted(REQUIRED_RULE_IDS-rule_ids)
    if missing: errors.append(_err('E_VISIBLE_RULESET_INCOMPLETE','RULE_SOURCE','Visible CHECK_RULES.json is missing required rule IDs: '+', '.join(missing)))
    allowed=set(visible_rules.get('allowed_states') or DEFAULT_ALLOWED)
    positive_states=set(visible_rules.get('positive_states') or DEFAULT_POSITIVE)

    decision=pack.get('decision') if isinstance(pack.get('decision'),dict) else {}
    state=decision.get('final_state')
    if state not in allowed:
        errors.append(_err('E_DECISION_REQUIRED','R11','Choose one allowed final_state; the checker will not choose it.'))
        positive=False
    else: positive=state in positive_states

    # R12: visible fixture binding.
    fixture_ref=pack.get('fixture_ref')
    expected=_resolve_fixture(fixture_ref,visible_rules)
    if expected is None:
        errors.append(_err('E_FIXTURE_REF','R12','fixture_ref must name one shipped visible fixture or mutation from CHECK_RULES.json.'))
    else:
        expected_ref='fixture://'+fixture_ref
        if _text(pack.get('evidence_ref')) and pack.get('evidence_ref')!=expected_ref:
            errors.append(_err('E_FIXTURE_BINDING','R12',f'evidence_ref must bind to {expected_ref}.'))
        decisive=['asset_id','target','geometry','modifiers','naming_contract','provenance','known_limitations','interoperability_evidence']
        mismatches=[]
        for key in decisive:
            if key in pack and not _blank(pack.get(key)):
                mismatches.extend(_collect_mismatches(pack.get(key),expected.get(key),key))
        if mismatches:
            errors.append(_err('E_FIXTURE_BINDING','R12','Submitted evidence does not match the declared visible fixture at: '+', '.join(sorted(set(mismatches)))))
        if positive:
            gaps=_positive_fixture_binding_gaps(pack,expected,expected_ref)
            if gaps:
                errors.append(_err('E_FIXTURE_BINDING','R12','Positive state is not completely bound to fixture-known required evidence at: '+', '.join(sorted(set(gaps)))))

    target=pack.get('target') if isinstance(pack.get('target'),dict) else {}
    geom=pack.get('geometry') if isinstance(pack.get('geometry'),dict) else {}
    mods=pack.get('modifiers') if isinstance(pack.get('modifiers'),dict) else {}
    naming=pack.get('naming_contract') if isinstance(pack.get('naming_contract'),dict) else {}
    prov=pack.get('provenance') if isinstance(pack.get('provenance'),dict) else {}
    interop=pack.get('interoperability_evidence') if isinstance(pack.get('interoperability_evidence'),dict) else {}

    if positive:
        missing=[]
        for k in ('target_use','target_constraint_scope','unit_system','manifold_requirement'):
            if not _text(target.get(k)): missing.append(f'target.{k}')
        ref=target.get('reference_scale')
        if not isinstance(ref,dict) or not ref: missing.append('target.reference_scale')
        for k in ('topology_intent','normal_state','manifold_state','origin_pivot_state'):
            if not _text(geom.get(k)): missing.append(f'geometry.{k}')
        for k in ('vertex_count','triangle_count'):
            if not isinstance(geom.get(k),int): missing.append(f'geometry.{k}')
        if not isinstance(geom.get('object_transform_state'),list) or not geom.get('object_transform_state'): missing.append('geometry.object_transform_state')
        if not isinstance(geom.get('validation_view'),list) or not geom.get('validation_view'): missing.append('geometry.validation_view')
        if not isinstance(mods.get('modifier_stack'),list) or not mods.get('modifier_stack'): missing.append('modifiers.modifier_stack')
        if 'destructive_step_log' not in mods or not isinstance(mods.get('destructive_step_log'),list): missing.append('modifiers.destructive_step_log')
        for k in ('source_asset_ref','rights_provenance_ref','rights_status'):
            if not _text(prov.get(k)): missing.append(f'provenance.{k}')
        if 'known_limitations' not in pack or not isinstance(pack.get('known_limitations'),list): missing.append('known_limitations')
        if not _text(pack.get('evidence_ref')): missing.append('evidence_ref')
        if missing: errors.append(_err('E_MISSING_CRITICAL_POSITIVE','R1','Positive state is missing decisive evidence: '+'; '.join(missing)))

    transforms=geom.get('object_transform_state') if isinstance(geom.get('object_transform_state'),list) else []
    if positive and any(isinstance(x,dict) and x.get('scale_state')=='UNAPPLIED_NON_UNIFORM' for x in transforms):
        errors.append(_err('E_NONUNIFORM_SCALE','R2','Positive state conflicts with unapplied non-uniform scale in this bounded lab target.'))
    if positive and geom.get('normal_state')=='INCONSISTENT_FLIPPED':
        errors.append(_err('E_NORMAL_STATE','R3','Positive state conflicts with flipped/inconsistent normals.'))
    budget=target.get('geometry_budget_policy') if isinstance(target.get('geometry_budget_policy'),dict) else {}
    max_tri=budget.get('max_triangles'); tri=geom.get('triangle_count')
    if positive and isinstance(max_tri,int) and isinstance(tri,int) and tri>max_tri:
        errors.append(_err('E_TRIANGLE_BUDGET','R4',f'triangle_count {tri} exceeds declared max_triangles {max_tri}.'))
    if positive and target.get('manifold_requirement')=='REQUIRED' and geom.get('manifold_state')!='PASS_MANIFOLD_CHECK':
        errors.append(_err('E_MANIFOLD','R5','Required manifold target needs PASS_MANIFOLD_CHECK for a positive state.'))
    if positive and (not isinstance(mods.get('modifier_stack'),list) or not mods.get('modifier_stack') or mods.get('unreviewed_destructive_step') is True):
        errors.append(_err('E_HISTORY','R6','Positive state requires visible modifier/history evidence and no unreviewed destructive step.'))
    if positive:
        names=naming.get('decisive_object_names')
        if not _text(naming.get('convention_ref')) or not isinstance(names,list) or not any(_text(x) for x in names) or not _text(geom.get('origin_pivot_state')):
            errors.append(_err('E_NAMING','R7','Positive state requires naming contract, decisive object names, and origin/pivot evidence.'))
        if (not _text(interop.get('target_ref')) or interop.get('transform_binding_checked') is not True or interop.get('geometry_contract_checked') is not True or interop.get('export_only_proof') is not False or interop.get('beauty_render_only') is not False):
            errors.append(_err('E_INTEROP_EVIDENCE','R8','Positive state requires target-specific transform/geometry binding checks and cannot rely on export/beauty-render-only proof.'))
    scope=target.get('target_constraint_scope')
    if state=='READY' and scope!='STANDARD_DECLARED_TARGET': errors.append(_err('E_TARGET_SCOPE','R9','READY requires STANDARD_DECLARED_TARGET.'))
    if state=='TARGET_SPECIFIC' and scope!='CONSTRAINED_NAMED_TARGET_ONLY': errors.append(_err('E_TARGET_SCOPE','R9','TARGET_SPECIFIC requires CONSTRAINED_NAMED_TARGET_ONLY.'))

    triggers=decision.get('triggers') if isinstance(decision.get('triggers'),dict) else {}
    unknown=[k for k,v in triggers.items() if k not in ALLOWED_TRIGGER_TYPES and _text(v)]
    if unknown: errors.append(_err('E_DECISION_TRIGGER_TYPE','R10','Unknown trigger categories: '+', '.join(sorted(unknown))))
    valid=[k for k in ALLOWED_TRIGGER_TYPES if _text(triggers.get(k))]
    if state in allowed and not valid:
        errors.append(_err('E_DECISION_TRIGGERS','R10','Decision requires at least one applicable named future trigger condition; all five categories are not required.'))
    return errors
