"""Additive L01 entry point; existing clip/case catalogs stay immutable."""
import csv
import hashlib
import json
from pathlib import Path
import shutil
from run_l01 import ROOT, BASELINE, PACKAGE, REMOTE, digest, save

def build():
    assert not (ROOT/'metadata/catalog.json').exists(),'Completed catalog protected'
    specs=json.loads((ROOT/'specs.json').read_text())
    old=json.loads((BASELINE/'metadata/catalog.json').read_text())
    cases=json.loads((BASELINE/'metadata/action_cases.json').read_text())['cases']
    source_identity=json.loads((ROOT/'reports/source_fingerprints.json').read_text())
    assert digest(BASELINE/'metadata/catalog.json')==source_identity['baseline_catalog_sha256']
    assert digest(BASELINE/'manifest.json')==source_identity['baseline_manifest_sha256']
    assert digest(BASELINE/'metadata/user_observations.md')==source_identity['observations_sha256']
    with (PACKAGE/'metadata/frame_maps/L01.csv').open(encoding='utf-8-sig',newline='') as f:
        original=list(csv.DictReader(f))
    assert len(original)==19975
    assert digest(PACKAGE/'metadata/frame_maps/L01.csv')==source_identity['frame_map_sha256']
    assert digest(Path(specs['L01_P01']['local_source']))==source_identity['input_sha256']
    meta=ROOT/'metadata';(meta/'observation_maps').mkdir(parents=True,exist_ok=True)
    parts=[];all_rows={};joined_rows={};total=0
    ref=json.loads((ROOT/'model_reference.json').read_text())
    for key,s in specs.items():
        cache=ROOT/'clips'/key
        check=json.loads((cache/'reports/cache_validation.json').read_text())
        extra=json.loads((cache/'reports/compatibility.json').read_text())
        plan=json.loads((cache/'inputs/sampling_plan.json').read_text())
        rows=json.loads((cache/'inputs/pose.json').read_text())
        hashes=json.loads((cache/'reports/cache_hashes.json').read_text())['files']
        assert check['status']==extra['status']=='PASS'
        assert len(rows)==s['expected_samples']==len(plan['target_seconds'])
        assert [r['sample'] for r in rows]==list(range(len(rows)))
        lo,hi=s['display_bounds'];origin=plan['part_origin_seconds']
        assert all(lo<=r['source_frame']<=hi for r in rows)
        assert rows[0]['source_frame']==lo and abs(rows[0]['seconds']-origin)<1e-6
        joined=[]
        for r in rows:
            source=original[r['source_frame']-1]
            assert int(source['clip_frame_1based'])==r['source_frame']
            assert abs(float(source['clip_pts_seconds'])-r['seconds'])<1e-6
            assert abs(r['target_seconds']-(origin+r['sample']/10))<1e-7
            joined.append(dict(part_id=key,sample=r['sample'],target_seconds=r['target_seconds'],
                part_elapsed_seconds=r['seconds']-origin,part_target_seconds=r['target_seconds']-origin,
                part_decoded_frame_1based=r['source_frame']-lo+1,**source))
        with (meta/'observation_maps'/f'{key}.csv').open('w',encoding='utf-8-sig',newline='') as f:
            w=csv.DictWriter(f,fieldnames=list(joined[0]));w.writeheader();w.writerows(joined)
        for rel,h in hashes.items():
            if not rel.startswith('inputs/frames/'):assert digest(cache/rel)==h,rel
        adapter=ROOT/'main_reader'/key/'data';adapter.mkdir(parents=True,exist_ok=True)
        shutil.copy2(cache/'reports/cache_validation.json',adapter/'cache_validation.json')
        rgb={Path(rel).name:h for rel,h in hashes.items() if rel.startswith('inputs/frames/')}
        assert len(rgb)==len(rows)
        save(adapter/'input_rgb_sha256.json',rgb)
        identity=dict(input_sha256=s['sha256'],part_id=key,input_bounds=s['display_bounds'],
            image=ref['image'],engine_sha256=ref['pose']['engine_sha256'],source_files=ref['pose']['source_files'],
            tracking=ref['pose']['tracking'],tracking_reset='new process and new ByteTracker per part',
            tracking_identity=['part_id','track_id'],preprocess=ref['pose']['preprocess'],
            parser_threshold=.1,nms_iou=.85,derived_person_threshold=.15,legacy_valid_joints_threshold=.3,
            sampling_plan_sha256=digest(cache/'inputs/sampling_plan.json'),
            pose_helper_sha256=digest(ROOT/'helpers/pose.py'),
            part_plan_helper_sha256=digest(ROOT/'helpers'/('part_plan_attempt01.py' if key=='L01_P01' else 'part_plan.py')),
            P01_selection_preserved_and_equivalent_to_updated_planner=True)
        identity_sha=hashlib.sha256(json.dumps(identity,sort_keys=True,separators=(',',':')).encode()).hexdigest()
        save(meta/'cache_identities'/f'{key}.json',dict(identity=identity,sha256=identity_sha))
        parts.append(dict(part_id=key,status='PASS',source_frame_bounds=[lo,hi],
            review_frame_bounds=[int(original[lo-1]['review_frame_1based']),int(original[hi-1]['review_frame_1based'])],
            observations=len(rows),cache=f'clips/{key}',identity=f'metadata/cache_identities/{key}.json',identity_sha256=identity_sha,
            observation_map=f'metadata/observation_maps/{key}.csv',source_frame='L01 input 1-based',seconds='actual L01-relative PTS',
            sample='part-local 0-based',part_origin_seconds=origin,
            first_selected_L01_frame=rows[0]['source_frame'],last_selected_L01_frame=rows[-1]['source_frame'],
            first_selected_L01_pts=rows[0]['seconds'],last_selected_L01_pts=rows[-1]['seconds'],
            storage=dict(local='Pose/Tracking/prefilter/maps/reports; no RGB',remote='complete including RGB'),
            reader_data=f'main_reader/{key}/data',case_ids=[]))
        all_rows[key]=rows;joined_rows[key]=joined;total+=len(rows)
    assert total==8305
    gap=dict(source_before=dict(L01_frame=2542,review_frame=14504,seconds=float(original[2541]['clip_pts_seconds'])),
             source_after=dict(L01_frame=2543,review_frame=14505,seconds=float(original[2542]['clip_pts_seconds'])),
             source_gap_seconds=float(original[2542]['clip_pts_seconds'])-float(original[2541]['clip_pts_seconds']),
             observed_before=dict(part_id='L01_P01',L01_frame=all_rows['L01_P01'][-1]['source_frame'],seconds=all_rows['L01_P01'][-1]['seconds']),
             observed_after=dict(part_id='L01_P02',L01_frame=all_rows['L01_P02'][0]['source_frame'],seconds=all_rows['L01_P02'][0]['seconds']),
             observed_gap_seconds=all_rows['L01_P02'][0]['seconds']-all_rows['L01_P01'][-1]['seconds'],
             cross_part_tracking_linked=False,synthetic_observations=False,engine_reset_implemented=False)
    assert abs(gap['source_gap_seconds']-1.016668)<1e-6
    assert abs(gap['observed_gap_seconds']-1.057729)<1e-6
    save(meta/'gap.json',gap)
    action_links=[];outside=[]
    for case in cases:
        start,end=case['review_anchor_range'];links=[]
        for p in parts:
            key=p['part_id'];lo=max(start,p['review_frame_bounds'][0]);hi=min(end,p['review_frame_bounds'][1])
            if lo>hi:continue
            source_by_review={int(x['review_frame_1based']):x for x in original[specs[key]['display_bounds'][0]-1:specs[key]['display_bounds'][1]]}
            a,b=source_by_review[lo],source_by_review[hi]
            selected=joined_rows[key]
            between=[x['sample'] for x in selected if lo<=int(x['review_frame_1based'])<=hi]
            near=[]
            for anchor in (start,end):
                if anchor not in source_by_review:continue
                seconds=float(source_by_review[anchor]['clip_pts_seconds'])
                x=min(selected,key=lambda r:(abs(float(r['clip_pts_seconds'])-seconds),r['sample']))
                near.append(dict(anchor_review_frame=anchor,sample=x['sample'],L01_frame=int(x['clip_frame_1based']),
                    selected_review_frame=int(x['review_frame_1based']),absolute_time_error_seconds=abs(float(x['clip_pts_seconds'])-seconds)))
            links.append(dict(part_id=key,review_anchor_overlap=[lo,hi],L01_frame_anchor_overlap=[int(a['clip_frame_1based']),int(b['clip_frame_1based'])],
                L01_pts_anchor_overlap=[float(a['clip_pts_seconds']),float(b['clip_pts_seconds'])],
                samples_between_approximate_anchors=between,nearest_anchor_observations=near))
            p['case_ids'].append(case['case_id'])
        if not links:
            outside.append(case['case_id']);continue
        action_links.append(dict(case_id=case['case_id'],observation_id=case['observation_id'],name=case['name'],
            review_anchor_range=case['review_anchor_range'],boundary_precision=case['boundary_precision'],
            fight_expectation=case['fight_expectation'],fall_expectation=case['fall_expectation'],priority=case['priority'],
            mandatory=case['mandatory'],links=links,source_catalog='baseline:metadata/action_cases.json'))
    assert len(action_links)==57 and len(outside)==2
    assert next(c for c in action_links if c['observation_id']=='O20')['fight_expectation']=='USER_JUDGMENT_PENDING'
    save(meta/'action_links.json',dict(schema='contextra.l01-action-links.v1',cases=action_links,outside_L01=outside,
        source_sha256=digest(BASELINE/'metadata/action_cases.json'),annotation_source='baseline:metadata/user_observations.md',
        precision='user narrative anchors only; no frame GT or model/Event evaluation'))
    save(meta/'locations.json',dict(local_run=str(ROOT),remote_run=REMOTE,baseline_local=str(BASELINE),
        baseline_remote='/storage/contextra/experiments/action-model-eval/2026/'+BASELINE.name,
        clip_package_local=str(PACKAGE),L01_remote_source=REMOTE+'/sources/L01.mp4',RGB_local_present=False))
    save(meta/'catalog.json',dict(schema='contextra.self-filming-unified-catalog.v2',
        existing_clips=dict(root='baseline',catalog='metadata/catalog.json',sha256=digest(BASELINE/'metadata/catalog.json'),count=22,
            entries=[dict(clip_id=c['clip_id'],root='baseline',cache=c['cache'],observations=c['observations']) for c in old['clips']]),
        L01=dict(status='READY_PARTITIONED',input_sha256=source_identity['input_sha256'],input_root='clip_package_local',
            input_path=next(c['output'] for c in json.loads((PACKAGE/'metadata/manifest.json').read_text())['clips'] if c['id']=='L01'),
            full_frame_map='clip_package_local:metadata/frame_maps/L01.csv',frame_map_sha256=source_identity['frame_map_sha256'],
            observations=total,parts=parts,gap='gap.json',case_links='action_links.json'),
        rules=['Existing catalog DEFERRED entry is historical; this new L01 entry is the current readiness record',
               'Never concatenate part pose.json without preserving the part namespace and discontinuity',
               'Same source frames across Cxx/L01 are not independent accuracy cases',
               'Part tracking reset does not define operating Event termination or implement an engine reset']))
    helper_hashes={p.name:digest(p) for p in (ROOT/'helpers').glob('*.py')}
    assert digest(ROOT/'helpers/pose.py')==digest(BASELINE/'helpers/pose.py')
    save(meta/'helper_sha256.json',helper_hashes)
    text=['# L01 두 파트 Pose cache와 통합 진입점','',
        '- 기존 C01~C22는 재처리하지 않았다. 새 `metadata/catalog.json`에서 기존 목록을 참조한다.',
        '- L01 영상은 원본 하나 그대로다. cache만 `L01_P01`과 `L01_P02` 두 파트로 관리한다.',
        '- P01 1044관측 + P02 7261관측 = 8305관측. 실제 경로는 `metadata/locations.json` 참조.',
        '- RGB는 원격에만 있고, Pose/Tracking·prefilter·대응표·검증은 test에도 있다.',
        '- `source_frame`은 L01 전체의1-based Frame이다. `seconds`와 `target_seconds`도 L01 시간축이다.',
        '- `sample`만 파트마다0부터 시작한다. P02의 첫 `seconds`는105.352669초이며0으로 바꾸지 않는다.',
        '- **두 pose.json을 그대로 이어 붙이지 않는다.** T ID는 `(part_id, track_id)`로 구분하며 경계에서 추적을 새로 시작했다.',
        '- 원본 경계 간격1.016668초와 선택 관측 간격1.057729초는 다르다. 경계Frame을 강제 추가하거나 가짜 관측을 만들지 않았다.',
        '- `metadata/observation_maps`는 part/sample → L01 Frame/PTS → 기존 검토Frame/PTS → 원본PTS/packet 참조번호를 연결한다.',
        '- L01에 포함되는 사용자 행동 참고57개는 `metadata/action_links.json`에 있다. 기존 전체59개 중 O01/O02는 L01 밖이다.',
        '- a/b·인물 보정·대략적 anchor·우선도는 baseline 원문을 유지한다. O20 싸움 판단 대기와 쓰러짐 전체 비감지 참고도 유지한다.',
        '- 중첩 Cxx/L01 Frame을 독립 사례로 합산하지 않는다. 미설명 구간을 직접 분석하거나 정확한 GT로 만들지 않았다.',
        '- 기존 raw17관절 score와 prefilter를 보존했다. `valid_joints`는 기존0.3 집계이며 새 기준에는 raw score를 사용한다.',
        '- reader JSON/hash 계약만 확인했다. 엔진 reset·Event 종료 정책·renderer 실행·RTSP 송출은 구현하거나 시험하지 않았다.',
        '', '| 파트 | L01 범위 | 검토 범위 | 선택된 L01 Frame | 관측 | 첫 시각 |','| --- | --- | --- | --- | ---: | ---: |']
    for p in parts:text.append(f"| {p['part_id']} | {p['source_frame_bounds']} | {p['review_frame_bounds']} | {p['first_selected_L01_frame']}~{p['last_selected_L01_frame']} | {p['observations']} | {p['first_selected_L01_pts']:.6f} |")
    text+=['','## 사용자 행동 참고 연결','', '| 사례 | 대략적 검토 Frame | 내용 | 싸움 기대 | 연결 파트 |','| --- | --- | --- | --- | --- |']
    for c in action_links:text.append(f"| {c['case_id']} | {c['review_anchor_range']} | {c['name']} | {c['fight_expectation']} | {', '.join(x['part_id'] for x in c['links'])} |")
    (ROOT/'00_README_ko.md').write_text('\n'.join(text)+'\n')
    save(ROOT/'reports/catalog_validation.json',dict(status='PASS',parts=2,observations=total,action_links=57,
        existing_clips_referenced=22,baseline_preserved=True,input_and_map_preserved=True,
        source_gap_seconds=gap['source_gap_seconds'],observed_gap_seconds=gap['observed_gap_seconds']))
    print(json.dumps(dict(status='PASS',parts=2,observations=total,case_links=57)),flush=True)

if __name__=='__main__':build()
