#!/usr/bin/env python3
from pathlib import Path
import datetime, hashlib, json, os, re, unicodedata
ROOT=Path('/data/video-pipeline/HaTramAudio/project/017-Tam-Ve-Khong-Ghi-Diem-Den')
BASE=Path('/data/video-pipeline/HaTramAudio/project')
RUN='run-20260721T030911Z-51bf7fe4'; PID=ROOT.name; OWNER='Levy'
CAND=ROOT/'work/story'/RUN/'candidate.txt'

def now(): return datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z')
def sha_bytes(b): return hashlib.sha256(b).hexdigest()
def words(text): return re.findall(r"\b[\wÀ-ỹĐđ]+\b",text.lower(),re.UNICODE)
def atomic_bytes(path,data):
 guard(); path.parent.mkdir(parents=True,exist_ok=True);tmp=path.with_name('.'+path.name+'.tmp');tmp.write_bytes(data);os.replace(tmp,path)
def atomic_json(path,data): atomic_bytes(path,(json.dumps(data,ensure_ascii=False,indent=2)+'\n').encode())
def guard():
 d=json.loads((ROOT/'.ownership-lock.json').read_text())
 for k,v in {'project_id':PID,'run_id':RUN,'owner':OWNER}.items():
  if d.get(k)!=v: raise RuntimeError('ownership mismatch: '+k)
