#!/usr/bin/env python3
"""Rebuild fair hybrid journal from review files.

Fair rule: if a candidate has a valid setup/entry geometry and entry is triggered,
it belongs in journal BEFORE knowing whether TP or SL wins. Outcome may be Win,
Loss, BE/Review. Losing valid setups are not moved to shadow.

This script snapshots the current biased journal/backtest, then overwrites
hybrid/data/journal.json and hybrid/data/backtest-data.json with a fair rebuild.
"""
from __future__ import annotations

import json, re, shutil, statistics
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

ROOT=Path(__file__).resolve().parents[1]
DATA=ROOT/'data'
REV=ROOT/'reviews'
REPORTS=ROOT/'reports'

def now_iso(): return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00','Z')
def parse_dt(v):
    s=str(v)
    if 'T' in s and s.count(':')<2 and '-' in s.split('T')[1]:
        d,t=s.split('T'); s=d+'T'+t.rstrip('Z').replace('-',':')+'Z'
    dt=datetime.fromisoformat(s.replace('Z','+00:00'))
    if dt.tzinfo is None: dt=dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc)
def sess(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 load(p): return json.loads(p.read_text())
def result_items(payload):
    if isinstance(payload,list): return payload
    if isinstance(payload,dict):
        for k in ('items','results','decisions','reviews'):
            if isinstance(payload.get(k),list): return payload[k]
    return []
def safe_float(x):
    try:
        if x is None or x=='': return None
        return float(x)
    except Exception: return None

def phase1_items():
    out=[]
    for p in sorted((REV/'manual_replay_results').glob('replay_result_*.json')):
        for it in result_items(load(p)):
            cid=it.get('candidate_id')
            ts=it.get('timestamp')
            entry=safe_float(it.get('entry')); stop=safe_float(it.get('stop')); target=safe_float(it.get('target'))
            # Only fair-journal phase1 items if entry geometry is present. needs_more_context without entry remains review-only.
            if not (cid and ts and entry is not None and stop is not None and target is not None):
                continue
            direction='Long' if '_long_' in cid.lower() else 'Short' if '_short_' in cid.lower() else it.get('direction')
            dec=it.get('decision')
            if dec=='accepted': outcome='Win'; r=safe_float(it.get('r'))
            elif dec=='shadow': outcome='Loss'; r=safe_float(it.get('r')) if safe_float(it.get('r')) is not None else -1.0
            elif dec=='rejected': continue
            else: continue
            out.append({'source_phase':'phase1_liquidity_sweep','source_file':str(p.relative_to(ROOT.parent)),'candidate_id':cid,'timestamp':ts,'strategy':'Liquidity Sweep Reversal','direction':direction,'entry':entry,'stop':stop,'target':target,'outcome':outcome,'r':r,'reason':it.get('reason') or it.get('notes') or 'Phase1 fair rebuild item.','htf_context':it.get('htf_context') or 'Phase1 review context.','ltf_trigger':it.get('m15_m5_execution') or 'Phase1 M15/M5 execution review.'})
    return out

def phase2_items():
    # reviews_v2_multistrategy has all decisions and levels/entry/stop/target/r for confirmed/shadow.
    p=REV/'reviews_v2_multistrategy.json'
    if not p.exists(): return []
    out=[]
    for r in load(p).get('reviews',[]):
        dec=r.get('decision')
        if dec not in ('confirmed','shadow'):
            continue
        entry=safe_float(r.get('entry')); stop=safe_float(r.get('stop')); target=safe_float(r.get('target'))
        if entry is None or stop is None or target is None:
            continue
        outcome='Win' if dec=='confirmed' else 'Loss'
        rr=safe_float(r.get('r'))
        if outcome=='Loss' and (rr is None or rr>=0): rr=-1.0
        if outcome=='Win' and (rr is None or rr<=0): rr=1.0
        cand = r.get('candidate', {}) if isinstance(r.get('candidate'), dict) else {}
        ts = r.get('timestamp') or cand.get('timestamp')
        strategy = r.get('strategy') or cand.get('strategy')
        direction = r.get('direction') or cand.get('direction')
        out.append({'source_phase':'phase2_multistrategy','source_file':'hybrid/reviews/reviews_v2_multistrategy.json','candidate_id':r['candidate_id'],'timestamp':ts,'strategy':strategy,'direction':direction,'entry':entry,'stop':stop,'target':target,'outcome':outcome,'r':rr,'reason':r.get('reason') or 'Phase2 fair rebuild item.','htf_context':r.get('htf_context') or 'Phase2 HTF context mechanically checked.','ltf_trigger':r.get('m15_m5_execution') or 'Phase2 M15/M5 execution mechanically checked.'})
    return out

def canonical(x, idx):
    dt=parse_dt(x['timestamp']); direction=x['direction']; outcome=x['outcome']
    return {'id':f"fair_{dt.strftime('%Y%m%dT%H%M%SZ')}_{direction.lower()}_{idx:05d}",'date':dt.date().isoformat(),'timestamp':dt.isoformat().replace('+00:00','Z'),'symbol':'XAUUSD','strategy':x['strategy'],'direction':direction,'session':sess(dt),'narrative':'Fair rebuild: setup valid before outcome; losing valid setups included.','htf_context':x['htf_context'],'ltf_trigger':x['ltf_trigger'],'entry':x['entry'],'stop':x['stop'],'target':x['target'],'fib':'strategy-specific fair rebuild','pd':x['strategy'],'outcome':outcome,'r':x['r'],'confidence':'Fair deterministic rebuild; visual replay audit still recommended','notes':x['reason'],'source_candidate_id':x['candidate_id'],'source_phase':x['source_phase'],'source_review':x['source_file'],'is_confirmed_trade':True}

def base_obj(trades):
    return {'metadata':{'symbol':'XAUUSD','mode':'hybrid_fair_rebuild','period':'2026-01-01 to 2026-07-17','currency':'USD','riskPerR':100,'startingBalance':10000,'isDummy':False,'source':'fair_rebuild_from_phase1_phase2_reviews','last_updated':now_iso(),'trade_count':len(trades),'notes':'Fair rebuild includes losses for valid triggered setups; not winner-only.'},'strategies':['Aryy HTF Narrative Fib 50/62','ICT Sweep + MSS + FVG','Order Block Reclaim','Liquidity Sweep Reversal','Breaker Continuation'],'sessions':['Asia','London','NY AM','London Close','NY PM','Other'],'trades':trades}

def main():
    REPORTS.mkdir(exist_ok=True)
    stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
    for rel in ['journal.json','backtest-data.json']:
        p=DATA/rel
        if p.exists(): shutil.copy2(p, DATA/f'{p.stem}_biased_snapshot_before_fair_rebuild_{stamp}.json')
    raw=phase1_items()+phase2_items()
    # dedupe by source candidate id + strategy
    seen=set(); clean=[]
    for x in sorted(raw,key=lambda z:parse_dt(z['timestamp'])):
        key=(x['candidate_id'],x['strategy'])
        if key in seen: continue
        seen.add(key); clean.append(x)
    trades=[canonical(x,i+1) for i,x in enumerate(clean)]
    obj=base_obj(trades)
    (DATA/'journal.json').write_text(json.dumps(obj,indent=2,ensure_ascii=False)+'\n')
    (DATA/'backtest-data.json').write_text(json.dumps(obj,indent=2,ensure_ascii=False)+'\n')
    counts=Counter(t['outcome'] for t in trades); strat=Counter(t['strategy'] for t in trades); rs=[float(t['r']) for t in trades]
    summary={'generated_at':now_iso(),'trade_count':len(trades),'outcomes':dict(counts),'winrate':round(counts.get('Win',0)/len(trades)*100,2) if trades else None,'total_R':round(sum(rs),2) if rs else 0,'avg_R':round(statistics.mean(rs),3) if rs else None,'strategy_counts':dict(strat),'snapshot_stamp':stamp,'notes':'Option C fair rebuild completed.'}
    (REPORTS/'fair_rebuild_summary.json').write_text(json.dumps(summary,indent=2,ensure_ascii=False)+'\n')
    print(json.dumps(summary,indent=2))
if __name__=='__main__': main()
