#!/usr/bin/env python3
"""V3 fair reviewer for HTF/PD-array aware candidates.

Produces research review outputs only by default. Does not overwrite production
journal/backtest-data. Fair rules:
- planned_rr is potential RR from setup.
- if entry is touched, valid setup goes to confirmed/shadow according to outcome.
- Win => realized r +planned_rr; Loss => realized r -1.
- ambiguous/unresolved => needs_more_context.
"""
from __future__ import annotations
import argparse, json, statistics
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT=Path(__file__).resolve().parents[1]
IN=ROOT/'candidates/candidates_v3_htf_pdarray.json'
REV=ROOT/'reviews/reviews_v3_htf_pdarray.json'
CONF=ROOT/'reviews/confirmed_v3_htf_pdarray.json'
SHADOW=ROOT/'reviews/shadow_v3_htf_pdarray.json'
SUMMARY=ROOT/'reports/v3_htf_pdarray_review_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_bars(tf):
    obj=json.loads((ROOT/'data'/f'ohlcv_{tf}.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 session(dt):
    h=dt.hour
    if 0<=h<7: return 'Asia'
    if 7<=h<10: return 'London'
    if 12<=h<17: return 'NY AM'
    if 15<=h<17: return 'London Close'
    if 17<=h<21: return 'NY PM'
    return 'Other'
def window(bars,dt,before=60,after=240):
    import bisect
    times=[x[0] for x in bars]; i=bisect.bisect_right(times,dt)-1
    return bars[max(0,i-before+1):i+1], 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_levels(c):
    lv=c.get('levels',{}); direction=c['direction']; planned=float(lv.get('planned_rr',2.0)); entry=float(lv.get('entry'))
    # SL from explicit stop or Fib/strategy level.
    if 'stop' in lv: stop=float(lv['stop'])
    elif direction=='Long': stop=float(lv.get('sweep_low', lv.get('impulse_low')))
    else: stop=float(lv.get('sweep_high', lv.get('impulse_high')))
    risk=entry-stop if direction=='Long' else stop-entry
    if risk<=0: raise ValueError('invalid risk geometry')
    target=entry+risk*planned if direction=='Long' else entry-risk*planned
    return round(entry,3), round(stop,3), round(target,3), planned
def valid_context(c):
    strat=c['strategy']; flags=set(c.get('scanner_flags',[])); lv=c.get('levels',{}); htf=c.get('htf_narrative',{})
    issues=[]
    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_without_strong_pd_array')
        if 'htf_narrative' not in flags: issues.append('missing_htf_narrative')
    if strat=='ICT Sweep + MSS + FVG':
        if not lv.get('pd_arrays'): issues.append('missing_fvg_pd_array')
        if not any('sweep' in f for f in flags): issues.append('missing_liquidity_sweep')
    if strat=='Breaker Continuation':
        if not lv.get('pd_arrays'): issues.append('missing_breaker_pd_array')
        if not any('break' in f for f in flags): issues.append('missing_break_structure')
    if strat=='Liquidity Sweep Reversal':
        if 'external_level_context' not in flags: issues.append('missing_external_level_context')
    if htf.get('bias')=='unknown': issues.append('unknown_htf_bias')
    return issues
def review(c,m5):
    issues=valid_context(c)
    try: entry,stop,target,planned=derive_levels(c)
    except Exception as e:
        return {'candidate_id':c['id'],'timestamp':c['timestamp'],'strategy':c['strategy'],'decision':'rejected','reason':f'level error: {e}','issues':['level_error'],'candidate':c}
    if planned<2: issues.append('planned_rr_below_2')
    if issues:
        return {'candidate_id':c['id'],'timestamp':c['timestamp'],'strategy':c['strategy'],'direction':c['direction'],'decision':'rejected','reason':'context validation failed: '+','.join(issues),'issues':issues,'entry':entry,'stop':stop,'target':target,'planned_rr':planned,'candidate':c}
    dt=parse_dt(c['timestamp']); _,post=window(m5,dt,60,288)
    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':'valid context candidate but 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='shadow'; 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"{c['strategy']} {c['direction']} 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 trade(r,idx):
    dt=parse_dt(r['timestamp'])
    return {'id':f"v3_{dt.strftime('%Y%m%dT%H%M%SZ')}_{r['direction'].lower()}_{idx:05d}",'date':dt.date().isoformat(),'timestamp':iso(dt),'symbol':'XAUUSD','strategy':r['strategy'],'direction':r['direction'],'session':session(dt),'narrative':'V3 HTF/PD-array fair review','htf_context':r['htf_context'],'ltf_trigger':r['m15_m5_execution'],'entry':r['entry'],'stop':r['stop'],'target':r['target'],'planned_rr':r['planned_rr'],'fib_entry_level':r['candidate'].get('levels',{}).get('fib_entry_level','n/a'),'fib':'V3 rule: 50% needs PD Array, 62% allowed with structure' if r['strategy']=='Aryy HTF Narrative Fib 50/62' else 'strategy-specific','pd':r['strategy'],'outcome':r['outcome'],'r':r['r'],'confidence':'V3 deterministic HTF/PD-array review','notes':r['reason'],'source_candidate_id':r['candidate_id'],'source_review':'hybrid/reviews/reviews_v3_htf_pdarray.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':'V3 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_v3_htf_pdarray.json','is_confirmed_trade':False}
def main():
    ap=argparse.ArgumentParser(); ap.add_argument('--input',type=Path,default=IN); args=ap.parse_args()
    candidates=json.loads(args.input.read_text())['candidates']; m5=load_bars('M5')
    reviews=[review(c,m5) for c in candidates]
    conf=[trade(r,i+1) for i,r in enumerate([x for x in reviews if x['decision']=='confirmed'])]
    sh=[shadow(r) for r in reviews if r['decision']=='shadow']
    ts=iso(datetime.now(timezone.utc)); counts=Counter(r['decision'] for r in reviews)
    REV.write_text(json.dumps({'metadata':{'purpose':'v3_htf_pdarray_reviews','generated_at':ts,'review_count':len(reviews),'breakdown':dict(counts),'isFinalBacktest':False},'reviews':reviews},indent=2,ensure_ascii=False)+'\n')
    CONF.write_text(json.dumps({'metadata':{'purpose':'v3_confirmed_preview','generated_at':ts,'count':len(conf),'isFinalBacktest':False},'trades':conf},indent=2,ensure_ascii=False)+'\n')
    SHADOW.write_text(json.dumps({'metadata':{'purpose':'v3_shadow_preview','generated_at':ts,'count':len(sh),'isFinalBacktest':False},'shadowTrades':sh},indent=2,ensure_ascii=False)+'\n')
    by=Counter((r['strategy'],r['decision']) for r in reviews)
    summary={'generated_at':ts,'review_count':len(reviews),'breakdown':dict(counts),'confirmed_count':len(conf),'shadow_count':len(sh),'strategy_decision_counts':{f'{k[0]}|{k[1]}':v for k,v in by.items()}}
    SUMMARY.write_text(json.dumps(summary,indent=2,ensure_ascii=False)+'\n')
    print(json.dumps(summary,indent=2))
if __name__=='__main__': main()
