"""One synchronized review per candidate; no inference or semantic video analysis."""
import csv
from fractions import Fraction
import hashlib
import json
import os
from pathlib import Path
import sys
import time
import av
import numpy as np
from PIL import Image,ImageDraw
from inputs import OUT,load,evidence

sys.path.insert(0,'/baseline')
from contract import palette,observation,edge_state
from layout import W,H,IW,IH,IX,IY,FONT,text,overlay,MUTED

LABEL={'positive_reference':'사용자 감지 희망 참고','negative_reference':'사용자 비감지 희망 참고',
       'unknown':'구간 기대 미지정','conflict_or_pending':'기대 중첩 또는 사용자 판단 대기'}


def hull(points):
    pts=sorted(set(tuple(p) for p in points))
    def cross(o,a,b):return (a[0]-o[0])*(b[1]-o[1])-(a[1]-o[1])*(b[0]-o[0])
    lower=[];upper=[]
    for p in pts:
        while len(lower)>1 and cross(lower[-2],lower[-1],p)<=0:lower.pop()
        lower.append(p)
    for p in reversed(pts):
        while len(upper)>1 and cross(upper[-2],upper[-1],p)<=0:upper.pop()
        upper.append(p)
    return lower[:-1]+upper[:-1]


def base_panel(image,row,view,colors):
    people=[p for p in observation(row,colors) if p['visible']]
    for p in people:
        p['joint_states']=['BELOW' if s=='LOW' else s for s in p['joint_states']]
        from contract import EDGES
        p['edge_states']=[edge_state(p['joint_states'][a],p['joint_states'][b]) for a,b in EDGES]
    graph={};by={b['order']:b for b in view['bodies']}
    for p in view['pairs']:
        a,b=p['orders'];graph.setdefault(a,set()).add(b);graph.setdefault(b,set()).add(a)
    mask=Image.new('RGBA',image.size,(0,0,0,0));d=ImageDraw.Draw(mask);seen=set();groups=0
    for a in sorted(graph):
        if a in seen:continue
        stack=[a];members=set()
        while stack:
            x=stack.pop()
            if x in members:continue
            members.add(x);seen.add(x);stack+=list(graph[x]-members)
        coords=[]
        for x in members:
            l,t,r,b=by[x]['bbox'];coords += [(l,t),(r,t),(r,b),(l,b)]
        poly=hull(coords)
        if len(poly)>2:d.polygon(poly,fill=(85,185,230,28),outline=(85,185,230,90))
        groups+=1
    image=Image.alpha_composite(image.convert('RGBA'),mask).convert('RGB')
    panel,meta=overlay(image,people)
    from PIL import ImageOps
    fit=ImageOps.contain(image,(IW,IH));sx=fit.width/image.width;sy=fit.height/image.height
    ox,oy=(IW-fit.width)//2,(IH-fit.height)//2
    def pt(p):return (ox+max(0,min(fit.width-1,p[0]*sx)),oy+max(0,min(fit.height-1,p[1]*sy)))
    return panel,meta,pt,groups


