from copy import deepcopy
from datetime import datetime, timedelta, timezone
from pathlib import Path
import hashlib
import html
import json
import re

ROOT = Path(__file__).parent
OUTPUT = Path('/workspace/architecture_docs/topics/contextra/20260928_crowd_web_contract.html')
BASE = Path('/workspace/contextra-analytics-runtime-lifecycle-refactor')
PARAMS = {
 'INTRUSION': dict(person_confidence=.5,dwell_seconds=.5,rearm_seconds=1.,grace_seconds=.5),
 'LOITERING': dict(person_confidence=.5,dwell_seconds=5.,rearm_seconds=1.,grace_seconds=1.),
 'FALL': dict(person_confidence=.15,pose_confidence=.1,down_angle_deg=50,recovery_hold_seconds=1.),
 'FIGHT': dict(person_confidence=.15,pose_confidence=.1,pair_distance_ratio=1.8,pair_close_hold_seconds=.1,arm_motion_ratio=.1,required_bursts=1),
 'FIRE': dict(flame_confidence=.5,flame_window_seconds=2.,flame_positive_ratio=.6,smoke_confidence=.5,smoke_window_seconds=2.,smoke_positive_ratio=.6,clear_seconds=5.),
 'CROWD': dict(person_confidence=.45,head_confidence=.45,window_seconds=3.,report_seconds=3.),
}
POLYGON = [[0.,0.],[1.,0.],[1.,1.],[0.,1.]]
START = int(datetime(2026,9,28,10,tzinfo=timezone(timedelta(hours=9))).timestamp()*1000)
before_start = dict(cameraId=101,recServerId='REC-DEMO',recCameraId='101',rtspUrl='rtsp://example.invalid/live/demo',
 startWallTimeMs=START,rtpClockRate=90000,scenarios=[dict(scenarioCode=k,enabled=True,
 rois=[dict(label='군중 영역',points=POLYGON)] if k=='CROWD' else [],parameters=v) for k,v in PARAMS.items()])
after_start = deepcopy(before_start)
for s in after_start['scenarios']:
 s['parameters']['cooldown_seconds']=10
 if s['scenarioCode']=='CROWD':
  s['parameters'].update(reference_count=20,safe_ratio=.25,caution_ratio=.5,warning_ratio=.75,danger_ratio=.9)
timing = dict(anchorId='anchor-demo',startWallTimeMs=START,anchorRtpTimestamp=0,rtpClockRate=90000,
 rtpTimestamp=900000,rtpTimestampExt=900000,detectedAtWallMs=START+10000,syncStatus='SYNCED',timeSource='RTP_ZERO_BASE')
frame = dict(schemaVersion='1',cameraId=101,sourceId='101',serverId='AI-DEMO',streamId='camera-demo',
 runId='run-demo',runGeneration=1,frameSeq=100,ptsNs=10000000000,ingestTimeWallMs=START+10020,
 frameWidth=1920,frameHeight=1080,coordinateSpace='SOURCE_FRAME',format='XYXY',normalized=True,origin='TOP_LEFT',
 configVersion='config-demo',frameTime=timing,providerKey='crowd',providerModel='Awiros_person_and_head_detection',requiredProviders=['pose','fire','crowd'])
objects=[]
for i,(cls,label,color,box) in enumerate([(0,'person | P≈12','#00CC66',[.1,.2,.2,.8]),(1,'head | H≈18','#00B8D9',[.12,.2,.17,.27])]):
 objects.append(dict(objectRef=str(i),classId=cls,modelKey='Awiros_person_and_head_detection',confidence=.9,bbox=box,
                     trackId=None,eventIds=[],labelText=label,color=color,visible=True))
