"""Offline exact-frame trim and dual output; read-only source files, CPU only."""
import csv
import hashlib
import json
import pathlib
import re
import shutil
import subprocess
from fractions import Fraction

ROOT = pathlib.Path('/work')
OUT = ROOT / 'Contextra_User_Segments_20'
REPORT = ROOT / 'reports'
FONT = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'

def digest(p):
    h = hashlib.sha256()
    with p.open('rb') as f:
        for chunk in iter(lambda:f.read(1048576), b''):
            h.update(chunk)
    return h.hexdigest()

def save(p, value):
    with p.open('x') as f:
        json.dump(value, f, ensure_ascii=False, indent=2)

def command(args, timeout):
    r = subprocess.run(args, capture_output=True, timeout=timeout)
    assert r.returncode == 0, r.stderr.decode(errors='replace')[-2000:]
    return r

def probe(path, report):
    r = command(['ffprobe','-v','error','-threads','2','-select_streams','v:0','-show_streams','-show_frames','-show_entries','stream=codec_name,width,height,pix_fmt,color_range,color_space,color_transfer,color_primaries,r_frame_rate,avg_frame_rate,time_base,duration,nb_frames:frame=best_effort_timestamp,best_effort_timestamp_time,width,height','-of','json',str(path)],600)
    assert not r.stderr.strip(), r.stderr.decode(errors='replace')[-2000:]
    data = json.loads(r.stdout)
    save(report, data)
    return data

def render(spec, source):
    ident = spec['id']
    plain = OUT/'Unmarked_Clips'/f'{ident}_original.mp4'
    review = OUT/'Frame_Review'/f'{ident}_review.mp4'
    mapfile = OUT/'Frame_Maps'/f'{ident}.csv'
    valfile = OUT/'Validation'/f'{ident}.json'
    if valfile.exists():
        v = json.loads(valfile.read_text())
        assert all(digest(OUT/k) == d['sha256'] for k,d in v['files'].items())
        print('REUSE '+ident,flush=True)
        return v
    assert not any(p.exists() for p in (plain,review,mapfile))
    assert shutil.disk_usage(ROOT).free >= 15*1024**3
    first,last,count = spec['first'],spec['last'],spec['frames']
    pts = source['pts'][first-1:last]
    relative = [x-pts[0] for x in pts]
    st = source['stream']; tb = Fraction(st['time_base'])
    attempt = 1
    while (REPORT/f'{ident}_a{attempt:02d}').exists(): attempt += 1
    work = REPORT/f'{ident}_a{attempt:02d}'; work.mkdir()
    draft = work/'original.mp4'; annotated = work/'review.mp4'
    header = f"camera{spec['camera_id']} | Clip Frame %{{eif\\:n+1\\:d}}/{count} | Recording Frame %{{eif\\:n+{first}\\:d}}"
    draw = f"drawtext=fontfile={FONT}:text='{header}':fontsize=32:fontcolor=white:box=1:boxcolor=black@0.75:boxborderw=8:x=20:y=20"
    draw += f",drawtext=fontfile={FONT}:text='Clip Time %{{pts\\:hms}}':fontsize=32:fontcolor=white:box=1:boxcolor=black@0.75:boxborderw=8:x=20:y=70"
    graph = f'[0:v]trim=start_frame={first-1}:end_frame={last},showinfo,setpts=PTS-STARTPTS,split=2[plain][base];[base]{draw}[review]'
    cmd = ['ffmpeg','-nostdin','-hide_banner','-loglevel','info','-n','-copyts','-threads','2','-i',str(source['path']),'-filter_complex_threads','1','-filter_complex',graph]
    for label, dest in (('plain',draft),('review',annotated)):
        cmd += ['-map',f'[{label}]','-an','-map_metadata','-1','-c:v','libx264','-preset','veryfast','-crf','18','-threads','1','-bf','0','-pix_fmt',st['pix_fmt'],'-fps_mode','passthrough','-enc_time_base',st['time_base'],'-video_track_timescale',str(tb.denominator),'-movflags','+faststart']
        for key,opt in (('color_range','-color_range'),('color_space','-colorspace'),('color_transfer','-color_trc'),('color_primaries','-color_primaries')):
            if st.get(key) and st[key] != 'unknown': cmd += [opt,st[key]]
        cmd += [str(dest)]
    print('RENDER '+ident,flush=True)
    r = subprocess.run(cmd,capture_output=True,timeout=900)
    log = r.stderr.decode(errors='replace')
    (work/'encode.log').write_text(log)
    assert r.returncode == 0, log[-2000:]
    trace = [(int(n),int(p)) for n,p in re.findall(r'n:\s*(\d+)\s+pts:\s*(-?\d+)\s+pts_time:',log)]
    assert trace == list(enumerate(pts)), ('trim selection mismatch', ident)
    variants = {}
    for name,path in (('unmarked',draft),('review',annotated)):
        p = probe(path,work/f'{name}_probe.json')
        s = p['streams'][0]; frames=p['frames']; otb=Fraction(s['time_base'])
        assert len(frames) == count
        actual = [int(x['best_effort_timestamp'])*otb for x in frames]
        assert actual == [x*tb for x in relative], (ident,name,'PTS changed')
        assert (s['width'],s['height']) == (st['width'],st['height'])
        assert all((f['width'],f['height']) == (st['width'],st['height']) for f in frames)
        assert s['pix_fmt'] == st['pix_fmt']
        variants[name] = dict(frame_count=len(frames),time_base=s['time_base'],relative_pts_error=0,duration=s['duration'],avg_frame_rate=s['avg_frame_rate'],r_frame_rate=s['r_frame_rate'],decode_errors='')
    with mapfile.open('x',encoding='utf-8-sig',newline='') as f:
        w=csv.writer(f)
        w.writerow(['clip_id','clip_frame_1based','recording_frame_1based','recording_pts_seconds','clip_pts_seconds','review_pts_seconds','case_label_user_reference'])
        for n,(p,rel) in enumerate(zip(pts,relative),1):
            w.writerow([ident,n,first+n-1,f'{float(p*tb):.9f}',f'{float(rel*tb):.9f}',f'{float(rel*tb):.9f}',spec['case_label']])
    draft.rename(plain); annotated.rename(review)
    v=dict(id=ident,camera_id=spec['camera_id'],first_recording_frame=first,last_recording_frame=last,frames=count,inclusive=True,source_selection_exact=True,relative_pts_error=0,elapsed_seconds=float(relative[-1]*tb),variants=variants,files={str(p.relative_to(OUT)):{'bytes':p.stat().st_size,'sha256':digest(p)} for p in (plain,review,mapfile)})
    save(valfile,v)
    if ident in ('camera06_01_fall_F0362-0722','camera11_02_fall_F0603-0963'):
        command(['ffmpeg','-nostdin','-hide_banner','-loglevel','error','-n','-threads','2','-i',str(review),'-vf',f'select=eq(n\,0)+eq(n\,{count-1})','-fps_mode','passthrough','-frames:v','2','-threads','1',str(REPORT/'previews'/f'{ident}_%02d.png')],120)
    print('PASS '+ident+' '+str(count),flush=True)
    return v

