#!/usr/bin/env python3
from __future__ import annotations
import datetime, hashlib, json, os, re, sys, tempfile
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]; MP=ROOT/'script/project-manifest.json'
def sha(p):
 h=hashlib.sha256()
 with p.open('rb') as f:
  for b in iter(lambda:f.read(8*1024*1024),b''):h.update(b)
 return h.hexdigest()
def resolve(m,k):
 v=m.get('active_paths',{}).get(k)
 if not v:raise RuntimeError(f'missing active_paths.{k}')
 p=Path(v) if Path(v).is_absolute() else ROOT/v;p.resolve().relative_to(ROOT.resolve());return p
def atomic(p,o):
 p.parent.mkdir(parents=True,exist_ok=True);d=(json.dumps(o,ensure_ascii=False,indent=2)+'\n').encode();fd,n=tempfile.mkstemp(dir=p.parent,prefix='.'+p.name+'.')
 try:
  with os.fdopen(fd,'wb') as f:f.write(d);f.flush();os.fsync(f.fileno())
  os.replace(n,p)
 finally:
  if os.path.exists(n):os.unlink(n)
def main():
 m=json.loads(MP.read_text());cand=resolve(m,'candidate');closep=resolve(m,'producer_close_receipt');out=resolve(m,'candidate_receipt')
 close=json.loads(closep.read_text());data=cand.read_bytes();text=data.decode('utf-8','strict');ch=sha(cand);words=len(text.split())
 if close.get('status')!='completed' or close.get('writer_closed') is not True:raise RuntimeError('producer is not closed')
 close_ok=close.get('candidate_sha256')==ch and close.get('bytes',close.get('byte_count'))==len(data) and close.get('word_count')==words
 lines=text.splitlines()
 hygiene={
  'markdown_heading':bool(re.search(r'(?m)^\s*#{1,6}\s+',text)),
  'scene_marker':bool(re.search(r'(?mi)^\s*(?:chương|phần|cảnh|scene|act)(?:\s+\d+|\s*[:.\-])',text)),
  'separator_marker':any(x.strip() in {'***','---','==='} for x in lines),
  'json_or_meta':bool(re.search(r'(?mi)^\s*[\{\[]|\b(?:TODO|TBD|PLACEHOLDER|ghi chú sản xuất|candidate_sha256)\b',text)),
  'channel_cta':bool(re.search(r'Gác Mái Audio|đăng ký kênh|like video|like và subscribe',text,re.I)),
 }
 required_names=['Du Hạ Linh','Kiều Dịch Thần','La Cảnh Du','Tần Nhược Chi','Phó Minh Triết','Kiều Tử Du']
 name_counts={n:text.count(n) for n in required_names}
 scrub=text
 protected=required_names+['Hạ Linh','Dịch Thần','Cảnh Du','Nhược Chi','Minh Triết','Tử Du','bác sĩ Du','kỹ sư Kiều','quản lý La','thợ Phó']
 for handle in sorted(protected,key=len,reverse=True):
  scrub=re.sub(re.escape(handle),' ',scrub,flags=re.I)
 # Forbidden name calls are proper-name tokens. Lowercase common words such as
 # "tinh thần", "an thần" and "chi tiết" are not character handles.
 forbidden_calls={n:len(re.findall(rf'(?<!\w){re.escape(n)}(?!\w)',scrub)) for n in ['Linh','Thần','Chi','Triết','Du']}
 concepts={
  'glass_dome':['mái kính','mái vòm'], 'swift_colony':['đàn chim','chim yến'], 'seventy_two_hours':['bảy mươi hai giờ','72 giờ'],
  'storm':['cơn bão','bão'], 'flight_corridor':['hành lang','đường bay'], 'airflow':['luồng khí','thoát áp'],
  'safety_harness':['dây an toàn','dây bảo hộ'], 'choice_question':['đóng','mở'], 'permanent_nesting':['khe làm tổ','mùa chim năm sau']}
 concept_hits={k:any(v.casefold() in text.casefold() for v in vals) for k,vals in concepts.items()}
 timeline_match=re.search(r'(?mi)^\s*Bốn ngày trước\b',text)
 teaser=text[:timeline_match.start()].strip() if timeline_match else ''
 teaser_words=len(teaser.split())
 first=teaser.casefold()
 cold={'timeline_transition_found':timeline_match is not None,'teaser_80_180_words':80<=teaser_words<=180,'has_haling':'hạ linh' in first,'has_dichthan':'dịch thần' in first,'has_glass':'kính' in first,'has_bird':'chim' in first,'has_choice':'đóng' in first and 'mở' in first,'no_final_outcome':not any(x in first for x in ['ba tháng sau','mùa chim năm sau','quảng trường mở lại','bắt đầu hẹn hò'])}
 old_entities=['Tống Vãn Ninh','Cố Trạch Xuyên','Lục Mạn Thanh','Lâm Tri Vi','Tống Nghi Hòa','Lê An Nhiên','Mai Thanh Lam']
 old_hits=[x for x in old_entities if x in text]
 checks={'producer_close_matches':close_ok,'word_count_10500_12500':10500<=words<=12500,'narration_hygiene':not any(hygiene.values()),'required_cast':all(v>0 for v in name_counts.values()),'naming_rules':all(v==0 for v in forbidden_calls.values()),'core_concepts':all(concept_hits.values()),'cold_open_contract':all(cold.values()),'no_old_project_entities':not old_hits,'paragraphs_present':len([p for p in re.split(r'\n\s*\n',text) if p.strip()])>=80}
 verified=all(checks.values());r={'status':'completed' if verified else 'failed','verified':verified,'candidate_path':str(cand),'candidate_sha256':ch,'bytes':len(data),'words_whitespace':words,'producer_close_receipt':str(closep),'producer_close_sha256':sha(closep),'checks':{k:{'verified':v,'value':v} for k,v in checks.items()},'hygiene_findings':hygiene,'name_counts':name_counts,'forbidden_single_name_counts':forbidden_calls,'concept_hits':concept_hits,'cold_open':cold,'old_entity_hits':old_hits,'blockers':[k for k,v in checks.items() if not v],'checked_at':datetime.datetime.now(datetime.timezone.utc).isoformat()}
 atomic(out,r);print(json.dumps({'status':r['status'],'verified':verified,'words':words,'candidate_sha256':ch,'blockers':r['blockers'],'receipt':str(out)},ensure_ascii=False));return 0 if verified else 1
if __name__=='__main__':
 try:sys.exit(main())
 except Exception as e:print(json.dumps({'status':'failed','verified':False,'error':f'{type(e).__name__}: {e}'},ensure_ascii=False),file=sys.stderr);sys.exit(1)
