from pathlib import Path
import json

STATE_VOCAB={'USE','PILOT','NOT_NOW','UNKNOWN','EVIDENCE_INCOMPLETE','OWNER_MISSING','REVIEW_REQUIRED','HOLD','PARTIAL','DISPUTED'}
POSITIVE={'USE','PILOT'}
AXES={'SEARCH_ENGINE','GRAPHQL'}
UNRESOLVED={None,'','UNKNOWN','UNMEASURED','UNRESOLVED','NOT_ASSESSED','INCOMPLETE','EVIDENCE_INCOMPLETE','CONTRADICTORY'}
SEARCH_REQ_PRESENT={'PRESENT','REQUIRED','TYPO_TOLERANT_RANKING_REQUIRED'}
SEARCH_REQ_ABSENT={'ABSENT','NOT_REQUIRED'}
SEARCH_POS={
 'technology':{'ELASTICSEARCH_OR_OPENSEARCH'},
 'layer':{'SEARCH_ANALYTICS'},
 'problem_class':{'INDEXED_RETRIEVAL'},
 'query_pattern':{'TYPO_TOLERANT_FULL_TEXT_WITH_FACETS'},
 'full_text_requirement':{'PRESENT'},
 'filter_aggregation_requirement':{'PRESENT'},
 'relevance_requirement':{'TYPO_TOLERANT_RANKING_REQUIRED'},
 'vector_requirement':{'ABSENT'},
 'indexing_pipeline':{'BOUNDED_INDEX_PIPELINE_DEFINED','CDC_OR_EVENT_PIPELINE_DEFINED','PILOT_INDEX_PIPELINE_DEFINED'},
 'source_of_truth':{'POSTGRESQL_AUTHORITATIVE','PRIMARY_DATABASE_AUTHORITATIVE','UPSTREAM_SERVICE_AUTHORITATIVE'},
 'freshness_lag_tolerance':{'UP_TO_60_SECONDS','UP_TO_5_SECONDS','BOUNDED_EVENTUAL_WITHIN_TOLERANCE'},
 'consistency_requirement':{'EVENTUAL_INDEX_OK_SOURCE_DB_AUTHORITATIVE','EVENTUAL_INDEX_BOUNDED','SEARCH_INDEX_NON_AUTHORITATIVE'},
 'authorization_boundary':{'SEARCH_FILTERS_MIRROR_SOURCE_POLICY','EXPLICIT_SEARCH_AUTH_FILTER_POLICY'},
 'simpler_alternative':{'POSTGRESQL_FULL_TEXT_AND_EXTENSIONS'}}
GQL_POS={
 'technology':{'GRAPHQL'},
 'layer':{'API_QUERY_SHAPING'},
 'problem_class':{'API_QUERY_SHAPING'},
 'client_query_variability':{'HIGH_MANY_CLIENT_TYPES','HIGH_NESTED_SHAPE_VARIABILITY'},
 'query_pattern':{'MANY_NESTED_REPRESENTATIONS','FREQUENT_OVER_UNDER_FETCH'},
 'schema_owner':{'API_PLATFORM_OWNER','PRODUCT_API_SCHEMA_OWNER'},
 'resolver_boundary':{'EXPLICIT_RESOLVER_DATA_BOUNDARY','SERVICE_OWNED_RESOLVER_BOUNDARY'},
 'authorization_boundary':{'FIELD_AND_OBJECT_POLICY_DEFINED','EXPLICIT_GRAPHQL_AUTHORIZATION_POLICY'},
 'n_plus_one_risk':{'BOUNDED_WITH_BATCHING_AND_QUERY_COST_LIMITS','EXPLICIT_COST_AND_BATCHING_POLICY'},
 'source_of_truth':{'UNDERLYING_SERVICES_AND_POSTGRESQL'},
 'consistency_requirement':{'RESOLVER_READS_FOLLOW_SOURCE_CONTRACTS'},
 'simpler_alternative':{'EXTEND_EXISTING_REST_ENDPOINTS'}}

def _err(errs,rule,msg): errs.append({'rule':rule,'message':msg})
def _bindings(root):
    b=json.loads((root/'fixtures/FIXTURE_BINDINGS.json').read_text(encoding='utf-8'))
    return {x['fixture_id']:json.loads((root/x['path']).read_text(encoding='utf-8')) for x in b['fixtures']}