def main():
    assert pathlib.Path(FONT).is_file()
    for d in (OUT/'Unmarked_Clips',OUT/'Frame_Review',OUT/'Frame_Maps',OUT/'Validation',REPORT/'previews'):
        d.mkdir(parents=True,exist_ok=True)
    manifest = json.loads((ROOT/'manifest.json').read_text())
    sources = {}
    for cid,source in manifest['sources'].items():
        path = pathlib.Path(f'/source/camera{cid}/camera{cid}_original.mp4')
        mapping = path.parent/f'camera{cid}_frame_map.csv'
        assert digest(path) == source['source_sha256'] and digest(mapping) == source['source_map_sha256']
        probe_path = REPORT/f'camera{cid}_source_probe.json'
        p = json.loads(probe_path.read_text()) if probe_path.exists() else probe(path,probe_path)
        st=p['streams'][0];pts=[int(x['best_effort_timestamp']) for x in p['frames']]
        assert len(pts)==source['frames'] and all(b>a for a,b in zip(pts,pts[1:]))
        with mapping.open() as f: rows=list(csv.DictReader(f))
        assert len(rows)==len(pts)
        assert all(int(x['frame_1based'])==i+1 and abs(float(x['original_pts_seconds'])-float(pts[i]*Fraction(st['time_base'])))<.000001 for i,x in enumerate(rows))
        sources[int(cid)] = dict(path=path,stream=st,pts=pts)
    results = [render(s,sources[s['camera_id']]) for s in manifest['clips']]
    for cid,s in sources.items(): assert digest(s['path']) == manifest['sources'][str(cid)]['source_sha256']
    save(REPORT/'validation_summary.json',dict(clips=len(results),videos=2*len(results),selected_frames=sum(x['frames'] for x in results),source_hashes_unchanged=True,all_source_selection_exact=True,all_pts_error_zero=True,decode_errors=0))
    with (OUT/'clip_list.csv').open('x',encoding='utf-8-sig',newline='') as f:
        fields=['id','camera_id','name','case_label','first','last','frames','label_origin']
        w=csv.DictWriter(f,fieldnames=fields,extrasaction='ignore');w.writeheader();w.writerows(manifest['clips'])
    shutil.copy2(ROOT/'manifest.json',OUT/'manifest.json')
    (OUT/'00_README_ko.md').write_text('''# 사용자 지정 반복 구간20개

Unmarked_Clips: 무표시20본. Frame_Review: 번호 검토본20본. Frame_Maps: 대응표20개.
시작·끝 Frame을 모두 포함했다. 앞뒤 context를 추가하지 않았고 기존60초 녹화본은 보존했다.
검토본 Clip Frame은1부터, Recording Frame은 기존60초 검토본 번호다. Clip Time은 구간 시작0초 기준이다.
실제PTS 간격과 정상 속도를 유지했다. 프레임 정확 절단을 위해 CPU 재인코딩했으며 streamcopy/무손실 복사는 아니다.
Frame 수/선택 구간/상대PTS·전체 디코딩·SHA256은 Validation과 SHA256.json을 따른다. FPS metadata 추정값과 실제PTS를 혼동하지 않는다.
행동명은 사용자 참고 라벨이며 모델 판정이나 Event GT가 아니다. camera11의두 번째 구간은 채널명 Intrusion과 달라도 사용자 지정 쓰러짐으로 기록했다.
camera8/9는 별도 행동명이 없어 지정 구간으로 표기했다. camera7전체·camera14화재·camera16군중은 사용자 지정 중복으로 제외했다.
camera12/13/17 실패는 계속 보류한다. 새 녹화·추론·운영 설정 변경은 하지 않았다.
이 폴더는 서버의 다운로드용 자료이며 사용자 Windows 다운로드 폴더로 실제 복사했다고 의미하지 않는다.
''',encoding='utf-8')
    checks={str(p.relative_to(OUT)):{'bytes':p.stat().st_size,'sha256':digest(p)} for p in sorted(OUT.rglob('*')) if p.is_file()}
    save(OUT/'SHA256.json',checks)
    print(json.dumps({'clips':len(results),'videos':40,'frames':6380,'files':len(checks)}),flush=True)

if __name__ == '__main__':
    main()
