#!/usr/bin/env python3
"""
Headless V84 MeshNex/NormIK pose QA.
Reads V84 MediaPipe candidates, creates:
- qa/mesh_nex_v84/V84_MESHNEX_POSE_QA_REPORT.json
- qa/mesh_nex_v84/V84_MESHNEX_POSE_QA_CONTACT_SHEET.jpg
No browser/Chromium required.
"""
from __future__ import annotations
import json, glob, os, math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont

ROOT = Path(__file__).resolve().parents[2]
BASE = ROOT/'tools/keypose_image_sampler/out/v84_stage2_phase3_strict_mediapipe_keyframes'
PLAN = BASE/'V84_STAGE2_PHASE3_STRICT_SOURCE_PLAN.json'
CAND_DIR = BASE/'mediapipe_candidates'
OUT_DIR = ROOT/'qa/mesh_nex_v84'
OUT_DIR.mkdir(parents=True, exist_ok=True)

CRITICAL = ['left_shoulder','right_shoulder','left_elbow','right_elbow','left_wrist','right_wrist','left_hip','right_hip','left_knee','right_knee','left_ankle','right_ankle','left_heel','right_heel','left_foot_index','right_foot_index']
LOWER = ['left_hip','right_hip','left_knee','right_knee','left_ankle','right_ankle','left_heel','right_heel','left_foot_index','right_foot_index']
UPPER = ['left_shoulder','right_shoulder','left_elbow','right_elbow','left_wrist','right_wrist']
SEQUENCE_FLAGS = {'alternating_sequence_needs_runtime_contract','sprint_cadence_not_proven_by_still','reaction_timing_not_proven_by_still','turn_and_sprint_sequence_needs_runtime_contract'}
CONNECTIONS = [
    ('left_shoulder','right_shoulder'),('left_hip','right_hip'),('left_shoulder','left_hip'),('right_shoulder','right_hip'),
    ('left_shoulder','left_elbow'),('left_elbow','left_wrist'),('right_shoulder','right_elbow'),('right_elbow','right_wrist'),
    ('left_hip','left_knee'),('left_knee','left_ankle'),('left_ankle','left_heel'),('left_heel','left_foot_index'),('left_ankle','left_foot_index'),
    ('right_hip','right_knee'),('right_knee','right_ankle'),('right_ankle','right_heel'),('right_heel','right_foot_index'),('right_ankle','right_foot_index'),
    ('nose','left_shoulder'),('nose','right_shoulder')
]

def load_font(size=13, bold=False):
    candidates=[
        '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' if bold else '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
        '/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf' if bold else '/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf',
    ]
    for p in candidates:
        if os.path.exists(p):
            return ImageFont.truetype(p,size)
    return ImageFont.load_default()
FONT=load_font(13); FONT_B=load_font(14, True); FONT_S=load_font(11)

