from __future__ import annotations
import math
REQUIRED=['query_id', 'learner_intent', 'candidate_set', 'relevance_score', 'filter_state', 'rank_position', 'facet_dimension', 'facet_value', 'judgment_set', 'relevance_grade', 'evaluation_metric', 'metric_result', 'zero_result_state', 'fallback_route', 'taxonomy_version', 'review_owner']
UNKNOWN={'UNKNOWN','STALE','INCONCLUSIVE','UNVERIFIED','MISSING',''}

def empty(v):
    return v is None or (isinstance(v,str) and v.strip().upper() in UNKNOWN)

def ndcg(grades):
    if not grades: return 0.0
    dcg=sum((2**g-1)/math.log2(i+2) for i,g in enumerate(grades))
    ideal=sorted(grades,reverse=True)
    idcg=sum((2**g-1)/math.log2(i+2) for i,g in enumerate(ideal))
    return 0.0 if idcg==0 else dcg/idcg

def precision(grades,k,threshold=1):
    vals=grades[:k]
    return 0.0 if not vals else sum(1 for g in vals if g>=threshold)/len(vals)

def reciprocal_rank(grades,threshold=1):
    for i,g in enumerate(grades,1):
        if g>=threshold:return 1.0/i
    return 0.0

def evaluate(x):
    missing=[k for k in REQUIRED if k not in x]
    if missing:return {'state':'HOLD_INSUFFICIENT_EVIDENCE','reason':'MISSING_FIELDS','missing':missing}
    if x.get('baseline_oracle_reuse'):return {'state':'HOLD_BASELINE_ORACLE_REUSE','reason':'BASELINE_LABEL_REUSE_FORBIDDEN'}
    if empty(x.get('query_id')) or empty(x.get('learner_intent')):return {'state':'HOLD_QUERY_INTENT_UNKNOWN','reason':'QUERY_ID_AND_INTENT_REQUIRED'}
    owner=str(x.get('review_owner','')).strip().lower()
    if owner in {'','none','unassigned','missing','unknown'}:return {'state':'HOLD_REVIEW_OWNER','reason':'REVIEW_OWNER_REQUIRED'}
    if empty(x.get('taxonomy_version')):return {'state':'HOLD_TAXONOMY_VERSION_UNKNOWN','reason':'FACET_TAXONOMY_VERSION_REQUIRED'}
    if x.get('facet_mapping_valid') is False:return {'state':'HOLD_FACET_TAXONOMY_DRIFT','reason':'FACET_VALUE_NO_LONGER_MAPS_IN_DECLARED_TAXONOMY'}
    if x.get('query_filter_context_collapsed'):return {'state':'HOLD_QUERY_FILTER_CONTEXT_COLLAPSED','reason':'SCORED_QUERY_MOVED_TO_BINARY_FILTER_REQUIRES_RECOMPUTATION'}
    c=x.get('candidate_set')
    if not isinstance(c,list):return {'state':'HOLD_CANDIDATE_SET_UNKNOWN','reason':'CANDIDATE_SET_MUST_BE_VISIBLE_LIST'}
    if len(c)==0:
        if str(x.get('zero_result_state','')).upper()!='ZERO_RESULTS' or empty(x.get('fallback_route')) or str(x.get('fallback_route')).upper()=='NONE':
            return {'state':'HOLD_ZERO_RESULT_FALLBACK_MISSING','reason':'ZERO_RESULTS_REQUIRE_EXPLICIT_FALLBACK_ROUTE'}
        return {'state':'ZERO_RESULT_FALLBACK_REVIEW_REQUIRED','reason':'ZERO_RESULT_WITH_EXPLICIT_FALLBACK'}
    scores=x.get('relevance_score')
    if not isinstance(scores,dict) or any(empty(v) for v in scores.values()):return {'state':'HOLD_RELEVANCE_SCORE_UNKNOWN','reason':'VISIBLE_SCORE_EVIDENCE_REQUIRED_FOR_RANKED_CANDIDATES'}
    fs=x.get('filter_state')
    if not isinstance(fs,dict) or not isinstance(fs.get('included'),list) or not isinstance(fs.get('excluded'),list):return {'state':'HOLD_FILTER_STATE_UNKNOWN','reason':'FILTER_INCLUSION_EXCLUSION_REQUIRED'}
    included=fs['included']; ranks=x.get('rank_position')
    if not isinstance(ranks,dict) or set(ranks)!=set(included):return {'state':'HOLD_FILTER_RANK_INCONSISTENCY','reason':'RANKED_SET_MUST_EQUAL_FILTER_INCLUDED_SET'}
    try:
        rv=sorted(int(v) for v in ranks.values())
    except Exception:return {'state':'HOLD_RANK_POSITION_INVALID','reason':'RANK_POSITIONS_MUST_BE_INTEGERS'}
    if rv!=list(range(1,len(rv)+1)):return {'state':'HOLD_RANK_POSITION_INVALID','reason':'RANK_POSITIONS_MUST_BE_UNIQUE_CONTIGUOUS'}
    js=x.get('judgment_set')
    if empty(js) or not isinstance(x.get('relevance_grade'),dict):return {'state':'HOLD_RELEVANCE_JUDGMENTS_MISSING','reason':'EXPLICIT_INTENT_BOUND_JUDGMENTS_REQUIRED'}
    grades=x['relevance_grade']
    if set(grades)!=set(included) or any(empty(v) for v in grades.values()):return {'state':'HOLD_RELEVANCE_GRADE_UNKNOWN','reason':'EVERY_RANKED_CANDIDATE_REQUIRES_VISIBLE_GRADE'}
    if x.get('intent_changed') and not x.get('judgments_rebuilt'):return {'state':'HOLD_JUDGMENTS_NOT_REBUILT','reason':'CHANGED_INTENT_REQUIRES_NEW_JUDGMENTS'}
    metric=str(x.get('evaluation_metric','')).upper(); ordered=sorted(included,key=lambda d:ranks[d]); g=[float(grades[d]) for d in ordered]
    if metric.startswith('NDCG@'):
        try:k=int(metric.split('@',1)[1]); computed=ndcg(g[:k])
        except Exception:return {'state':'HOLD_EVALUATION_METRIC_UNKNOWN','reason':'INVALID_NDCG_CONFIGURATION'}
    elif metric.startswith('PRECISION@'):
        try:k=int(metric.split('@',1)[1]); computed=precision(g,k,float(x.get('relevant_grade_threshold',1)))
        except Exception:return {'state':'HOLD_EVALUATION_METRIC_UNKNOWN','reason':'INVALID_PRECISION_CONFIGURATION'}
    elif metric in {'MRR','MEAN_RECIPROCAL_RANK'}:
        computed=reciprocal_rank(g,float(x.get('relevant_grade_threshold',1)))
    else:return {'state':'HOLD_EVALUATION_METRIC_UNKNOWN','reason':'SUPPORTED_METRIC_REQUIRED'}
    try: recorded=float(x.get('metric_result'))
    except Exception:return {'state':'HOLD_METRIC_RESULT_UNKNOWN','reason':'NUMERIC_METRIC_RESULT_REQUIRED'}
    if abs(recorded-computed)>0.001:return {'state':'HOLD_METRIC_RESULT_MISMATCH','reason':'RECORDED_METRIC_DOES_NOT_MATCH_VISIBLE_RANKS_AND_GRADES','computed':round(computed,4)}
    threshold=x.get('metric_threshold')
    if threshold is not None:
        try: threshold=float(threshold)
        except Exception:return {'state':'HOLD_METRIC_RESULT_UNKNOWN','reason':'METRIC_THRESHOLD_INVALID'}
        if computed+1e-12 < threshold:return {'state':'RANK_TUNING_REVIEW_REQUIRED','reason':'METRIC_BELOW_LOCAL_DESIGN_THRESHOLD','computed':round(computed,4),'threshold':threshold}
    return {'state':'DISCOVERY_EVIDENCE_REVIEW_READY','reason':'INTENT_SCORE_FILTER_FACET_JUDGMENT_AND_METRIC_EVIDENCE_RECONCILED','computed':round(computed,4)}
