"""Bounded manifest, raw-result invariance, and display/time audit."""
import bootstrap
from bootstrap import SOURCE
import csv
import hashlib
import json
from pathlib import Path
import shutil
from inputs import OUT


def read(path):return json.loads(path.read_text())
def sha(path):
    h=hashlib.sha256()
    with path.open('rb') as f:
        for b in iter(lambda:f.read(1024*1024),b''):h.update(b)
    return h.hexdigest()
def lines(path):
    with path.open() as f:
        return [json.loads(s) for s in f if s.strip()]


plan=read(OUT/'plan.json');dest=OUT/'download';validation=dest/'validation'
metadata=dest/'metadata';metadata.mkdir(exist_ok=True)
assert sha(OUT/'candidates.json')==sha(SOURCE/'candidates.json')
for name in ('candidates.json','specs.json','self_maps.json','cases.json','plan.json','execution_contract.json'):
    shutil.copy2(OUT/name,metadata/name)
videos=[];audits=[];reused=[];total_obs=0;total_frames=0
for clip in plan['output_inputs']:
    folder=dest/clip;data=folder/'data';report=read(data/'video_validation.json')
    video=folder/'B_Integrated_Space_Review.mp4'
    assert report['status']=='PASS' and sha(video)==report['sha256']
    if clip=='SELF_L01':
        records=[]
        for part in plan['parts']:
            sd=data/part;s=read(sd/'summary.json');r=lines(sd/'B_Integrated_diagnostics.jsonl')
            assert sha(sd/'B_Integrated_diagnostics.jsonl')==s['sha256']
            assert s['candidate_sha256']==sha(OUT/'candidates.json')
            for source,digest in s['frozen_sources'].items():
                assert sha(Path('/evidence')/source)==digest
            assert not r[0]['hit'] and not r[0]['episode']['display'] and not r[0]['episode']['event']
            for row in r:
                assert row['part_id']==part.removeprefix('SELF_')
                for pair in row['pairs']:
                    for feature in pair['diagnostics']:
                        assert r[0]['seconds']<=feature['input_start']<feature['input_end']==row['seconds']
            records+=r
        assert len(records)==8305 and report['gap_slates']==1
    else:
        old=SOURCE/'download'/clip/'data';source_hash=sha(old/'B_Integrated_diagnostics.jsonl')
        assert source_hash==sha(data/'B_Integrated_diagnostics.jsonl')==read(old/'summary.json')['sha256']
        assert sha(data/'summary.json')==sha(old/'summary.json')
        reused.append(dict(clip=clip,sha256=source_hash,byte_identical=True,engine_rerun=False))
        records=[r for r in lines(data/'B_Integrated_diagnostics.jsonl') if r['displayed']]
    states=read(data/'B_Integrated_Space_Review_display.json')
    assert all(b['source_frame']>a['source_frame'] and b['seconds']>a['seconds'] for a,b in zip(records,records[1:]))
    assert len({(r.get('part_id',''),r['sample']) for r in records})==len(records)
    with (data/'B_Integrated_Space_Review_frame_map.csv').open(newline='') as f:mapping=list(csv.DictReader(f))
    observations=[(s,m) for s,m in zip(states,mapping) if m['kind']=='observation']
    assert len(states)==len(mapping)==report['frames'] and len(observations)==len(records)==report['observations']
    if clip=='SELF_L01':
        assert [s['observation_ordinal'] for s,m in observations]==list(range(1,8306))
        assert all(s['observation_ordinal'] is None for s,m in zip(states,mapping) if m['kind']=='gap')
    for r,(s,m) in zip(records,observations):
        assert (r['sample'],r['source_frame'])==(int(m['sample']),int(m['source_frame']))
        assert abs(r['seconds']-float(m['source_seconds']))<1e-8
        assert (r['hit'],r['episode']['display'],r['episode']['event'],r['observable'])==(s['hit'],s['display'],s['event'],s['observable'])
        expected='RED' if r['hit'] else 'YELLOW' if r['episode']['display'] else 'BLUE'
        assert s['state']==expected
        assert s['group_states']==[dict(id=g['id'],state=g['state']) for g in r['groups']]
        by={p['token']:p for p in r['pairs']}
        for g in r['groups']:
            if g['state']=='RED_CURRENT':assert g['contributing_pairs'] and all(by[k]['hit'] for k in g['contributing_pairs'])
            if g['state']=='YELLOW_HOLD':
                assert g['held_pairs'] and all(not by[k]['hit'] for k in g['held_pairs'])
                assert all(0<=r['seconds']-ts<=1.0+1e-8 for ts in g['hold_last_hit'].values())
    total_obs+=len(records);total_frames+=report['frames']
    audit=dict(clip=clip,status='PASS',observations=len(records),video_frames=report['frames'],
        hit_observations=sum(r['hit'] for r in records),new_test_events=sum(r['episode']['event'] for r in records),
        display_hold_observations=sum(r['episode']['display'] and not r['hit'] for r in records),
        insufficient_observations=sum(not r['observable'] for r in records),
        classification_metrics='NOT_COMPUTED',source_reused=clip!='SELF_L01')
    audits.append(audit)
    videos.append(dict(input=clip,path=str(video.relative_to(dest)),observations=len(records),video_frames=report['frames'],
        first_source_frame=records[0]['source_frame'],last_source_frame=records[-1]['source_frame'],
        first_pts=records[0]['seconds'],last_pts=records[-1]['seconds'],sha256=report['sha256']))