def _search_trigger(e):
    decisive=[e.get('query_pattern'),e.get('full_text_requirement'),e.get('filter_aggregation_requirement'),e.get('relevance_requirement'),e.get('source_of_truth')]
    unresolved=any(v in UNRESOLVED for v in decisive)
    present=(e.get('query_pattern')!='EXACT_LOOKUP_ONLY' and any(e.get(k) in SEARCH_REQ_PRESENT for k in ['full_text_requirement','filter_aggregation_requirement','relevance_requirement','vector_requirement']))
    absent=(not unresolved and e.get('query_pattern')=='EXACT_LOOKUP_ONLY' and all(e.get(k) in SEARCH_REQ_ABSENT for k in ['full_text_requirement','filter_aggregation_requirement','relevance_requirement','vector_requirement']))
    return unresolved,present,absent

def _graphql_trigger(e):
    decisive=[e.get('query_pattern'),e.get('client_query_variability')]
    unresolved=any(v in UNRESOLVED for v in decisive)
    present=e.get('client_query_variability') in GQL_POS['client_query_variability'] and e.get('query_pattern') in GQL_POS['query_pattern']
    absent=(not unresolved and e.get('client_query_variability')=='LOW_FIXED_CLIENT_SHAPES' and e.get('query_pattern')=='MOSTLY_STABLE_REST_SHAPES')
    return unresolved,present,absent

def _authority_ok(axis,refs):
    if not isinstance(refs,list): return False
    s=set(refs)
    if axis=='SEARCH_ENGINE':
        return {'source://AWR-003/OPENSEARCH','source://AWR-003/ELASTIC','lesson://AWR-003#claim/AWR-003-K1','lesson://AWR-003#claim/AWR-003-K2','lesson://AWR-003#claim/AWR-003-K4'} <= s
    return {'source://AWR-003/GRAPHQL','lesson://AWR-003#claim/AWR-003-K3','lesson://AWR-003#claim/AWR-003-K4'} <= s

def _positive_ready(axis,e):
    vocab=SEARCH_POS if axis=='SEARCH_ENGINE' else GQL_POS
    return all(e.get(k) in allowed for k,allowed in vocab.items())

def _has_unresolved_boundary(axis,e):
    if axis=='SEARCH_ENGINE':
        keys=['query_pattern','full_text_requirement','filter_aggregation_requirement','relevance_requirement','indexing_pipeline','source_of_truth','freshness_lag_tolerance','consistency_requirement','authorization_boundary']
    else:
        keys=['query_pattern','client_query_variability','schema_owner','resolver_boundary','authorization_boundary','n_plus_one_risk']
    return any(e.get(k) in UNRESOLVED for k in keys)

def _decisive_keys(axis):
    if axis=='SEARCH_ENGINE':
        return ['query_pattern','full_text_requirement','filter_aggregation_requirement','relevance_requirement','vector_requirement','indexing_pipeline','source_of_truth','freshness_lag_tolerance','consistency_requirement','authorization_boundary']
    return ['query_pattern','client_query_variability','schema_owner','resolver_boundary','authorization_boundary','n_plus_one_risk','source_of_truth','consistency_requirement']

def _token_basis(axis,e,tokens):
    for k in _decisive_keys(axis):
        v=e.get(k)
        if isinstance(v,str):
            u=v.upper()
            if any(tok in u for tok in tokens): return True
    return False

def _partial_basis(axis,e):
    vals=[e.get(k) for k in _decisive_keys(axis)]
    return sum(v in UNRESOLVED for v in vals)>0 and sum(v not in UNRESOLVED for v in vals)>0

def _typed_state_basis(axis,st,e,unresolved,boundary_unresolved):
    if st=='REVIEW_REQUIRED': return _token_basis(axis,e,{'STALE','EXCEEDS_TOLERANCE','OBSERVED_','CONFLICT','CONTRADICT'})
    if st=='HOLD': return _token_basis(axis,e,{'HOLD','BLOCKED','WAITING','PAUSED'})
    if st=='PARTIAL': return _partial_basis(axis,e)
    if st=='DISPUTED': return _token_basis(axis,e,{'CONFLICT','CONTRADICT','DISPUTED'})
    return True

