#!/usr/bin/env python3
"""Fair reviewer for v4 programmable ICT/SMC candidates.

Fair rules:
- Candidate valid context + entry touched => journal-preview trade.
- TP first => Win, realized r = +planned_rr.
- SL first => Loss, realized r = -1.
- Same-bar or unresolved => needs_more_context.
- Entry not touched => shadow observation only, not fair journal.
No production journal writes.
"""
from __future__ import annotations
import json, argparse, statistics
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
IN=ROOT/'candidates/candidates_v4_programmable_ict.json'
REV=ROOT/'reviews/reviews_v4_programmable_ict.json'
FAIR=ROOT/'reviews/fair_journal_preview_v4_programmable_ict.json'
SHADOW=ROOT/'reviews/shadow_v4_programmable_ict.json'
SUMMARY=ROOT/'reports/fair_v4_stats_summary.json'
def parse_dt(v):
    dt=datetime.fromisoformat(str(v).replace('Z','+00:00'))
    if dt.tzinfo is None: dt=dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc)
def iso(dt): return dt.replace(microsecond=0).isoformat().replace('+00:00','Z')
def load_m5():
    obj=json.loads((ROOT/'data/ohlcv_M5.json').read_text()); rows=obj.get('bars',obj) if isinstance(obj,dict) else obj; out=[]
    for r in rows:
        t=r.get('timestamp',r.get('time',r.get('date'))) if isinstance(r,dict) else r[0]
        vals=r if isinstance(r,dict) else {'open':r[1],'high':r[2],'low':r[3],'close':r[4]}
        out.append((parse_dt(t),{k:float(vals[k]) for k in ['open','high','low','close']}))
    return sorted(out,key=lambda x:x[0])
def window(bars,dt,after=288):
    import bisect
    times=[x[0] for x in bars]; i=bisect.bisect_right(times,dt)-1
    return bars[i+1:min(len(bars),i+1+after)]
def hit(direction,entry,stop,target,post):
    for dt,b in post:
        if direction=='Long': sl=b['low']<=stop; tp=b['high']>=target
        else: sl=b['high']>=stop; tp=b['low']<=target
        if sl and tp: return 'ambiguous_same_bar',dt
        if sl: return 'sl_first',dt
        if tp: return 'tp_first',dt
    return 'unresolved',None
def derive(c):
    lv=c.get('levels',{}); direction=c['direction']
    entry=float(lv['entry']); planned=float(lv.get('planned_rr',2.0))
    if 'stop' in lv: stop=float(lv['stop'])
    elif direction=='Long': stop=float(lv.get('sweep_low', lv.get('impulse_low', lv.get('level', entry-1))))
    else: stop=float(lv.get('sweep_high', lv.get('impulse_high', lv.get('level', entry+1))))
    risk=entry-stop if direction=='Long' else stop-entry
    if risk<=0: raise ValueError('invalid risk')
    target=entry+risk*planned if direction=='Long' else entry-risk*planned
    return round(entry,3),round(stop,3),round(target,3),planned
def validate(c):
    issues=[]; strat=c['strategy']; lv=c.get('levels',{}); flags=set(c.get('scanner_flags',[])); htf=c.get('htf_narrative',{})
    if float(lv.get('planned_rr',0) or 0)<2: issues.append('planned_rr_below_2')
    if strat=='Aryy HTF Narrative Fib 50/62':
        fl=lv.get('fib_entry_level')
        if fl=='50%' and not lv.get('pd_arrays'): issues.append('fib50_missing_pd_array')
        if fl=='62%' and not (lv.get('pd_arrays') or lv.get('sr_at_level')): issues.append('fib62_missing_fvg_or_sr')
    if strat in ('ICT Sweep + MSS + FVG','Breaker Continuation') and not lv.get('pd_arrays'): issues.append('missing_pd_array')
    if 'Raid Reversal' in strat and not htf.get('external_refs'): issues.append('raid_without_external_ref')
    if strat=='BPR Rebalance' and not ('bpr_low' in lv and 'bpr_high' in lv): issues.append('missing_bpr')
    if strat=='IFVG Continuation' and 'ifvg' not in flags: issues.append('missing_ifvg_flag')
    return issues
def review(c,m5):
    try: entry,stop,target,planned=derive(c)
    except Exception as e:
        return {'candidate_id':c['id'],'timestamp':c['timestamp'],'strategy':c['strategy'],'decision':'rejected','reason':str(e),'candidate':c}
    issues=validate(c)
    if issues:
        return {'candidate_id':c['id'],'timestamp':c['timestamp'],'strategy':c['strategy'],'direction':c['direction'],'decision':'rejected','reason':'validation failed: '+','.join(issues),'issues':issues,'entry':entry,'stop':stop,'target':target,'planned_rr':planned,'candidate':c}
    post=window(m5,parse_dt(c['timestamp']))
    touch=False; touch_dt=None
    for t,b in post:
        if b['low']<=entry<=b['high']:
            touch=True; touch_dt=t; break
    if not touch:
        return {'candidate_id':c['id'],'timestamp':c['timestamp'],'strategy':c['strategy'],'direction':c['direction'],'decision':'shadow','reason':'entry not touched in forward M5 window','entry':entry,'stop':stop,'target':target,'planned_rr':planned,'r':None,'candidate':c}
    post_after=[x for x in post if x[0]>=touch_dt]
    outcome,out_dt=hit(c['direction'],entry,stop,target,post_after)
    if outcome=='tp_first': dec='confirmed'; realized=planned; out='Win'
    elif outcome=='sl_first': dec='confirmed'; realized=-1.0; out='Loss'
    else: dec='needs_more_context'; realized=None; out='Review'
    return {'candidate_id':c['id'],'timestamp':c['timestamp'],'strategy':c['strategy'],'direction':c['direction'],'session':c['session'],'decision':dec,'outcome':out,'reason':f"entry={entry}, planned_rr={planned}, touch={iso(touch_dt)}, outcome={outcome}",'entry':entry,'stop':stop,'target':target,'planned_rr':planned,'r':realized,'entry_touch_time':iso(touch_dt),'outcome_time':iso(out_dt) if out_dt else None,'htf_context':c.get('htf_narrative',{}).get('note','HTF checked'),'m15_m5_execution':c.get('notes'),'candidate':c}
