#!/usr/bin/env python3
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","PARTIAL","DISPUTED"}
BAD_SIGNAL={"UNSTABLE","UNKNOWN","MISSING","","PARTIAL"}

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 _num(v:Any)->bool: return isinstance(v,(int,float)) and not isinstance(v,bool)
def _err(code,rule,message): return {"code":code,"rule":rule,"message":message}

def _deep_merge(base,changes):
    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 _config(rules,errors):
    if not isinstance(rules,dict) or rules.get('schema')!='MAKNOW_AWR007_LAB_VISIBLE_RULES_v1.0':
        errors.append(_err('E_RULESET','R11/R12/R13','Published CHECK_RULES.json is missing or has an unexpected schema.'))
        return DEFAULT_POSITIVE,DEFAULT_ALLOWED,{'governance_trigger_types':['REVIEW','MIGRATION','ESCALATION']}
    ids={x.get('id') for x in rules.get('rules',[]) if isinstance(x,dict)}
    if not {f'R{i}' for i in range(1,15)}.issubset(ids): errors.append(_err('E_RULESET','R1-R14','Published rule IDs R1-R14 must all be present.'))
    return set(rules.get('positive_states') or DEFAULT_POSITIVE),set(rules.get('allowed_states') or DEFAULT_ALLOWED),rules.get('decision_contract') or {}

def _decision(axis,prefix,errors,positive,allowed,contract):
    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','R11/R12','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','R11','Governance triggers need an allowed type and non-empty condition.'))
        if not valid: missing.append('governance_triggers')
    if missing: errors.append(_err(f'E_{prefix}_DECISION_META','R11','Each axis decision requires: '+', '.join(sorted(set(missing)))))
    return state,state in positive

def _bound_fixture(gate,rules,lab_root,errors):
    if lab_root is None: return None
    lab_root=Path(lab_root)
    rel=rules.get('fixture_binding_manifest') if isinstance(rules,dict) else None
    if not _text(rel): errors.append(_err('E_RULESET','R13','Fixture binding manifest is not declared.')); return None
    try: manifest=json.loads((lab_root/rel).read_text(encoding='utf-8'))
    except Exception: errors.append(_err('E_FIXTURE_REF','R13','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','R13','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')); mut=json.loads((lab_root/spec['path']).read_text(encoding='utf-8'))
            axis='multi_region' if mut.get('axis')=='MULTI_REGION' else 'ad_automation'; fixture[axis]=_deep_merge(fixture[axis],mut.get('changes',{})); fixture['fixture_id']=mut.get('mutation_id')
        else: raise ValueError('kind')
    except Exception: errors.append(_err('E_FIXTURE_REF','R13','Resolved fixture files are invalid or unavailable.')); return None
    return fixture

