"""Join existing user annotations and clip maps to verified cache, without scoring events."""
import collections
import csv
import datetime
import hashlib
import json
from pathlib import Path
import shutil
import sys

from control import ROOT, PACKAGE, REMOTE, BASELINE, digest, save
from cases import CASES

TOPIC=Path('/workspace/contextra-analytics-runtime-lifecycle-refactor/docs/profiles/ltb1021/topics/runtime-lifecycle')
OBS=TOPIC/'analysis/20261029T125858+0900-analysis-self-filming-original01-user-observations.md'
READER=ROOT.parent/'2026-10-28_contextra-fight-ab-6clips_r01/helpers'

def build():
    specs=json.loads((ROOT/'specs.json').read_text())
    manifest=json.loads((PACKAGE/'metadata/manifest.json').read_text())
    original_links=json.loads((PACKAGE/'metadata/observation_clip_links.json').read_text())
    assert digest(OBS)==manifest['observations_sha256']
    meta=ROOT/'metadata';meta.mkdir(exist_ok=True)
    snapshot=meta/'user_observations.md'
    assert not snapshot.exists(),'Completed catalog must not be overwritten'
    shutil.copy2(OBS,snapshot)
    (meta/'observation_maps').mkdir(exist_ok=True)
    reference=json.loads((ROOT/'model_reference.json').read_text())
    helper_hashes={p.name:digest(p) for p in (ROOT/'helpers').glob('*.py')}
    assert all(digest(ROOT/'helpers'/n)==digest(BASELINE/'helpers'/n) for n in ('pose.py','sampling.py','plan.py','verify_cache.py'))
    clips=[];maps={};selected={};total=0
    for key,s in specs.items():
        clip=ROOT/'clips'/key
        check=json.loads((clip/'reports/cache_validation.json').read_text())
        extra=json.loads((clip/'reports/compatibility.json').read_text())
        rows=json.loads((clip/'inputs/pose.json').read_text())
        assert check['status']==extra['status']=='PASS'
        assert len(rows)==s['expected_samples']
        assert digest(clip/'inputs/pose.json')==check['cache_sha256']
        with (PACKAGE/'metadata/frame_maps'/f'{key}.csv').open(encoding='utf-8-sig',newline='') as f:
            full=list(csv.DictReader(f))
        maps[key]={int(r['review_frame_1based']):r for r in full}
        joined=[]
        for r in rows:
            source=full[r['source_frame']-1]
            assert int(source['clip_frame_1based'])==r['source_frame']
            assert abs(float(source['clip_pts_seconds'])-r['seconds'])<1e-6
            joined.append(dict(sample=r['sample'],target_seconds=r['target_seconds'],**source))
        selected[key]=joined
        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)
        hashes=json.loads((clip/'reports/cache_hashes.json').read_text())['files']
        for rel,sha in hashes.items():
            if not rel.startswith('inputs/frames/'):assert digest(clip/rel)==sha
        adapter=ROOT/'main_reader'/key/'data';adapter.mkdir(parents=True,exist_ok=True)
        shutil.copy2(clip/'reports/cache_validation.json',adapter/'cache_validation.json')
        rgb={Path(rel).name:sha for rel,sha 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'],model_image=reference['image'],engine_sha256=reference['pose']['engine_sha256'],
            source_files=reference['pose']['source_files'],helpers={n:helper_hashes[n] for n in ('pose.py','sampling.py','plan.py')},
            preprocess=reference['pose']['preprocess'],tracking=reference['pose']['tracking'],
            tracking_lifecycle='fresh ByteTracker for every clip; no cross-clip ID meaning',
            parser_threshold=.1,nms_iou=.85,derived_person_threshold=.15,legacy_valid_joints=.3,
            sampling_plan_sha256=digest(clip/'inputs/sampling_plan.json'))
        fingerprint=hashlib.sha256(json.dumps(identity,sort_keys=True,separators=(',',':')).encode()).hexdigest()
        save(clip/'reports/cache_identity.json',dict(identity=identity,sha256=fingerprint))
        c=s['source_clip']
        clips.append(dict(clip_id=key,name=s['name'],status='PASS',observations=len(rows),
            video=dict(root='clip_package',path=c['output'],sha256=s['sha256']),
            full_frame_map=dict(root='clip_package',path=f'metadata/frame_maps/{key}.csv',sha256=digest(PACKAGE/'metadata/frame_maps'/f'{key}.csv')),
            observation_map=f'metadata/observation_maps/{key}.csv',cache=f'clips/{key}',cache_identity_sha256=fingerprint,
            original_observation_ids=c['intersected_observation_ids'],core_review_range=[c['core_start'],c['core_end']],
            extracted_review_range=[c['start'],c['end']],
            cache_storage=dict(local='pose/prefilter/sampling/reports present; RGB remote only',remote='complete including RGB'),
            reader_validation=f'main_reader/{key}/data/cache_validation.json',reader_rgb_hashes=f'main_reader/{key}/data/input_rgb_sha256.json',
            source_frame_semantics='input clip 1-based; NOT review frame',case_ids=[]))
        total+=len(rows)
    assert total==7060 and len(clips)==22
    cases=[];counts=collections.Counter()
    for oid,start,end,name,fight,priority in CASES:
        counts[oid]+=1;case_id=f'SELF01-{oid}-A{counts[oid]:02d}'
        links=[]
        for c in clips:
            key=c['clip_id'];frame_map=maps[key]
            lo=max(start,min(frame_map));hi=min(end,max(frame_map))
            if lo>hi:continue
            a,b=frame_map[lo],frame_map[hi]
            points=selected[key]
            between=[p['sample'] for p in points if lo<=int(p['review_frame_1based'])<=hi]
            near=[]
            for anchor in (start,end):
                if anchor not in frame_map:continue
                pts=float(frame_map[anchor]['clip_pts_seconds'])
                chosen=min(points,key=lambda p:(abs(float(p['clip_pts_seconds'])-pts),p['sample']))
                near.append(dict(anchor_review_frame=anchor,sample=chosen['sample'],
                    selected_review_frame=int(chosen['review_frame_1based']),selected_clip_frame=int(chosen['clip_frame_1based']),
                    absolute_time_error_seconds=abs(float(chosen['clip_pts_seconds'])-pts)))
            links.append(dict(clip_id=key,anchor_overlap_review_range=[lo,hi],clip_frame_range=[int(a['clip_frame_1based']),int(b['clip_frame_1based'])],
                clip_pts_range=[float(a['clip_pts_seconds']),float(b['clip_pts_seconds'])],
                samples_between_approximate_anchors=between,nearest_anchor_observations=near,
                context_only=hi<c['core_review_range'][0] or lo>c['core_review_range'][1]))
            c['case_ids'].append(case_id)
        assert links,case_id
        cases.append(dict(case_id=case_id,observation_id=oid,name=name,review_anchor_range=[start,end],
            boundary_precision='approximate user narrative; NOT exact GT',single_anchor=start==end,
            fight_expectation=fight,fall_expectation='non_event_reference',priority=priority,mandatory=False,
            annotation_source='metadata/user_observations.md; parent prose and P1-P10 corrections take precedence',links=links))
    assert set(counts)=={f'O{i:02d}' for i in range(1,21)}
    assert next(c for c in cases if c['observation_id']=='O20')['fight_expectation']=='USER_JUDGMENT_PENDING'
    save(meta/'action_cases.json',dict(schema='contextra.self-filming-action-cases.v1',cases=cases,
        rules=['User narrative only; a/b never mapped to T/q','Approximate anchors and selected samples are navigation, not truth labels',
               'Duplicate review frames and overlapping context are not independent cases','No mandatory acceptance; high priority remains a preference',
               'Unspecified portions retain parent reference guidance, not precise frame negatives']))
    save(meta/'observation_clip_links.json',original_links)
    save(meta/'catalog.json',dict(schema='contextra.pose-cache-catalog.v1',clips=clips,observations=total,action_cases='action_cases.json',
        deferred=[dict(clip_id='L01',status='DEFERRED',reason='PTS gap causes duplicate nearest-frame selection; sampler policy unchanged',
                       video=next(c['output'] for c in manifest['clips'] if c['id']=='L01'),root='clip_package')],
        reset_contract='Each clip Tracking starts fresh. Future short-clip engine state should start fresh; engine NOT executed here.',
        source_observation_sha256=digest(snapshot),source_package_sha256_manifest=digest(PACKAGE/'SHA256SUMS.json'),helper_sha256=helper_hashes,
        main_reader_source_sha256={n:digest(READER/n) for n in ('evaluate.py','features.py','render.py')}))
    save(meta/'locations.json',dict(local_run=str(ROOT),remote_run=REMOTE,clip_package=str(PACKAGE),
        remote_sources='sources/{clip_id}.mp4',remote_cache='clips/{clip_id}',local_rgb_present=False))
    lines=['# 자체촬영 원본01 Pose cache 통합 목록','',
        '- C01~C22: 22개 / 7060관측. Event·엔진 실행 없이 기술 검증만 수행.',
        '- JSON: `metadata/catalog.json`, 행동별 목록: `metadata/action_cases.json`, 실제 경로: `metadata/locations.json`.',
        '- 원시 점수와 prefilter는 local/remote, RGB PNG는 remote에 보존. local에 RGB가 있다고 가정하지 않는다.',
        '- `source_frame`은 클립 내부 번호다. 기존 사용자 검토 번호는 `metadata/observation_maps/Cxx.csv`로 찾는다.',
        '- L01은 PTS 간격과 기존 sampler의 중복 선택 방지로 보류. 영상 손상으로 단정하지 않는다.',
        '- 일부 미설명 긴 구간은 L01에만 있다. 이번 cache가 원본 전체를 덮는다는 뜻은 아니며 `metadata/observation_clip_links.json`의 continuous_only_ranges를 확인한다.',
        '- bbox 0.25/0.20·관절 0.10/0.05는 후속 비교 기준이며 cache를 그 값으로 잘라내지 않았다.',
        '- 기존 `valid_joints`는 score>=0.3 집계. 후속 기준에는 원래 17관절 score를 사용한다.',
        '- a/b 묘사는 사람 눈 기준이며 실제 T/q 정답이 아니다. 인물 보정은 `metadata/user_observations.md` 참조.',
        '- 사례 anchor는 대략적이다. 가까운 관측과 구간 안 관측은 탐색용이며 정답 범위가 아니다.',
        '- 고우선도도 필수 수용이 아니다. O20/C22 싸움은 사용자 판단 대기, 쓰러짐은 비감지 참고.',
        '- 메인 reader의 JSON/hash/selected_samples 계약 검증만 통과. 엔진 실행·renderer 준비 완료를 의미하지 않는다.',
        '- 기존 renderer의 `identity_palette.json` 같은 표시용 자료는 후속 시각화 작업에서 별도 준비한다.',
        '', '| 클립 | 이름 | 관측 수 | 원본 검토 범위 | cache |','| --- | --- | ---: | --- | --- |']
    for c in clips:lines.append(f"| {c['clip_id']} | {c['name']} | {c['observations']} | {c['extracted_review_range']} | `{c['cache']}` |")
    lines+=['','## 행동별 사용자 참고 목록','',
        '같은 관찰을 여러 행동으로 나눈 탐색 목록이다. context가 겹쳐도 독립 검증 자료 수로 합산하지 않는다.',
        '', '| 사례 | 대략적 검토 Frame | 행동 | 싸움 기대 | 우선도 | 연결 클립 |','| --- | --- | --- | --- | --- | --- |']
    labels={'non_event':'비감지 참고','event_desired':'감지 희망','USER_JUDGMENT_PENDING':'사용자 판단 대기'}
    for c in cases:lines.append(f"| {c['case_id']} | {c['review_anchor_range']} | {c['name']} | {labels[c['fight_expectation']]} | {c['priority']} | {', '.join(x['clip_id'] for x in c['links'])} |")
    (ROOT/'00_README_ko.md').write_text('\n'.join(lines)+'\n')
    package_intact=all(digest(PACKAGE/p)==v['sha256'] for p,v in json.loads((PACKAGE/'SHA256SUMS.json').read_text())['files'].items())
    assert package_intact,'Original clip package changed; do not publish catalog'
    save(ROOT/'reports/catalog_validation.json',dict(status='PASS',clips=len(clips),observations=total,action_cases=len(cases),
        observation_ids=len(counts),all_case_links_resolve=True,expected_scope='C01-C22; L01 excluded',no_event_evaluation=True,
        source_package_verified=package_intact))
    print(json.dumps(dict(clips=len(clips),observations=total,action_cases=len(cases))),flush=True)

if __name__=='__main__':build()