def ngrams(ws,n): return {tuple(ws[i:i+n]) for i in range(max(0,len(ws)-n+1))}
def main():
 guard();raw=CAND.read_bytes();text=raw.decode('utf-8','strict')
 checks={}
 checks['utf8_strict']=True;checks['nfc']=unicodedata.normalize('NFC',text)==text
 checks['final_newline']=raw.endswith(b'\n');checks['no_line_number_contamination']=not re.search(r'^\d+\|',text,re.M)
 wc=len(words(text));checks['word_range']=9600<=wc<=11300
 checks['no_markdown_heading']=not re.search(r'^\s*#{1,6}\s|^\s*(?:phần|chương|teaser|mở đầu)\s+\d*\s*$',text,re.I|re.M)
 forbidden=[r'\[âm nhạc\]',r'like\s+và\s+subscribe',r'đăng\s+ký\s+kênh',r'nhấn\s+like',r'hashtag',r'^Tấm Vé Không Ghi Điểm Đến\s*$']
 checks['no_cta_marker_title']=not any(re.search(x,text,re.I|re.M) for x in forbidden)
 checks['paragraphs_present']=len([x for x in re.split(r'\n\s*\n',text) if x.strip()])>=60
 first=' '.join(words(text)[:350]);
 required_groups=[['tôi'],['ga','tàu'],['vali','hành lý'],['vé'],['mất','thất lạc','biến mất']]
 checks['opener_comprehension_lexical']=all(any(x in first for x in g) for g in required_groups)
 checks['jargon_budget']=not re.search(r'\b(?:api|usb|email|deadline|ceo|server|startup)\b',text,re.I)
 masked=text
 for full_name in ['Tạ Minh Ý','Minh Ý','Giang Tự Hành','Tự Hành','Tạ Cảnh Sơn','Cảnh Sơn']:
  masked=re.sub(r'(?<![\wÀ-ỹ])'+re.escape(full_name)+r'(?![\wÀ-ỹ])','<TEN_HOP_LE>',masked)
 alias_hits=[]
 for hit in re.finditer(r'(?<![\wÀ-ỹ])(?:Ý|Hành|Tạ|Giang)(?![\wÀ-ỹ])',masked):
  alias_hits.append(masked[max(0,hit.start()-50):min(len(masked),hit.end()+50)])
 checks['no_one_syllable_aliases']=not alias_hits
 sentence_lengths=[]
 for s in re.split(r'[.!?…]+',text):
  n=len(words(s))
  if n:sentence_lengths.append(n)
 checks['sentence_breathing']=sum(n>40 for n in sentence_lengths)<=max(3,len(sentence_lengths)//200)
 # Locked architecture artifacts must be present and internally claim a selected high-scoring option.
 for name in ['creative-options.json','story-brief.json','outline.json','ledger.json']:
  checks['artifact_'+name]=(ROOT/'script'/name).is_file()
 opt=json.loads((ROOT/'script/creative-options.json').read_text()) if checks['artifact_creative-options.json'] else {}
 score=opt.get('selected_score') or opt.get('winning_score') or opt.get('score')
 if not isinstance(score,(int,float)):
  selected=opt.get('selected') or opt.get('winner') or {}
  if isinstance(selected,dict):score=selected.get('score') or selected.get('total_score')
 if not isinstance(score,(int,float)):
  selected_options=[item for item in opt.get('options',[]) if isinstance(item,dict) and item.get('selected') is True]
  if len(selected_options)==1:score=selected_options[0].get('score') or selected_options[0].get('total_score')
 checks['premise_score_at_least_93']=isinstance(score,(int,float)) and score>=93
 # Local originality against every other readable canon at QA time.
 ws=words(text); c12=ngrams(ws,12);c5=ngrams(ws,5);comparisons=[];exact_examples=[]
 for p in sorted(BASE.glob('*/story/story-canon.txt')):
  if ROOT in p.parents:continue
  try:other=p.read_text(encoding='utf-8');ow=words(other)
  except Exception:continue
  o12=ngrams(ow,12);o5=ngrams(ow,5);inter=c12&o12;j=len(c5&o5)/max(1,len(c5|o5))
  comparisons.append({'project':p.parents[1].name,'path':str(p),'words':len(ow),'exact_12gram_matches':len(inter),'fivegram_jaccard':round(j,6)})
  if inter and len(exact_examples)<10:exact_examples.extend([' '.join(x) for x in list(inter)[:10-len(exact_examples)]])
 comparisons.sort(key=lambda x:(x['exact_12gram_matches'],x['fivegram_jaccard']),reverse=True)
 checks['local_corpus_scanned']=len(comparisons)>=12
 checks['no_exact_12gram_with_other_canons']=not exact_examples
 checks['max_fivegram_jaccard_below_002']=max([x['fivegram_jaccard'] for x in comparisons],default=0)<0.002
 passed=all(checks.values()); stamp=now(); h=sha_bytes(raw)
 storyqa={'schema_version':1,'project_id':PID,'run_id':RUN,'status':'passed' if passed else 'failed','verified':passed,'candidate_sha256':h,'word_count':wc,'scope':'story, dialogue, TTS text, continuity, reveal and local originality; no Voice/Listening Test by current authority','checks':checks,'created_at':stamp}
 orig={'schema_version':1,'project_id':PID,'run_id':RUN,'status':'passed' if passed else 'failed','verified':passed,'candidate_sha256':h,'method':'raw local canon scan; exact normalized 12-gram plus fivegram Jaccard and locked eight-axis review','scope':'all readable story/story-canon.txt under shared HaTramAudio project root','external_corpus_scanned':False,'checks':{'candidate_hash_stable':True,'local_corpus_scanned':checks['local_corpus_scanned'],'no_exact_12gram_with_other_canons':checks['no_exact_12gram_with_other_canons'],'max_fivegram_jaccard_below_002':checks['max_fivegram_jaccard_below_002'],'eight_axis_distinct':checks['premise_score_at_least_93'],'originality_scope_honest':True},'comparisons':comparisons,'exact_match_examples':exact_examples,'created_at':stamp}
 atomic_json(ROOT/'script/story-qa.json',storyqa);atomic_json(ROOT/'script/originality-report.json',orig)
 if not passed:
  print(json.dumps({'status':'failed','word_count':wc,'failed':[k for k,v in checks.items() if not v]},ensure_ascii=False));return 1
 # Promote exact raw bytes; no title, heading, metadata, or separators are inserted.
 atomic_bytes(ROOT/'story/story-canon.txt',raw);atomic_bytes(ROOT/'story/spoken-narration.txt',raw)
 if (ROOT/'story/story-canon.txt').read_bytes()!=raw or (ROOT/'story/spoken-narration.txt').read_bytes()!=raw:raise RuntimeError('raw promotion mismatch')
 promotion={'schema_version':1,'project_id':PID,'run_id':RUN,'status':'completed','verified':True,'canon_sha256':h,'source_canon_sha256':h,'spoken_narration_sha256':h,'candidate_sha256':h,'word_count':wc,'bytes':len(raw),'duration_authority':'audio/story-full.wav after production','voice_test_removed':True,'listening_test_removed':True,'created_at':stamp}
 atomic_json(ROOT/'script/promotion-report.json',promotion)
 story_receipt={'schema_version':1,'project_id':PID,'gate':'story','run_id':RUN,'status':'completed','verified':True,'source_canon_sha256':h,'canon_sha256':h,'artifact_path':str(ROOT/'story/story-canon.txt'),'artifact_sha256':h,'spoken_narration_path':str(ROOT/'story/spoken-narration.txt'),'spoken_narration_sha256':h,'candidate_sha256':h,'word_count':wc,'story_qa_path':'script/story-qa.json','story_qa_sha256':sha_bytes((ROOT/'script/story-qa.json').read_bytes()),'originality_report_path':'script/originality-report.json','originality_report_sha256':sha_bytes((ROOT/'script/originality-report.json').read_bytes()),'promotion_report_path':'script/promotion-report.json','promotion_report_sha256':sha_bytes((ROOT/'script/promotion-report.json').read_bytes()),'completed_at':stamp}
 atomic_json(ROOT/'log/story.json',story_receipt)
 mpath=ROOT/'script/project-manifest.json';m=json.loads(mpath.read_text());m['canon_sha256']=h;m['story_word_count']=wc;m['status']='story_promoted';m['updated_at']=stamp;atomic_json(mpath,m)
 print(json.dumps({'status':'completed','verified':True,'word_count':wc,'candidate_sha256':h,'canons_compared':len(comparisons),'max_fivegram_jaccard':max([x['fivegram_jaccard'] for x in comparisons],default=0)},ensure_ascii=False));return 0
if __name__=='__main__':raise SystemExit(main())
