#!/usr/bin/env python3
import hashlib,json,re,sys
from datetime import datetime,timezone
from pathlib import Path
PROJECT=Path(__file__).resolve().parents[1];STORY=PROJECT/'story';OUTLINE=PROJECT/'script/outline.json';IDENTITY=PROJECT/'script/identity-registry.json';REPORT=STORY/'promotion-report.json'
WORD_RE=re.compile(r'[\wÀ-ỹĐđ]+',re.UNICODE);HEADING_RE=re.compile(r'^\s*(?:chương|phần|cảnh|chapter|part|scene)\s+(?:\d+|[ivxlcdm]+)\s*[:.\-]?',re.I);PRODUCTION_RE=re.compile(r'\[(?:sfx|music|nhạc|âm thanh|pause|transition)\]',re.I)
def sha(t):return hashlib.sha256(t.encode()).hexdigest()
def words(t):return WORD_RE.findall(t)
def exact_join(texts):return '\n\n'.join(x.strip() for x in texts).strip()+'\n'
def identity_hits(text,registry):
 scrubbed=text;fragments=set()
 for e in registry['characters']:
  allowed=sorted(set(e.get('allowed_narrative_names',[])+e.get('allowed_title_names',[])+e.get('role_address',[])+[e['full_name']]),key=len,reverse=True)
  for value in allowed:scrubbed=re.sub(rf'(?<![\wÀ-ỹĐđ]){re.escape(value)}(?![\wÀ-ỹĐđ])',' ',scrubbed,flags=re.I)
  fragments.update(e.get('forbidden_bare_names',[]))
 lead=r'(?:cô|anh|chị|ông|bà|cậu|em|gọi|hỏi|bảo|đáp|quát|nhìn|nói với|thì thầm với)\s+'
 hits=[]
 for f in sorted(fragments):
  for pattern in [re.compile(rf'(?i)(?<![\wÀ-ỹĐđ]){lead}{re.escape(f)}(?![\wÀ-ỹĐđ])'),re.compile(rf'[,;:]\s*{re.escape(f)}\s*[,!?.:]'),re.compile(rf'[\"“]\s*{re.escape(f)}\s*[,!?.:]')]:
   for m in pattern.finditer(scrubbed):
    hits.append({'fragment':f,'line':scrubbed.count('\n',0,m.start())+1,'context':scrubbed[max(0,m.start()-60):m.end()+80].strip()})
    if len(hits)>=50:return hits
 return hits
def reveal_hits(chapters):
 checks=[
  (6,'reversed_crack_timeline',r'(?:mười ba|13) (?:vết nứt|ảnh).{0,220}?(?:đảo (?:thứ tự|thời gian)|đổi hướng vì.{0,100}?thời gian)'),
  (8,'recycled_bearings',r'(?:gối cầu|vật liệu).{0,180}?(?:tái chế|thay lô|đánh tráo).{0,160}?(?:Khải Phong|liên danh)|Khải Phong.{0,220}?(?:gối cầu|vật liệu).{0,120}?(?:tái chế|thay)'),
  (7,'original_calibration',r'(?:Gia Hân|bản hiệu chuẩn gốc).{0,220}?(?:giữ|trao|chứng minh).{0,120}?(?:ghép trang|chữ ký)'),
  (9,'early_opening_order',r'Hạo Nhiên.{0,220}?(?:ra lệnh|chỉ đạo).{0,120}?mở cầu sớm|lệnh mở cầu sớm.{0,180}?Hạo Nhiên'),
  (7,'independent_vibration_data',r'Vĩnh Khang.{0,220}?(?:giữ|lưu).{0,120}?(?:mười ba phút|13 phút).{0,100}?(?:rung|dữ liệu)|thiết bị đo độc lập.{0,220}?(?:mười ba phút|13 phút)')]
 hits=[]
 for before,key,pat in checks:
  early='\n'.join(chapters[:before])
  for m in re.finditer(pat,early,re.I|re.S):
   context=early[max(0,m.start()-100):m.end()+100]
   if re.search(r'(?:chưa|không)\s+(?:đủ|thể).{0,100}?(?:chứng minh|xác định|kết luận)|(?:nghi|nếu|có thể).{0,100}$',context[:140],re.I|re.S):continue
   hits.append({'reveal':key,'before_chapter':before+1,'excerpt':context});break
 return hits
def main():
 issues=[];outline=json.loads(OUTLINE.read_text());registry=json.loads(IDENTITY.read_text());expected=len(outline['chapters']);paths=[STORY/'chapters'/f'{i:02d}.txt' for i in range(1,expected+1)];texts=[];rows=[]
 for i,path in enumerate(paths,1):
  if not path.exists():issues.append(f'missing_chapter:{i:02d}');texts.append('');continue
  text=path.read_text();texts.append(text);rows.append({'chapter':i,'path':str(path.relative_to(PROJECT)),'words':len(words(text)),'sha256':sha(text)})
  if not text.strip():issues.append(f'empty_chapter:{i:02d}')
  for n,line in enumerate(text.splitlines(),1):
   if HEADING_RE.match(line):issues.append(f'heading:{i:02d}:{n}')
   if PRODUCTION_RE.search(line):issues.append(f'production_marker:{i:02d}:{n}')
 narration_path=STORY/'spoken-narration.txt';narration=narration_path.read_text() if narration_path.exists() else ''
 if not narration:issues.append('missing_or_empty_spoken_narration')
 expected_text=exact_join(texts) if all(texts) else ''
 if narration and expected_text and narration!=expected_text:issues.append('spoken_narration_not_exact_sequential_join')
 count=len(words(narration));low,high=outline['allowed_word_range']
 if not low<=count<=high:issues.append(f'word_count_out_of_range:{count}')
 for n,line in enumerate(narration.splitlines(),1):
  if HEADING_RE.match(line):issues.append(f'heading_in_spoken:{n}')
  if PRODUCTION_RE.search(line):issues.append(f'production_marker_in_spoken:{n}')
 ids=identity_hits(narration,registry) if narration else []
 if ids:issues.append(f'standalone_identity_fragments:{len(ids)}')
 protected=reveal_hits(texts) if all(texts) else []
 if protected:issues.append(f'protected_reveal_too_early:{len(protected)}')
 report={'version':1,'status':'passed' if not issues else 'failed','verified':not issues,'canonical_title':outline['canonical_title'],'expected_chapters':expected,'chapters_present':sum(x.exists() for x in paths),'chapter_rows':rows,'spoken_narration':str(narration_path.relative_to(PROJECT)),'total_words':count,'target_range':[low,high],'estimated_minutes_at_baseline':round(count/233.33,3) if count else 0,'spoken_sha256':sha(narration) if narration else None,'identity_hits':ids,'protected_reveal_hits':protected,'issues':issues,'checked_at':datetime.now(timezone.utc).isoformat()};REPORT.write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n');print(json.dumps({'verified':report['verified'],'chapters':report['chapters_present'],'total_words':count,'issues':issues[:20],'report':str(REPORT)},ensure_ascii=False));return 0 if report['verified'] else 1
if __name__=='__main__':sys.exit(main())