def validate_submission(submission,root=None):
    root=Path(root or Path(__file__).resolve().parent); errs=[]; fixtures=_bindings(root)
    fid=submission.get('fixture_id'); fixture=fixtures.get(fid)
    if fixture is None:
        _err(errs,'R3','Unknown fixture_id; choose a shipped fixture.')
        return {'ok':False,'errors':errs,'fixture_id':fid}
    records=submission.get('records')
    if not isinstance(records,list) or len(records)!=2:
        _err(errs,'R1','Exactly two decision records are required.')
        return {'ok':False,'errors':errs,'fixture_id':fid}
    axes=[r.get('decision_axis') for r in records]
    if set(axes)!=AXES or len(set(axes))!=2: _err(errs,'R1','Decision axes must be exactly SEARCH_ENGINE and GRAPHQL.')
    by={r.get('decision_axis'):r for r in records}
    for axis,r in by.items():
        if r.get('decision_state') not in STATE_VOCAB: _err(errs,'R2',f'{axis}: invalid decision_state.')
        if not isinstance(r.get('rationale'),str) or not r.get('rationale','').strip(): _err(errs,'R10',f'{axis}: rationale required.')
        if not r.get('review_due'): _err(errs,'R10',f'{axis}: review_due required.')
        if not r.get('stop_or_pivot_trigger'): _err(errs,'R10',f'{axis}: stop_or_pivot_trigger required.')
    mapping={'SEARCH_ENGINE':'search_engine','GRAPHQL':'graphql'}
    for axis,key in mapping.items():
        if axis in by and by[axis].get('evidence') != fixture[key]: _err(errs,'R3',f'{axis}: evidence does not exactly match selected fixture.')
    for axis,key,trig,rule in [('SEARCH_ENGINE','search_engine',_search_trigger,'R4'),('GRAPHQL','graphql',_graphql_trigger,'R5')]:
        if axis not in by: continue
        r=by[axis]; e=r.get('evidence') or {}; st=r.get('decision_state'); unresolved,present,measured_absent=trig(e)
        boundary_unresolved=_has_unresolved_boundary(axis,e)
        if st in POSITIVE:
            if unresolved or not present: _err(errs,rule,f'{axis}: positive state requires its own measured present trigger.')
            if not _positive_ready(axis,e): _err(errs,rule,f'{axis}: positive state requires explicit admissible boundary evidence.')
            if not isinstance(e.get('problem'),str) or not e.get('problem','').strip(): _err(errs,rule,f'{axis}: positive state requires a visible layer-specific problem.')
            if not isinstance(e.get('simpler_alternative'),str) or not e.get('simpler_alternative','').strip(): _err(errs,rule,f'{axis}: positive state requires a visible simpler-alternative comparison.')
            if not e.get('operational_owner') or (axis=='GRAPHQL' and e.get('schema_owner') in UNRESOLVED): _err(errs,'R6',f'{axis}: accountable owner required for positive state.')
            for k in ['cost_risk','failure_surface','evidence_ref']:
                if e.get(k) in UNRESOLVED: _err(errs,'R9',f'{axis}: positive state requires {k}.')
            if not _authority_ok(axis,e.get('authority_refs')): _err(errs,'R11',f'{axis}: authority_refs do not satisfy required FACT/DESIGN trace.')
        if (unresolved or boundary_unresolved) and st in POSITIVE|{'NOT_NOW'}: _err(errs,'R7',f'{axis}: unresolved decisive evidence requires typed uncertainty, not success/NOT_NOW.')
        if st=='NOT_NOW':
            if not measured_absent: _err(errs,'R8',f'{axis}: NOT_NOW requires measured trigger absence.')
            if not e.get('simpler_alternative'): _err(errs,'R8',f'{axis}: NOT_NOW requires a visible simpler alternative.')
        if st=='OWNER_MISSING' and e.get('operational_owner') and not (axis=='GRAPHQL' and e.get('schema_owner') in UNRESOLVED): _err(errs,'R12',f'{axis}: OWNER_MISSING contradicts visible owner evidence.')
        if st in {'UNKNOWN','EVIDENCE_INCOMPLETE'} and not (unresolved or boundary_unresolved): _err(errs,'R12',f'{axis}: {st} requires unresolved decisive evidence.')
        if st in {'REVIEW_REQUIRED','HOLD','PARTIAL','DISPUTED'} and not _typed_state_basis(axis,st,e,unresolved,boundary_unresolved): _err(errs,'R12',f'{axis}: {st} requires its visible typed-state evidence basis.')
        if not _authority_ok(axis,e.get('authority_refs')): _err(errs,'R11',f'{axis}: authority_refs required for reproducible FACT/DESIGN trace.')
    return {'ok':not errs,'errors':errs,'fixture_id':fid,'rules_checked':[f'R{i}' for i in range(1,13)]}
