import hashlib
import json
import math
import os
import statistics
from collections import Counter
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

ROOT = Path(__file__).resolve().parents[1]
BASE = Path('/data/codex-artifacts/contextra/action-model-eval/2026')
OLD = ROOT.parent / '2026-11-01_icerti-camera24-fight-observe_r01'
EVAL = BASE / '2026-10-26_contextra-camera24-eval_r01'
REVIEW = BASE / '2026-10-30_contextra-r5-review-12clips_r01'
DATA = REVIEW / 'Contextra_Fight_R5_Review_20261030/01_camera24_fight/data'
os.umask(0o077)


def read(path):
    return json.loads(path.read_text())


def kst(seconds):
    return datetime.fromtimestamp(seconds, ZoneInfo('Asia/Seoul')).isoformat(timespec='milliseconds')


def fight(obj):
    return any(x.get('scenario') == 'FIGHT' and x.get('state') == 'FIGHT'
               for x in obj.get('scenarioResults', []))


def quantile(values, q):
    ordered = sorted(values)
    position = (len(ordered) - 1) * q
    lo, hi = math.floor(position), math.ceil(position)
    return ordered[lo] * (hi - position) + ordered[hi] * (position - lo) if hi != lo else ordered[lo]


def numbers(values):
    return dict(N=len(values), mean=statistics.mean(values), p5=quantile(values, .05),
                p50=quantile(values, .50), p95=quantile(values, .95))


def pose_stats(frames):
    persons = [p for frame in frames for p in frame]
    groups = {'priority8': (5, 6, 7, 8, 9, 10, 11, 12), 'wrists2': (9, 10), 'ankles2': (15, 16)}
    result = dict(observations=len(frames), objects=numbers([len(f) for f in frames]),
                  person_score=numbers([p['score'] for p in persons]),
                  person_slots=len(persons), untracked_slots=sum(p['track_id'] is None for p in persons))
    result['untracked_percent'] = 100 * result['untracked_slots'] / len(persons)
    result['joints'] = {}
    for name, indices in groups.items():
        points = [p['keypoints'][i] for p in persons for i in indices]
        valid = [all(math.isfinite(v) for v in point) and
                 0 <= point[0] <= 1 and 0 <= point[1] <= 1 for point in points]
        result['joints'][name] = dict(slots=len(points),
            coordinate_invalid=sum(not v for v in valid),
            score_ge010_valid=sum(v and p[2] >= .1 for v, p in zip(valid, points)),
            score_ge030_valid=sum(v and p[2] >= .3 for v, p in zip(valid, points)))
    return result


before = read(ROOT / 'private/settings_before.json')
after = read(ROOT / 'private/settings_after.json')
monitor = read(ROOT / 'private/monitor.json')
assert before['settings'] == after['settings']
assert before['runtime']['runId'] == after['runtime']['runId']
assert before['container_evidence'] == after['container_evidence'] == monitor['container_evidence']
assert before['source_sha256'] == after['source_sha256'] == read(OLD / 'private/settings_before.json')['source_sha256']
assert before['settings']['configured']['fight']['joint_score'] == .1
assert before['settings']['effective']['fight']['joint_score'] == .1
assert before['settings']['restart_pending'] is False
assert 30 <= monitor['elapsed_seconds'] <= 60
snapshots = [f['snapshot'] for f in monitor['frames']]
assert len({(x['runId'], x['runGeneration'], x['configVersion']) for x in snapshots}) == 1
assert all(b['frameSeq'] > a['frameSeq'] and b['ptsNs'] > a['ptsNs'] for a, b in zip(snapshots, snapshots[1:]))
assert all(x['status']['state'] == 'RUNNING' for x in monitor['statuses'] if x['status'])
assert all((p['color'].upper() == '#FF3030') == fight(p) for s in snapshots for p in s['objects'])
live_persons = [[dict(score=p['confidence'], track_id=p['trackId'], bbox=p['bbox'], keypoints=p['keypoints'])
                 for p in s['objects'] if p['confidence'] >= .25] for s in snapshots]

pose_path = EVAL / 'reports/pose_cache_copy.json'
spec = read(REVIEW / 'specs.json')['01_camera24_fight']
assert hashlib.sha256(pose_path.read_bytes()).hexdigest() == spec['cache_sha256']
all_pose = read(pose_path)
pose = [x for x in all_pose if x['sample'] in spec['selected_samples']]
offline_persons = [[dict(score=p['score'], track_id=p['track_id'],
    bbox=[v / (r['width'] if i % 2 == 0 else r['height']) for i, v in enumerate(p['bbox'])],
    keypoints=[[x/r['width'], y/r['height'], s] for x, y, s in p['keypoints']])
    for p in r['persons'] if p['score'] >= .25] for r in pose]