assert len(videos)==29 and len(reused)==28
assert total_obs==19425 and total_frames==19426
(validation/'technical_audit.json').write_text(json.dumps(dict(status='PASS',inputs=29,
    observations=total_obs,video_frames=total_frames,gap_slates=1,legacy_reused=reused,per_input=audits,
    cross_input_accuracy_aggregation=False,diagnostic_calculations='L01 parts only'),indent=2))
with (dest/'영상목록.csv').open('w',encoding='utf-8-sig',newline='') as f:
    w=csv.DictWriter(f,fieldnames=videos[0]);w.writeheader();w.writerows(videos)
sources=[SOURCE/'candidates.json']+sorted((SOURCE/'helpers').glob('*.py'))
(metadata/'frozen_source_fingerprints.json').write_text(json.dumps({str(p):sha(p) for p in sources},indent=2))
for part in plan['parts']:
    pid=part.removeprefix('SELF_');root=Path('/evidence/2026-10-29_contextra-self-filming-l01-pose-cache_r01')
    maps=metadata/'L01_observation_maps';maps.mkdir(exist_ok=True)
    shutil.copy2(root/'metadata/observation_maps'/f'{pid}.csv',maps/f'{pid}.csv')
    shutil.copy2(root/'metadata/cache_identities'/f'{pid}.json',maps/f'{pid}_identity.json')
for name in ('catalog.json','locations.json','action_links.json'):
    shutil.copy2(Path('/evidence/2026-10-29_contextra-self-filming-l01-pose-cache_r01/metadata')/name,metadata/('L01_'+name))
readme='''싸움 통합 후보 공간 검토: 추가 28개 + 연속본 L01, 총29개

1. 입력별 B_Integrated_Space_Review.mp4를 보세요. 기존 대표9개는 이전 폴더에 그대로 있습니다.
2. 청색은 공간 후보, 빨강은 현재 최종 조건 충족, 노랑은 마지막 충족 뒤 최대1초 표시 유지입니다.
   노랑은 현재 조건 충족이 아닙니다. 새 시험 Event와 빨간 관측은 다르며 쿨다운 중에도 빨강은 표시됩니다.
3. bbox와 skeleton은 입력 참고입니다. 특정 사람의 싸움 확정 판정이 아닙니다.
   bbox>=0.25, 관절>=0.10, 좌표 유효 조건은 기존과 같습니다. T는 원래 ID, q는 관측별 미추적 칸입니다.
4. 현재 후보의 규칙/threshold/Event 정책은 바꾸지 않았습니다. 기존28개 판정은 byte-identical 재사용입니다.
   영상별 data에 상세 진단과 Source Frame/PTS/출력 Frame 대응을 보존했습니다.
5. SELF_L01은 기존 두 cache 파트의8305관측을 사용한 영상1개입니다. 파트 경계에서 모든 엔진 상태를 초기화했습니다.
   식별은(part_id,T)이며 원본 Frame과 실제 L01 PTS를 유지합니다. 파트 경계는 실제 Event 종료 판정이 아닙니다.
   선택 관측 간격1.057729초에서 마지막 관측을 정상0.1초 표시한 뒤 남은0.957729초는 중립 공백 안내입니다.
   실제8305관측+공백 안내1화면=8306영상 Frame입니다. 안내 화면은 엔진 입력/판정 분모에 포함하지 않습니다.
   실제 시간 간격을 보존한 VFR입니다. 플레이어의 평균FPS가 정확히10이 아니어도 배속·재샘플링한 것이 아닙니다.
6. L01과 개별 Cxx는 초기 상태/cache가 달라 같은 장면의 판정도 달라질 수 있습니다. 중첩을 독립 사례로 합산하지 마세요.
   사용자 anchor는 대략적인 참고이며 미설명 구간은 미지정입니다. C22/O20 감지 기대는 사용자 판단 대기입니다.
7. 05/10은 입력 한계 참고 사례입니다. 자료 부족을 정상 행동이나 검출 성공으로 해석하지 않습니다.
8. 피드백: 입력명 / Source Frame 또는 L01 part+Frame / 청색·빨강·노랑 관찰 / 감지·비감지 희망을 적어주세요.
9. 기술 검증은 판정/표시 대응, 시간축, 전체 디코딩, 전송SHA256과 표본 가독성입니다.
   정오탐·모델 우열·운영 실시간성은 평가하지 않았으며 운영 배포/실제 Event 전송도 하지 않았습니다.
'''
(dest/'00_README_ko.txt').write_text(readme,encoding='utf-8-sig')
manifest={str(p.relative_to(dest)):dict(sha256=sha(p),bytes=p.stat().st_size)
          for p in sorted(dest.rglob('*')) if p.is_file() and p.name!='SHA256_manifest.json'}
(dest/'SHA256_manifest.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2))
print('PACKAGE_PASS',len(videos),total_obs,total_frames,len(manifest),flush=True)