def page(base,meta,pt,groups,row,view,result,candidate,index,total):
    panel=base.copy();pd=ImageDraw.Draw(panel)
    for p in result['pairs']:
        for f in p['passing']:
            x,y=pt(f['endpoint']);c='#ffb35c' if p['hit'] else '#f1d67a'
            radius=12 if p['hit'] else 7
            pd.ellipse((x-radius,y-radius,x+radius,y+radius),outline=c,width=3 if p['hit'] else 2)
            pd.line((*pt(f['root_point']),x,y),fill=c,width=2)
            pd.line((x,y,*pt(f['target_point'])),fill=c,width=1)
    for box in meta['label_boxes']:
        box=tuple(int(round(x)) for x in box)
        panel.paste(base.crop(box),box)
    out=Image.new('RGB',(W,H),'#101820');d=ImageDraw.Draw(out);rects=[];out.paste(panel,(IX,IY))
    text(d,rects,(20,16),clip+' | '+candidate['id']+' | '+candidate['title'],34)
    text(d,rects,(20,78),f"입력 Frame {row['source_frame']} | PTS {row['seconds']:.3f}s | 관측 {index+1}/{total} | 10FPS 정상 속도",28)
    state='현재 조건 충족' if result['hit'] else '표시 유지 (현재 조건 아님)' if result['episode']['display'] else '현재 조건 미충족' if result['observable'] else '근거 부족 / 준비 중'
    color='#ff6666' if result['hit'] else '#e6b65b' if result['episode']['display'] else '#82c7d5'
    if result['episode']['display']:d.rectangle((IX,IY,IX+IW-1,IY+IH-1),outline=color,width=7)
    text(d,rects,(20,1220),'ROI 상태: '+state,32,color)
    text(d,rects,(20,1270),f"공간 그룹 {groups} | 현재 후보쌍 {len(view['pairs'])} | 미관측 보관 {view['retained_absent']} | 새 시험 Event {int(result['episode']['event'])}",26)
    ref=LABEL[result['reference']]
    if result['review_frame'] is not None:ref+=' | 사용자 검토 Frame '+str(result['review_frame'])
    text(d,rects,(20,1330),ref+' / 정확한 Frame 정답 아님',26)
    for i,line in enumerate(['청색 영역: 공간 후보 / 최대1초 보관과 현재 동작은 별개',
        '관절 링: 현재 특징 통과 / 큰 링: 최근 관측 수까지 충족',
        'T는 기존 ID, q는 Frame별 미추적 칸 / 새 사람 ID 생성 없음',
        'ROI 결과를 특정 사람의 확정 행동으로 해석하지 마세요.',
        '운영 Event 저장·전송 없음 / 표시 유지로 판정 비율을 늘리지 않음']):
        text(d,rects,(20,1390+i*65),line,26,MUTED)
    x=1910;cfg=candidate['config']
    text(d,rects,(x,150),'조건 및 계산 근거 / bbox0.25 · 관절0.10',28)
    text(d,rects,(x,200),f"속도>={cfg['speed_min']:.2f} / 거리<={cfg['near_max']:.2f} / 접근속도>={cfg['approach_speed_min']:.2f}",24)
    text(d,rects,(x,244),f"방향 cos>={cfg['cosine_min']:.2f} / 최근{cfg['window']:.1f}초 같은 주체 {cfg['min_samples']}관측",24)
    entries=sorted(result['pairs'],key=lambda p:(not p['hit'],not p['raw'],p['orders']))[:3]
    for i,p in enumerate(entries):
        y=310+i*350;lim=(x,y,3180,y+330);d.rectangle(lim,outline='#43515e',width=2)
        text(d,rects,(x+18,y+14),p['token']+' | '+' + '.join(p['refs']),28,limit=lim)
        text(d,rects,(x+18,y+65),('현재 근접' if p['candidate_state']=='CURRENT_NEAR' else '거리 완화 유지')+f" | 유지 경과 {p['hold_age']:.2f}s",24,limit=lim)
        text(d,rects,(x+18,y+110),f"현재 특징 {int(p['raw'])} | 누적조건 {int(p['hit'])} | 관측 수 {p['counts']}",24,limit=lim)
        if p['passing']:
            f=max(p['passing'],key=lambda f:f['speed']);kind='손' if f['kind']=='hand' else '발'
            role='시드 뒤 실제 움직임' if f.get('evidence_role')=='OBSERVED_RETURN_OR_CONTINUATION' else '상대 방향 접근 근거'
            text(d,rects,(x+18,y+155),p['refs'][f['actor']]+' '+f['limb']+' / '+kind+' / '+role,24,limit=lim)
            text(d,rects,(x+18,y+200),f"속도 {f['speed']:.3f} | 거리 {f['near']:.3f} | 접근 {f['approach']/f['elapsed']:.3f}",24,limit=lim)
            text(d,rects,(x+18,y+245),f"사용 PTS {f['input_start']:.3f}~{f['input_end']:.3f} / 미래Frame 없음",24,MUTED,lim)
        else:text(d,rects,(x+18,y+185),'현재 통과 특징 없음 / '+p['reason'],22,MUTED,lim)
        text(d,rects,(x+18,y+292),'몸 크기로 정규화한 값 / 싸움 확률 아님',22,MUTED,lim)
    if not entries:text(d,rects,(x+18,340),'현재 유효 후보쌍 없음 / 정상 행동 확정 아님',26)
    text(d,rects,(x,1430),f"상세 {len(entries)}쌍 / {max(0,len(result['pairs'])-3)}쌍 상세 생략, 전체 데이터 보존",24,MUTED)
    text(d,rects,(x,1500),'상세 순서: 조건 충족 → 특징 통과 → 원래 객체 순서',24,MUTED)
    text(d,rects,(x,1570),'공간 후보·현재 조건·표시 유지·Event는 서로 다릅니다.',24,MUTED)
    if cfg.get('family')=='seed_follow':text(d,rects,(x,1640),f"시드 유효 {cfg['seed_seconds']:.1f}초 / 유지 동작만으로 시드 연장 금지",24,MUTED)
    return out