before_redis=dict(frame,objects=objects,scenarioConfig={'CROWD':dict(enabled=True,roi_id='primary',roi_name='군중 영역',roi=POLYGON,debug_roi=True,confidence=.45)},scenarioRevision='scenario-demo',events=[])
REGION=dict(roiIndex=0,roiLabel='군중 영역',headCount=18,personCount=12,count=18,referenceCount=20,densityRatio=.9,status='DANGER')
after_redis=dict(deepcopy(before_redis),crowd=dict(regions=[deepcopy(REGION)]))

def event_body(crowd=False):
 scenario='CROWD' if crowd else 'INTRUSION'
 event_id='event-crowd-001' if crowd else 'event-intrusion-001'
 detail=dict(aiRecordedAtWallMs=START+10040,aiRecordedStage='EVENT_CONFIRMED_BEFORE_ENQUEUE',
  aiRecordedStageDescription='Event confirmed; AI reference clock sampled once before asynchronous enqueue.',
  scoreMeaning='TEMPORARY_FIXED',scoreMeasured=False,runId='run-demo',runGeneration=1,streamId='camera-demo',
  frameSeq=100,ptsNs=10000000000,frameTime=deepcopy(timing),trackId=None if crowd else 7,roiId='primary',
  artifact=dict(clip=True,thumbnail=True),artifactError=None,roiLabel='군중 영역' if crowd else '침입 영역')
 if crowd:detail['crowd']=deepcopy(REGION)
 directory=f'https://example.invalid/artifacts/cameras/101/2026-09-28/AI_100010.040+0900_{scenario}_{event_id}'
 return dict(serverId='AI-DEMO',events=[dict(eventId=event_id,cameraId=101,eventType=scenario,detectedAtWallMs=START+10000,
  score=.7,severityCode='DANGER' if crowd else 'WARNING',detail=detail,rtpTimestamp=900000,startWallTimeMs=START,
  clipUrl=directory+'/clip.mp4',thumbnailUrl=directory+'/thumbnail.jpg')])

SAMPLES={'start-before':before_start,'start-after':after_start,'redis-before':before_redis,'redis-after':after_redis,
         'event-before':event_body(),'event-after':event_body(True)}
NEW_KEYS={'cooldown_seconds','reference_count','safe_ratio','caution_ratio','warning_ratio','danger_ratio',
          'crowd','regions','roiIndex','roiLabel','headCount','personCount','count','referenceCount','densityRatio','status'}

def esc(s):return html.escape(str(s))
def code(ident, value, after=False):
 raw=json.dumps(value,ensure_ascii=False,indent=2)
 lines=[]
 for line in raw.splitlines():
  match=re.match(r'\s*"([^\"]+)":',line)
  changed=after and match and (match[1] in NEW_KEYS or ident=='event-after' and match[1] in {'eventType','severityCode','trackId'})
  lines.append('<span class="line'+(' added' if changed else '')+'">'+esc(line)+'</span>')
 return f'<pre tabindex="0" aria-label="{ident} JSON"><code>'+''.join(lines)+f'</code></pre><script type="application/json" id="{ident}">'+raw.replace('<','\u003c')+'</script>'

def pair(title,before,after,desc='',old_label='변경 전',new_label='변경 후 · 패치 적용 예정'):
 return f'<h3>{title}</h3><p>{desc}</p><div class="compare"><div><h4>{old_label}</h4>'+code(before,SAMPLES[before])+f'</div><div><h4>{new_label}</h4>'+code(after,SAMPLES[after],True)+'</div></div>'