trace_path = DATA / 'R5_diagnostics.jsonl'
validation = read(DATA / 'replay_validation.json')
assert hashlib.sha256(trace_path.read_bytes()).hexdigest() == validation['trace_sha256']
trace_all = [json.loads(line) for line in trace_path.read_text().splitlines()]
trace = [r for r in trace_all if r['displayed']]
assert len(trace) == len(pose) == 201
assert [(r['sample'], r['source_frame'], r['seconds']) for r in trace] == [(r['sample'], r['source_frame'], r['seconds']) for r in pose]
offline_display = [any(g['state'] in ('RED_CURRENT', 'YELLOW_HOLD') for g in r['groups']) for r in trace]
live_display = [any(fight(p) for p in s['objects']) for s in snapshots]
pts_gaps = [(b['ptsNs']-a['ptsNs'])/1e9 for a,b in zip(snapshots,snapshots[1:])]
recent = read(ROOT / 'private/event_recent.json')['recent_events']
events = {e['eventId']: e for s in snapshots for e in s['events'] if e.get('scenario') == 'FIGHT'}

intervals = []
start = None
for i, flag in enumerate(live_display + [False]):
    if flag and start is None:
        start = i
    if not flag and start is not None:
        end = i-1
        intervals.append(dict(first_frame_seq=snapshots[start]['frameSeq'], last_frame_seq=snapshots[end]['frameSeq'],
            first_pts_ns=snapshots[start]['ptsNs'], last_pts_ns=snapshots[end]['ptsNs'], observations=end-start+1,
            first_observed_kst=kst(monitor['frames'][start]['observed_at_ms']/1000),
            last_observed_kst=kst(monitor['frames'][end]['observed_at_ms']/1000),
            left_censored=start == 0, right_censored=end == len(snapshots)-1))
        start = None

result = dict(
    schema_version='camera24.live-offline-comparison.v1',
    comparison_scope='Non-paired descriptive comparison. No scene/time alignment established. No engine replay.',
    effective_fight=before['settings']['effective']['fight'],
    live=dict(start_kst=kst(monitor['started_ms']/1000), end_kst=kst(monitor['ended_ms']/1000),
        duration_seconds=monitor['elapsed_seconds'], observations=len(snapshots),
        metadata_rate=len(snapshots)/monitor['elapsed_seconds'],
        pose=pose_stats(live_persons), display_observations=sum(live_display),
        display_percent=100*sum(live_display)/len(live_display),
        current_hit_observations=None, events_in_window=list(events), intervals=intervals,
        pts_gap_seconds=numbers(pts_gaps), gaps_gt_015=sum(x>.15+1e-8 for x in pts_gaps),
        source_dimensions=sorted({(s['frameWidth'],s['frameHeight']) for s in snapshots}),
        run_id=snapshots[0]['runId'], run_generation=snapshots[0]['runGeneration'],
        recent_same_run_events=[dict(event_id=e['event_id'], created_kst=kst(e['created_at']),
            frame_seq=e['payload'].get('frameSeq'), pts_ns=e['payload'].get('ptsNs'),
            state=e['delivery_state'], artifact=e['artifact_state'], http_status=e['http_status'])
            for e in recent if e['payload'].get('runId')==snapshots[0]['runId']],
        tracking=before['settings']['effective']['tracking'], read_errors=monitor['read_errors']),
    offline=dict(observations=len(trace), source_frame_range=[trace[0]['source_frame'],trace[-1]['source_frame']],
        source_seconds=[trace[0]['seconds'],trace[-1]['seconds']],
        pose=pose_stats(offline_persons), current_hit_observations=sum(r['hit'] for r in trace),
        current_hit_percent=100*sum(r['hit'] for r in trace)/len(trace),
        active_group_display_observations=sum(offline_display),
        active_group_display_percent=100*sum(offline_display)/len(trace),
        global_episode_display_observations=sum(r['episode']['display'] for r in trace),
        events_in_shown_range=[r['source_frame'] for r in trace if r['episode']['event']],
        events_in_preparation=[r['source_frame'] for r in trace_all if not r['displayed'] and r['episode']['event']],
        pose_extraction=read(EVAL / 'reports/pose_summary.json')),
    previous_joint030_observation=read(OLD / 'shared/summary.json'),
    verified=dict(settings_stable=True, configured_effective_joint010=True, same_run=True,
        source_hashes_unchanged=True, container_identities_unchanged=True, pts_strict=True,
        color_state_mismatches=0, offline_pose_and_trace_hashes_match=True),
    limitations=[
        'No common source-frame or PTS mapping between live RTSP and old offline recording.',
        'Published live FIGHT state includes hold, not the current rule decision trace.',
        'Offline active group display and live contributor bbox display are different presentation units.',
        'Model provider name matches, but CPU resize versus live GPU preprocessing is not bit-equivalent evidence.',
        'No inference, rule replay, runtime patch or parameter adjustment performed.',
        'An unbounded recent-event SELECT exceeded its 2.5-second timeout; a bounded 10-minute read-only query succeeded.',
    ])
(ROOT / 'shared/comparison.json').write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n')
(ROOT / 'shared/camera24_metadata_45s_j010.json').write_text(json.dumps({
    k:monitor[k] for k in ('started_ms','ended_ms','elapsed_seconds','frames')}, ensure_ascii=False)+'\n')
print(json.dumps({k:result[k] for k in ('live','offline','verified')}, ensure_ascii=False, indent=2))