if __name__=='__main__':
    clip=os.environ['CLIP'];started=time.monotonic()
    rows,chosen,spec,cache,maps=load(clip);rows=[r for r in rows if r['sample'] in chosen]
    candidates=json.loads((OUT/'candidates.json').read_text())['candidates'];dest=OUT/'download'/clip;data=dest/'data'
    views=[json.loads(s) for s in (OUT/'work'/clip/'features.jsonl').read_text().splitlines()]
    views=[r for r in views if r['displayed']]
    colors=palette(rows);(data/'identity_palette.json').write_text(json.dumps(colors))
    rgb_hashes=json.loads(evidence(spec['rgb_hashes_remote']).read_text());jobs={}
    summary=json.loads((data/'summary.json').read_text())
    for c in candidates:
        name=c['id']+'_Review.mp4';output=dest/name;report=data/(c['id']+'_video_validation.json')
        if report.exists():
            check=json.loads(report.read_text());assert hashlib.sha256(output.read_bytes()).hexdigest()==check['sha256'];continue
        draft=dest/('.'+name+'.partial.mp4');assert not output.exists() and not draft.exists()
        raw=(data/(c['id']+'_diagnostics.jsonl')).read_bytes();assert hashlib.sha256(raw).hexdigest()==summary['candidates'][c['id']]['sha256']
        results=[json.loads(s) for s in raw.splitlines()];results=[r for r in results if r['displayed']]
        assert len(results)==len(rows)==len(views)
        container=av.open(str(draft),'w',options={'movflags':'faststart'});stream=container.add_stream('libx264',rate=10)
        stream.width=W;stream.height=H;stream.pix_fmt='yuv420p';stream.options={'crf':'22','preset':'veryfast'}
        stream.codec_context.thread_count=1;stream.time_base=stream.codec_context.time_base=Fraction(1,90000)
        samples={0,len(rows)-1,max(range(len(rows)),key=lambda i:len(views[i]['pairs']))}
        for pred in (lambda r:r['hit'],lambda r:r['episode']['display'] and not r['hit']):
            n=next((i for i,r in enumerate(results) if pred(r)),None)
            if n is not None:samples.add(n)
        jobs[c['id']]=dict(candidate=c,results=results,container=container,stream=stream,draft=draft,output=output,report=report,samples=samples)
    if not jobs:print('REUSED',clip);raise SystemExit(0)
    mapping=[]
    for i,(row,view) in enumerate(zip(rows,views)):
        assert (row['sample'],row['source_frame'],row['seconds'])==(view['sample'],view['source_frame'],view['seconds'])
        path=cache/'frames'/f"{row['sample']:04d}.png";assert hashlib.sha256(path.read_bytes()).hexdigest()==rgb_hashes[path.name]
        with Image.open(path) as im:image=im.convert('RGB')
        assert image.size==(row['width'],row['height'])
        base,meta,pt,groups=base_panel(image,row,view,colors);pts=round((row['seconds']-rows[0]['seconds'])*90000)
        for key,j in jobs.items():
            r=j['results'][i];assert (r['sample'],r['source_frame'],r['seconds'])==(row['sample'],row['source_frame'],row['seconds'])
            out=page(base,meta,pt,groups,row,view,r,j['candidate'],i,len(rows))
            if i in j['samples']:out.save(data/(key+f'_sample_{i:04d}.jpg'),quality=90)
            frame=av.VideoFrame.from_ndarray(np.asarray(out),format='rgb24');frame.pts=pts;frame.time_base=Fraction(1,90000)
            for packet in j['stream'].encode(frame):j['container'].mux(packet)
        mapping.append(dict(output_frame=i+1,sample=row['sample'],source_frame=row['source_frame'],source_seconds=row['seconds'],
            review_frame=view['review_frame'],output_seconds=pts/90000))
        if (i+1)%200==0:print('FRAME',clip,i+1,len(rows),flush=True)
    for key,j in jobs.items():
        for packet in j['stream'].encode():j['container'].mux(packet)
        j['container'].close();times=[]
        with av.open(str(j['draft'])) as inp:
            inp.streams.video[0].codec_context.thread_count=1
            for f in inp.decode(video=0):
                assert not f.is_corrupt and (f.width,f.height)==(W,H);times.append(float(f.pts*f.time_base))
        assert len(times)==len(rows);assert all(b>a for a,b in zip(times,times[1:]))
        error=max(abs(t-r['output_seconds']) for t,r in zip(times,mapping));assert error<1/90000
        j['draft'].rename(j['output'])
        result=dict(status='PASS',frames=len(rows),resolution=[W,H],nominal_fps=10,max_pts_error=error,
            text_overflow=0,text_overlap=0,source_rgb_hashes_verified=len(rows),
            sha256=hashlib.sha256(j['output'].read_bytes()).hexdigest(),elapsed_seconds=time.monotonic()-started)
        j['report'].write_text(json.dumps(result,indent=2));print(key,json.dumps(result),flush=True)
    with (data/'frame_map.csv').open('w',newline='') as f:
        w=csv.DictWriter(f,fieldnames=mapping[0]);w.writeheader();w.writerows(mapping)