def thumb_image(path:Path, size=(260,190)):
    try:
        im=Image.open(path).convert('RGB')
    except Exception:
        im=Image.new('RGB', size, (240,240,240))
        ImageDraw.Draw(im).text((8,8),'missing image',font=FONT,fill=(0,0,0))
        return im
    im.thumbnail(size, Image.LANCZOS)
    bg=Image.new('RGB', size, (245,245,245))
    bg.paste(im, ((size[0]-im.width)//2, (size[1]-im.height)//2))
    return bg

def metrics_for_landmarks(lms):
    lm={x['name']:x for x in lms}
    def vis(k): return float(lm.get(k,{}).get('visibility') or 0)
    crit=[vis(k) for k in CRITICAL if k in lm]
    lower=[vis(k) for k in LOWER if k in lm]
    upper=[vis(k) for k in UPPER if k in lm]
    edge=[]
    for k in CRITICAL:
        if k not in lm: continue
        x,y=lm[k]['x'],lm[k]['y']
        if x<0.02 or x>0.98 or y<0.02 or y>0.98:
            edge.append(k)
    low_crit=[k for k in CRITICAL if k in lm and vis(k)<0.50]
    very_low=[k for k in CRITICAL if k in lm and vis(k)<0.12]
    return {
        'meanVisibility': round(sum(float(x.get('visibility') or 0) for x in lms)/max(1,len(lms)), 6),
        'criticalMinVisibility': round(min(crit) if crit else 0, 6),
        'criticalMeanVisibility': round(sum(crit)/len(crit) if crit else 0, 6),
        'lowerMeanVisibility': round(sum(lower)/len(lower) if lower else 0, 6),
        'upperMeanVisibility': round(sum(upper)/len(upper) if upper else 0, 6),
        'lowCriticalLandmarks': low_crit,
        'veryLowCriticalLandmarks': very_low,
        'edgeCriticalLandmarks': edge,
    }

def classify_pose(exercise_key, keypose, metrics, plan_flags):
    flags=[]
    status='PASS_CANDIDATE'
    # Pose-quality thresholds are conservative: fail means unsuitable as test authority without manual review/fix.
    if metrics['criticalMinVisibility'] < 0.12:
        flags.append('very_low_critical_landmark_visibility')
    elif metrics['criticalMinVisibility'] < 0.30:
        flags.append('low_critical_landmark_visibility')
    if len(metrics['lowCriticalLandmarks']) >= 6:
        flags.append('many_low_critical_landmarks')
    if metrics['edgeCriticalLandmarks']:
        flags.append('cropped_or_edge_landmarks')
    if any(f in SEQUENCE_FLAGS for f in plan_flags):
        flags.append('still_frame_does_not_prove_sequence_or_timing')
    if 'partial_body_pose_risk' in plan_flags:
        flags.append('partial_body_pose_risk')
    # Exercise-specific tolerance: single-plane/side-view exercises often occlude the far side; still OK for a body-side keypose, but not as full-body test oracle.
    if flags:
        if 'very_low_critical_landmark_visibility' in flags or 'many_low_critical_landmarks' in flags or 'cropped_or_edge_landmarks' in flags or 'partial_body_pose_risk' in flags:
            status='FIX_BEFORE_TEST_AUTHORITY'
        else:
            status='REVIEW_BEFORE_RUNTIME_CONTRACT'
    return status, flags

def draw_mesh(lms, size=(260,190)):
    lm={x['name']:x for x in lms}
    canvas=Image.new('RGB', size, (248,248,248))
    d=ImageDraw.Draw(canvas)
    xs=[v['x'] for v in lm.values()]; ys=[v['y'] for v in lm.values()]
    if not xs or not ys:
        d.text((10,10),'no pose',font=FONT,fill=(0,0,0)); return canvas
    # Use bbox from landmarks with visibility to avoid tiny far-away subject.
    pad=0.08
    minx,maxx=min(xs),max(xs); miny,maxy=min(ys),max(ys)
    w=max(maxx-minx, 0.01); h=max(maxy-miny, 0.01)
    minx-=w*pad; maxx+=w*pad; miny-=h*pad; maxy+=h*pad
    sx=(size[0]-24)/max(maxx-minx,0.01); sy=(size[1]-24)/max(maxy-miny,0.01); s=min(sx,sy)
    ox=(size[0]-(maxx-minx)*s)/2 - minx*s
    oy=(size[1]-(maxy-miny)*s)/2 - miny*s
    def pt(k):
        v=lm.get(k)
        if not v: return None
        return (ox+v['x']*s, oy+v['y']*s)
    for a,b in CONNECTIONS:
        pa,pb=pt(a),pt(b)
        if not pa or not pb: continue
        va=float(lm[a].get('visibility') or 0); vb=float(lm[b].get('visibility') or 0)
        color=(40,140,55) if min(va,vb)>=0.50 else ((220,150,30) if min(va,vb)>=0.20 else (210,40,40))
        width=4 if min(va,vb)>=0.50 else 2
        d.line([pa,pb], fill=color, width=width)
    for k,v in lm.items():
        if k not in CRITICAL and k!='nose': continue
        p=pt(k); vis=float(v.get('visibility') or 0)
        r=4 if k in CRITICAL else 3
        color=(15,80,220) if vis>=0.50 else ((220,150,30) if vis>=0.20 else (210,40,40))
        d.ellipse((p[0]-r,p[1]-r,p[0]+r,p[1]+r), fill=color, outline=(255,255,255))
    # ground/contact reference line
    foot_pts=[pt(k) for k in ['left_ankle','right_ankle','left_heel','right_heel','left_foot_index','right_foot_index'] if pt(k)]
    if foot_pts:
        y=max(p[1] for p in foot_pts)
        d.line([(8,y),(size[0]-8,y)], fill=(180,180,180), width=1)
    return canvas

def build():
    plan=json.load(open(PLAN))
    plan_by_key={e['exerciseKey']:e for e in plan['exercises']}
    pose_rows=[]
    for f in sorted(glob.glob(str(CAND_DIR/'*/*.mediapipe_keypose_candidates.json'))):
        dct=json.load(open(f))
        p=plan_by_key.get(dct['exerciseKey'],{})
        plan_flags=p.get('reviewFlags') or []
        for rec in dct.get('records',[]):
            if not rec.get('hasPose'):
                pose_rows.append({'exerciseKey':dct['exerciseKey'],'keyposeId':rec.get('keyposeId'),'status':'NO_POSE','flags':['mediapipe_no_pose']})
                continue
            m=metrics_for_landmarks(rec['landmarks'])
            status,flags=classify_pose(dct['exerciseKey'],rec['keyposeId'],m,plan_flags)
            pose_rows.append({
                'exerciseKey':dct['exerciseKey'], 'displayName':dct.get('displayName'), 'keyposeId':rec['keyposeId'], 'sourceImage':rec['sourceImage'],
                'status':status, 'flags':flags, 'planReviewFlags':plan_flags, 'metrics':m,
                'imageWidth':rec.get('imageWidth'), 'imageHeight':rec.get('imageHeight'), '_landmarks':rec['landmarks']
            })
    source_gap_rows=[]
    for e in plan['exercises']:
        if e.get('strictSourceStatus')=='strict_source_gap_no_substitute':
            source_gap_rows.append({'exerciseKey':e['exerciseKey'], 'displayName':e.get('displayName'), 'status':'SOURCE_GAP_BLOCKER', 'reviewFlags':e.get('reviewFlags',[]), 'sourceNote':e.get('sourceNote')})
    summary={
        'schema':'NEXT11_V84_MESHNEX_POSE_QA_V1',
        'candidatePoseCount':len(pose_rows),
        'sourceGapBlockers':len(source_gap_rows),
        'fixBeforeTestAuthority':sum(1 for r in pose_rows if r['status']=='FIX_BEFORE_TEST_AUTHORITY'),
        'reviewBeforeRuntimeContract':sum(1 for r in pose_rows if r['status']=='REVIEW_BEFORE_RUNTIME_CONTRACT'),
        'passCandidate':sum(1 for r in pose_rows if r['status']=='PASS_CANDIDATE'),
        'method':'headless MediaPipe landmark -> MeshNex/NormIK stick readback, no Chromium, no runtime/preview mutation',
        'thresholds':{'criticalMinFixBelow':0.12,'lowLandmarkCountFixAtLeast':6,'edgeCriticalFix':True}
    }
    report={'summary':summary,'sourceGapBlockers':source_gap_rows,'poseRows':[{k:v for k,v in r.items() if not k.startswith('_')} for r in pose_rows]}
    (OUT_DIR/'V84_MESHNEX_POSE_QA_REPORT.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
    # Contact sheet sorted by severity.
    order={'SOURCE_GAP_BLOCKER':0,'FIX_BEFORE_TEST_AUTHORITY':1,'REVIEW_BEFORE_RUNTIME_CONTRACT':2,'PASS_CANDIDATE':3,'NO_POSE':1}
    rows=sorted(pose_rows, key=lambda r:(order.get(r['status'],9), r['exerciseKey'], r['keyposeId']))
    cell_w,cell_h=560,260
    cols=2
    sheet=Image.new('RGB',(cols*cell_w, math.ceil(len(rows)/cols)*cell_h),(255,255,255))
    for idx,r in enumerate(rows):
        x=(idx%cols)*cell_w; y=(idx//cols)*cell_h
        cell=Image.new('RGB',(cell_w,cell_h),(255,255,255)); d=ImageDraw.Draw(cell)
        status=r['status']
        stripe={'FIX_BEFORE_TEST_AUTHORITY':(190,30,30),'REVIEW_BEFORE_RUNTIME_CONTRACT':(220,145,0),'PASS_CANDIDATE':(40,140,70),'NO_POSE':(190,30,30)}.get(status,(80,80,80))
        d.rectangle([0,0,cell_w,22], fill=stripe)
        d.text((6,3), f"{status} — {r['exerciseKey']}", font=FONT_B, fill=(255,255,255))
        d.text((8,28), r['keyposeId'][:72], font=FONT_S, fill=(0,0,0))
        src=thumb_image(ROOT/r['sourceImage'], (260,190))
        mesh=draw_mesh(r.get('_landmarks') or [], (260,190))
        cell.paste(src,(8,56)); cell.paste(mesh,(292,56))
        m=r.get('metrics',{})
        lines=[
            f"critMin={m.get('criticalMinVisibility')} critMean={m.get('criticalMeanVisibility')}",
            f"low={','.join(m.get('lowCriticalLandmarks',[])[:6])}{'…' if len(m.get('lowCriticalLandmarks',[]))>6 else ''}",
            f"flags={','.join(r.get('flags',[])[:3])}{'…' if len(r.get('flags',[]))>3 else ''}",
        ]
        yy=220
        for line in lines:
            d.text((8,yy), line[:96], font=FONT_S, fill=(30,30,30)); yy+=12
        sheet.paste(cell,(x,y))
    sheet.save(OUT_DIR/'V84_MESHNEX_POSE_QA_CONTACT_SHEET.jpg', quality=92)
    print(json.dumps(summary, indent=2))
    print('report', OUT_DIR/'V84_MESHNEX_POSE_QA_REPORT.json')
    print('contactSheet', OUT_DIR/'V84_MESHNEX_POSE_QA_CONTACT_SHEET.jpg')

if __name__ == '__main__':
    build()