CSS='''
*{box-sizing:border-box;letter-spacing:0}body{margin:0;background:#f1f4f5;color:#233139;font:14px/1.7 "Noto Sans CJK KR","Malgun Gothic",sans-serif;word-break:keep-all;overflow-wrap:anywhere}main{max-width:1180px;margin:auto;padding:35px 42px 45px;background:white}header{border-top:5px solid #25816b;padding-top:20px}h1{font-size:28px;line-height:1.4;margin:8px 0 12px}h2{font-size:21px;margin:0 0 12px}h3{font-size:17px;margin:18px 0 8px}h4{font-size:13px;margin:0;padding:9px 12px;background:#edf2f4;border-bottom:1px solid #d8e1e5}.muted{font-size:12px;color:#53656c}.badge{display:inline-block;font-size:12px;color:#246c5b;border:1px solid #bdd7cf;padding:2px 8px;border-radius:4px}nav{display:flex;gap:20px;flex-wrap:wrap;border-top:1px solid #dde5e8;border-bottom:1px solid #dde5e8;padding:14px 0;margin-top:20px}a{color:#246c8c;text-underline-offset:3px}section{padding:28px 0;border-bottom:1px solid #dce4e7;scroll-margin-top:16px}p{margin:9px 0 13px}ul{padding-left:21px}.summary{padding-left:16px;border-left:3px solid #25816b}.compare{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px}.compare>div{border:1px solid #d8e1e5;border-radius:5px;overflow:hidden;min-width:0}pre{margin:0;padding:12px 0;background:#fafcfd;font:12px/1.7 ui-monospace,Consolas,monospace;max-height:500px;overflow:auto;tab-size:2;white-space:pre}pre code{display:block;min-width:max-content}code{font-family:ui-monospace,Consolas,monospace}.line{display:block;padding:0 12px;min-height:20px}.line.added{background:#e6f4ec;border-left:3px solid #25816b;padding-left:9px;color:#145741}.table-wrap{overflow:auto;max-width:100%}table{width:100%;border-collapse:collapse;font-size:13px}th,td{border-bottom:1px solid #dce4e7;padding:8px 10px;text-align:left;vertical-align:top}th{background:#f1f5f6;white-space:nowrap}td code{white-space:nowrap}.decision{font-size:12px;color:#4d5f67;background:#f1f5f6;padding:11px 14px}.pill{display:inline-block;color:#145741;background:#e6f4ec;padding:1px 6px;font-size:12px}.footer{font-size:12px;color:#53656c;padding-top:20px}a:focus-visible,pre:focus-visible{outline:3px solid #be8439;outline-offset:3px}
@media(max-width:760px){main{padding:20px 16px}h1{font-size:24px}.compare{grid-template-columns:1fr}pre{max-height:400px}table{min-width:600px}h2{font-size:19px}}
@media print{@page{size:A4;margin:12mm}body{background:white;font-size:10px}main{padding:0;max-width:none}nav{display:none}h1{font-size:20px}h2{font-size:16px}h3{font-size:13px}h4{font-size:10px}.compare{grid-template-columns:1fr 1fr;gap:8px}.compare>div{overflow:visible}pre{max-height:none;overflow:visible;font-size:7px;line-height:1.6;white-space:pre-wrap}pre code{min-width:0;overflow-wrap:anywhere}.line{min-height:11px;padding:0 6px;white-space:pre-wrap;word-break:break-all}.line.added{padding-left:3px}table{min-width:0;font-size:9px}td code{white-space:normal;word-break:break-all}.table-wrap{overflow:visible}thead{display:table-header-group}tr{break-inside:avoid}h2,h3,h4{break-after:avoid}.decision,.muted,.footer{font-size:9px}section{padding:18px 0}}
'''
overview='''<div class="summary"><p><b>Start:</b> 6개 시나리오의 parameters에 cooldown_seconds를 추가하고, CROWD에 기준 인원과 status 비율5개를 추가한다.</p><p><b>Redis:</b> Crowd provider에 crowd.regions를 추가한다. Pose·Medium·Fire의 메타데이터 필드 변경은 없다.</p><p><b>Event:</b> DANGER 진입에 CROWD Event와 클립·썸네일을 생성한다. 다른 시나리오의 Event 포맷은 유지한다.</p></div><p class="muted">이 문서는 합의된 신규 계약과 Web 선개발용 예시다. AI 구현·배포 완료를 뜻하지 않는다. URI·ID·시각·인원은 예시값이다. 연녹색 줄은 주요 추가·변경 필드다.</p>'''
rules='''<div class="table-wrap"><table><thead><tr><th>항목</th><th>합의 내용</th></tr></thead><tbody>
<tr><td>인원 기준</td><td>headCount를 판정에 사용한다. personCount는 참고값이며 두 값을 더하지 않는다.</td></tr>
<tr><td>집계</td><td>기본 최근3초 평균을 정수로 반올림하여3초마다 갱신한다. count = headCount.</td></tr>
<tr><td>비율</td><td>densityRatio = count / referenceCount. 1을 초과해도 자르지 않는다. 면적당 인구밀도가 아니다.</td></tr>
<tr><td>ROI</td><td>현재 채널·시나리오당1개. rois[0]과 출력 roiIndex=0을 연결한다. 빈 rois는 전체 화면이다.</td></tr>
<tr><td>Event</td><td>DANGER 진입 시1건. 유지 중 반복 발행하지 않고, 위험 해제 후 재진입 시 새 후보가 된다. 쿨다운 중에도 Redis status는 전달한다.</td></tr>
</tbody></table></div>
<h3>Status 구간</h3><div class="table-wrap"><table><thead><tr><th>코드</th><th>조건</th><th>예시: 기준20명</th></tr></thead><tbody>
<tr><td>SAFE</td><td>ratio &lt; caution_ratio</td><td>0~9명</td></tr><tr><td>CAUTION</td><td>caution_ratio ≤ ratio &lt; warning_ratio</td><td>10~14명</td></tr><tr><td>WARNING</td><td>warning_ratio ≤ ratio &lt; danger_ratio</td><td>15~17명</td></tr><tr><td>DANGER</td><td>ratio ≥ danger_ratio</td><td>18명 이상</td></tr></tbody></table></div><p class="muted">예시 비율: SAFE0.25 / CAUTION0.5 / WARNING0.75 / DANGER0.9. safe_ratio는 보존하되 현재 계산에 사용하지 않는다. 색상은 Web이 status에 따라 정한다.</p>'''
fields='''<div class="table-wrap"><table><thead><tr><th>추가 parameters 필드</th><th>대상 / 형식</th><th>의미</th></tr></thead><tbody>
<tr><td><code>cooldown_seconds</code></td><td>6종 공통 / number, 0~300</td><td>카메라·시나리오별 새 Event 최소 간격. 미지정/null/무효는 서버 기본값(현재10초). 0은 간격 제한 해제이며 episode 중복 방지는 유지한다.</td></tr>
<tr><td><code>reference_count</code></td><td>CROWD / 양의 integer</td><td>비율 계산 분모.20은 예시값이다.</td></tr><tr><td><code>safe_ratio</code></td><td>CROWD / number, 0~1</td><td>수신·보존용. 현재 판정 미사용.</td></tr><tr><td><code>caution_ratio</code></td><td>CROWD / number, 0~1</td><td>CAUTION 시작 비율.</td></tr><tr><td><code>warning_ratio</code></td><td>CROWD / number, 0~1</td><td>WARNING 시작 비율.</td></tr><tr><td><code>danger_ratio</code></td><td>CROWD / number, 0~1</td><td>DANGER 시작 비율 및 Event 진입 기준.</td></tr></tbody></table></div>
<p class="decision">비율 순서: caution_ratio &lt; warning_ratio &lt; danger_ratio. 숫자 문자열·boolean·NaN은 유효한 숫자로 취급하지 않는다. 새 군중 설정이 없는 기존 요청은 P/H 표시를 유지하고 신규 군중 Event는 비활성이다. cooldown을 다음 Start에서 생략하면 이전 Web override를 이어 쓰지 않고 서버 기본값을 적용한다.</p>'''
start_section=fields+pair('Start body 전체 샘플','start-before','start-after','기존 WSS 메시지 envelope와 action은 유지한다. 아래는 Start body이며 scenarios에는6개 시나리오를 모두 보낸다. rois 형식은 그대로이고 새 입력은 모두 parameters 안에 있다.')
redis_section='''<p>공통 Redis Stream의 <code>json</code> 필드를 JSON으로 파싱한 내용이다. providerKey/providerModel/requiredProviders와 Frame·RTP 필드는 그대로 유지한다.</p>
<div class="table-wrap"><table><thead><tr><th>추가 경로</th><th>형식 / 의미</th></tr></thead><tbody>
<tr><td><code>crowd.regions</code></td><td>ROI 결과 배열. 현재 유효한 집계 결과1개를 담는다.</td></tr><tr><td><code>roiIndex / roiLabel</code></td><td>0-based ROI 순서 / 요청의 label.</td></tr><tr><td><code>headCount / personCount / count</code></td><td>integer. 기간 평균을 반올림한 수. count는 headCount와 같다.</td></tr><tr><td><code>referenceCount / densityRatio / status</code></td><td>기준 인원 / 실제 비율 / SAFE·CAUTION·WARNING·DANGER.</td></tr></tbody></table></div>
<p class="decision">Web은 crowd.regions[].status로 군중 표시색을 정한다. objects[].color는 기존 객체 기본색이다. 다른 provider의 결과도 독립 메시지로 소비하며, 같은 RTP 시각이라는 이유만으로 Crowd·Pose·Fire 중 하나를 버리지 않는다.</p>'''
redis_section+=pair('Crowd provider 메타데이터','redis-before','redis-after','objects는 구조 확인용 대표2개만 발췌했다. headCount18/personCount12는 기간 집계값이다. 아래는 DANGER 유지 프레임 예시로 events는 비어 있으며, 신규 Event 전달 예시는 다음 절에 있다.')
redis_section+='''<p class="muted">예시에서는 scenarioConfig의 기존 표시 필드를 유지한다. 새 집계값은 labelText를 분석하지 않고 crowd.regions에서 읽는다. 집계값은3초 주기로 갱신되며, 매 프레임 objects의 개수를 합친 값과 같다고 가정하지 않는다.</p>
<p class="decision"><b>소비 기본안:</b> 신규 설정 미적용 시 crowd가 없을 수 있다. 집계 준비 전·만료 시 regions가 비어 있을 수 있으므로 SAFE로 추정하지 않고 이전 표시를 만료시킨다. 이 상태는 다섯 번째 status를 추가하지 않고 표현한다.</p>'''
event_section='''<p>기존에는 CROWD Event·Artifact를 발행하지 않았다. 왼쪽은 공통 포맷을 확인하기 위한 기존 INTRUSION 예시이고, 오른쪽은 새 CROWD Event 예시다. 빈 Event HTTP 요청을 새로 보내는 의미가 아니다.</p>
<div class="table-wrap"><table><thead><tr><th>경로</th><th>군중 Event 계약</th></tr></thead><tbody>
<tr><td><code>events[].eventType</code></td><td>새 유형 CROWD.</td></tr><tr><td><code>events[].severityCode</code></td><td>DANGER. 그 외 군중 status는 Redis로 전달한다.</td></tr><tr><td><code>events[].detail.crowd</code></td><td>Event 확정 당시의 ROI·집계 인원·기준 인원·비율·status 스냅샷.</td></tr><tr><td><code>events[].detail.trackId</code></td><td>null. ROI 단위 Event이며 가짜 Tracking ID를 만들지 않는다.</td></tr><tr><td><code>clipUrl / thumbnailUrl</code></td><td>기존 최상위 Event URL 필드 재사용. 해당 Crowd ROI를 표시한다.</td></tr></tbody></table></div>'''
event_section+=pair('Event·Artifact HTTP body','event-before','event-after','미디어 생성에 성공한 응답 예시다. score는 기존 고정 점수 계약을 유지하고, 군중 비율은 detail.crowd.densityRatio에서 읽는다. 이후 군중 상태가 바뀌어도 Event의 확정 당시 값은 유지한다.','변경 전 · 기존 INTRUSION Event','변경 후 · 신규 CROWD Event')
event_section+='''<p class="muted">EventID 기반 중복 방지·전송 재시도와 기존 미디어 실패 계약은 유지한다. 생성 실패 시 해당 URL은 없을 수 있고 detail.artifact / artifactError로 기존 상태를 전달한다. 다른 시나리오의 score·severityCode를 일괄 변경하지 않는다.</p>'''
footer='''<section id="notes"><h2>적용 범위</h2><ul><li>Start 신규값은 parameters 안에만 추가한다. 현재1ROI이며 추후 ROI별 설정 확장을 위한 배열 구조를 유지한다.</li><li>cooldown은6종 모두 적용한다. 새 Event 생성 간격만 조절하며 이미 인계된 Artifact나 전송 재시도를 취소하지 않는다.</li><li>Redis 새 집계 필드는 Crowd provider에만 추가한다. 다른 provider는 메시지 구조가 같고 Event 빈도만 설정에 따라 바뀐다.</li><li>이 문서는 패치 예정 계약이다. 현재 AI는 새 parameters를 아직 소비하지 않으므로 반영 완료 뒤 실제 연동을 확인한다.</li></ul></section>'''
page='<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Contextra 군중 시나리오 Web 연동 계약</title><style>'+CSS+'</style></head><body><main><header><span class="badge">내부 협의용 · 패치 예정 계약</span><h1>군중 시나리오 Web 연동 변경안</h1><p>Start · Redis 메타데이터 · Event / Artifact 전후 샘플</p><p class="muted">2026.09.28 · 모든 JSON은 예시 · 새 필드는 연녹색 강조</p></header><nav><a href="#summary">핵심 규칙</a><a href="#start">Start 전후</a><a href="#redis">Redis 전후</a><a href="#event">Event 전후</a><a href="#notes">적용 범위</a></nav><section id="summary"><h2>핵심 규칙</h2>'+overview+rules+'</section><section id="start"><h2>1. Web → AI Start</h2>'+start_section+'</section><section id="redis"><h2>2. AI → Redis</h2>'+redis_section+'</section><section id="event"><h2>3. AI → Web Event·Artifact</h2>'+event_section+'</section>'+footer+'<footer class="footer">독립 HTML 문서 · 네트워크 연결 없이 열람 가능</footer></main></body></html>'
OUTPUT.parent.mkdir(parents=True,exist_ok=True)
OUTPUT.write_text(page,encoding='utf-8')
(ROOT/'samples.json').write_text(json.dumps(SAMPLES,ensure_ascii=False,indent=2))
assert len(after_start['scenarios'])==6 and all(s['parameters']['cooldown_seconds']==10 for s in after_start['scenarios'])
assert after_redis['crowd']['regions'][0]['densityRatio']==18/20
assert after_redis['crowd']['regions'][0]['count']==after_redis['crowd']['regions'][0]['headCount']
assert after_redis.keys()-before_redis.keys()=={'crowd'}
assert all(after_redis[k]==v for k,v in before_redis.items())
assert SAMPLES['event-after']['events'][0]['detail']['trackId'] is None
sources=['app/secure_control.py','app/scenario_parameters.py','app/crowd_config.py','app/crowd_result.py','app/contracts/visualization.py','app/event_artifact_contract.py','app/event_cooldown.py','app/metadata_publisher.py']
(ROOT/'manifest.json').write_text(json.dumps(dict(lifecycle='presentation',profile='ltb1021',output=OUTPUT.name,
 sha256=hashlib.sha256(OUTPUT.read_bytes()).hexdigest(),samples=6,status='agreed-design-not-implemented',
 source_hashes={p:hashlib.sha256((BASE/p).read_bytes()).hexdigest() for p in sources}),ensure_ascii=False,indent=2))
print(json.dumps(dict(output=str(OUTPUT),bytes=OUTPUT.stat().st_size,samples=6),ensure_ascii=False))