def _strip_decision(axis): return {k:v for k,v in axis.items() if k!='decision'}
def _check_binding(gate,fixture,errors):
    if fixture is None: return
    mismatches=[]
    if gate.get('artifact_id')!=fixture.get('artifact_id'): mismatches.append('artifact_id')
    for axis in ('multi_region','ad_automation'):
        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','R13','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,contract=_config(visible_rules,errors)
    fixture=_bound_fixture(gate,visible_rules,lab_root,errors) if lab_root is not None else None; _check_binding(gate,fixture,errors)
    mr=gate.get('multi_region') if isinstance(gate.get('multi_region'),dict) else {}; ad=gate.get('ad_automation') if isinstance(gate.get('ad_automation'),dict) else {}
    mr_state,mr_positive=_decision(mr,'MR',errors,positive,allowed,contract); ad_state,ad_positive=_decision(ad,'AD',errors,positive,allowed,contract)
    if mr_positive:
        required=('problem','measured_trigger','availability_target','data_residency_requirement','data_replication_model','consistency_requirement','failover_owner','failure_guardrail','simpler_alternative','review_due')
        missing=[k for k in required if not _text(mr.get(k))]
        if not isinstance(mr.get('region_count'),int) or mr.get('region_count',0)<=0: missing.append('region_count')
        if not _nonempty_list(mr.get('user_geography')): missing.append('user_geography')
        if not _num(mr.get('latency_requirement_ms')) or mr.get('latency_requirement_ms')<=0: missing.append('latency_requirement_ms')
        if not _nonempty_list(mr.get('evidence_refs')): missing.append('evidence_refs')
        if not _text(mr.get('operational_ownership_cost')): errors.append(_err('E_MR_OPERATIONAL_COST','R1','Positive Multi-region state requires an explicit operational/ownership-cost class.'))
        if missing: errors.append(_err('E_MR_REQUIRED','R1','Positive Multi-region state is missing: '+', '.join(sorted(set(missing)))))
        trigger=str(mr.get('measured_trigger','')).upper()
        if trigger=='USER_COUNT_ONLY' or trigger.startswith('NO_NEW_'):
            errors.append(_err('E_MR_TRIGGER','R2','A positive Multi-region state requires a measured latency, availability, recovery, residency, or continuity trigger; growth or an explicit no-new-requirement state is insufficient.'))
        if not _text(mr.get('failover_owner')): errors.append(_err('E_MR_OWNER','R3','Positive Multi-region state requires an explicit failover owner.'))
        ro=mr.get('recovery_objective') if isinstance(mr.get('recovery_objective'),dict) else {}
        if not _num(ro.get('rto_minutes')) or not _num(ro.get('rpo_minutes')) or ro.get('rto_minutes')<0 or ro.get('rpo_minutes')<0: errors.append(_err('E_MR_RECOVERY','R3','Positive Multi-region state requires numeric RTO/RPO evidence.'))
        if mr.get('data_residency_requirement')=='ONE_MARKET_REGIONAL_RESIDENCY' and mr.get('data_replication_model')=='GLOBAL_ACTIVE_ACTIVE_ALL_DATA': errors.append(_err('E_MR_RESIDENCY_SCOPE','R4','One-market residency does not justify global active-active replication of all data in this fixture.'))
    if ad_positive:
        required=('problem','measured_trigger','campaign_goal','bidding_strategy','conversion_signal','automation_permission','approval_state','reconciliation_owner','simpler_alternative','review_due')
        missing=[k for k in required if not _text(ad.get(k))]
        if not _num(ad.get('budget_limit')) or ad.get('budget_limit')<=0: errors.append(_err('E_AD_BUDGET','R7','Positive Ad Automation state requires a positive budget limit.'))
        if not _nonempty_list(ad.get('targeting_scope')): errors.append(_err('E_AD_TARGETING','R7','Positive Ad Automation state requires explicit bounded targeting.'))
        if not _nonempty_list(ad.get('evidence_refs')): missing.append('evidence_refs')
        if not _text(ad.get('operational_ownership_cost')): errors.append(_err('E_AD_OPERATIONAL_COST','R5','Positive Ad Automation state requires an explicit operational/ownership-cost class.'))
        if ad_state=='USE':
            allowed_progress=set(contract.get('ad_use_progression_evidence_allowed') or [])
            if not _text(ad.get('progression_evidence')) or ad.get('progression_evidence') not in allowed_progress:
                errors.append(_err('E_AD_USE_PROGRESSION','R14','Ad Automation USE requires shipped-fixture evidence that simulation/recommendation progression was completed before advancing beyond PILOT.'))
        if missing: errors.append(_err('E_AD_REQUIRED','R5','Positive Ad Automation state is missing: '+', '.join(sorted(set(missing)))))
        if str(ad.get('conversion_signal','')).upper() in BAD_SIGNAL or 'UNSTABLE' in str(ad.get('conversion_signal','')).upper(): errors.append(_err('E_AD_SIGNAL','R6','Positive Ad Automation state requires a stable conversion signal.'))
        if not isinstance(ad.get('approval_required'),bool): errors.append(_err('E_AD_APPROVAL_REQUIRED_TYPE','R8','approval_required must be an explicit boolean for a positive state.'))
        elif ad.get('approval_required'):
            if ad.get('approval_state')!='CONFIGURED' or not _text(ad.get('approval_owner')): errors.append(_err('E_AD_APPROVAL','R8','Required approval must be CONFIGURED with an explicit approval owner.'))
        elif ad.get('approval_state')!='NOT_REQUIRED': errors.append(_err('E_AD_APPROVAL','R8','When approval_required=false, approval_state must be NOT_REQUIRED.'))
        sg=ad.get('spend_guardrail') if isinstance(ad.get('spend_guardrail'),dict) else {}; sl=ad.get('stop_loss') if isinstance(ad.get('stop_loss'),dict) else {}
        if not _num(sg.get('daily_limit')) or sg.get('daily_limit')<=0 or not _num(sg.get('change_limit_percent')) or sg.get('change_limit_percent')<=0 or not _text(sl.get('metric')) or not _num(sl.get('threshold')) or not _num(sl.get('window_hours')) or sl.get('window_hours')<=0 or not _text(ad.get('reconciliation_owner')):
            errors.append(_err('E_AD_GUARDRAIL','R9','Positive Ad Automation state requires spend limits, stop-loss, and reconciliation ownership.'))
        if ad.get('automation_permission')=='UNRESTRICTED_AUTONOMOUS_MUTATION': errors.append(_err('E_AD_UNBOUNDED','R10','Unrestricted autonomous mutation cannot receive USE/PILOT in this offline awareness lab.'))
    return errors