def canonical(r,idx):
    dt=parse_dt(r['timestamp']); cand=r.get('candidate',{})
    fib=cand.get('levels',{}).get('fib_entry_level','n/a'); source=f"{r['candidate_id']}|entry={r.get('entry')}|fib={fib}"
    return {'id':f"fair_v4_{dt.strftime('%Y%m%dT%H%M%SZ')}_{r.get('direction','na').lower()}_{idx:05d}",'date':dt.date().isoformat(),'timestamp':iso(dt),'symbol':'XAUUSD','strategy':r['strategy'],'direction':r.get('direction'),'session':r.get('session') or cand.get('session'),'narrative':'V4 fair review: programmable ICT/SMC, losses included.','htf_context':r.get('htf_context'),'ltf_trigger':r.get('m15_m5_execution'),'entry':r.get('entry'),'stop':r.get('stop'),'target':r.get('target'),'planned_rr':r.get('planned_rr'),'fib_entry_level':fib,'fib':'50% PD-array / 62% FVG-or-SR' if r['strategy']=='Aryy HTF Narrative Fib 50/62' else 'strategy-specific','pd':r['strategy'],'outcome':r['outcome'],'r':r['r'],'confidence':'V4 fair deterministic review','notes':r.get('reason'),'source_candidate_id':source,'source_review':'hybrid/reviews/reviews_v4_programmable_ict.json','is_confirmed_trade':True}
def shadow(r):
    dt=parse_dt(r['timestamp'])
    return {'id':'shadow_'+r['candidate_id'],'date':dt.date().isoformat(),'timestamp':iso(dt),'symbol':'XAUUSD','type':'V4 shadow observation','strategy':r['strategy'],'direction':r.get('direction'),'entry':r.get('entry'),'stop':r.get('stop'),'target':r.get('target'),'planned_rr':r.get('planned_rr'),'r':r.get('r'),'note':r.get('reason'),'source_candidate_id':r['candidate_id'],'source_result_file':'hybrid/reviews/reviews_v4_programmable_ict.json','is_confirmed_trade':False}
def maxdd(trades):
    eq=10000; peak=eq; md=0; pct=0; when=None
    for t in sorted(trades,key=lambda x:x['timestamp']):
        eq+=float(t['r'])*100; peak=max(peak,eq); d=peak-eq; p=d/peak*100 if peak else 0
        if p>pct: pct=p; md=d; when=t['timestamp']
    return round(md/100,2),round(pct,2),when,round(eq,2)
def stats(arr):
    c=Counter(t['outcome'] for t in arr); rs=[float(t['r']) for t in arr]; ddr,ddp,when,eq=maxdd(arr)
    return {'trades':len(arr),'wins':c.get('Win',0),'losses':c.get('Loss',0),'winrate':round(c.get('Win',0)/len(arr)*100,2) if arr else 0,'total_R':round(sum(rs),2) if rs else 0,'avg_R':round(statistics.mean(rs),3) if rs else 0,'maxDD_R':ddr,'maxDD_pct_peak':ddp,'maxDD_when':when,'final_equity_100risk':eq}
def main():
    ap=argparse.ArgumentParser(); ap.add_argument('--input',type=Path,default=IN); args=ap.parse_args()
    cands=json.loads(args.input.read_text())['candidates']; m5=load_m5(); reviews=[review(c,m5) for c in cands]
    fair=[canonical(r,i+1) for i,r in enumerate([x for x in reviews if x.get('decision')=='confirmed'])]
    shadows=[shadow(r) for r in reviews if r.get('decision')=='shadow']
    ts=iso(datetime.now(timezone.utc)); counts=Counter(r['decision'] for r in reviews)
    REV.write_text(json.dumps({'metadata':{'purpose':'v4_programmable_ict_reviews','generated_at':ts,'review_count':len(reviews),'breakdown':dict(counts),'isFinalBacktest':False},'reviews':reviews},indent=2,ensure_ascii=False)+'\n')
    FAIR.write_text(json.dumps({'metadata':{'purpose':'fair_v4_journal_preview','generated_at':ts,'trade_count':len(fair),'isFinalBacktest':False},'trades':fair},indent=2,ensure_ascii=False)+'\n')
    SHADOW.write_text(json.dumps({'metadata':{'purpose':'v4_shadow_preview','generated_at':ts,'count':len(shadows),'isFinalBacktest':False},'shadowTrades':shadows},indent=2,ensure_ascii=False)+'\n')
    by=defaultdict(list)
    for t in fair: by[t['strategy']].append(t)
    summary={'generated_at':ts,'review_count':len(reviews),'breakdown':dict(counts),'overall':stats(fair),'strategy_stats':{k:stats(v) for k,v in sorted(by.items())},'shadow_count':len(shadows)}
    SUMMARY.write_text(json.dumps(summary,indent=2,ensure_ascii=False)+'\n')
    print(json.dumps(summary,indent=2))
if __name__=='__main__': main()
