#!/usr/bin/env python3
from pathlib import Path
import argparse,datetime,hashlib,json,os,urllib.parse,urllib.request
ROOT=Path('/data/video-pipeline/HaTramAudio/project/022-Tiem-Giat-Mo-Cua-Luc-Bon-Gio-Muoi-Bay');PID=ROOT.name;RUN='run-20260721T172022Z-9462493a';OWNER='Levy';CFG=Path.home()/'.config/ha-tram-audio/postiz.env'
GATES=['story','tts','layout','poster','intro','footage','final_render','transcode','metadata','publish_ready','upload','schedule','cleanup']
def now():return datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z')
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 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 env():
 d={}
 for raw in CFG.read_text().splitlines():
  s=raw.strip()
  if s and not s.startswith('#') and '=' in s:
   k,v=s.split('=',1);d[k.strip()]=v.strip().strip("\"'")
 return d
def atomic(p,d):guard();t=p.with_name('.'+p.name+'.tmp');t.write_text(json.dumps(d,ensure_ascii=False,indent=2)+'\n');os.replace(t,p)
def parse_dt(v):
 x=datetime.datetime.fromisoformat(v.replace('Z','+00:00'))
 if x.tzinfo is None:x=x.replace(tzinfo=datetime.timezone.utc)
 return x.astimezone(datetime.timezone.utc).replace(microsecond=0)
def verify_calendar(schedule):
 e=env();slot=parse_dt(schedule['slot_utc']);q=urllib.parse.urlencode({'startDate':(slot-datetime.timedelta(minutes=10)).isoformat().replace('+00:00','Z'),'endDate':(slot+datetime.timedelta(minutes=10)).isoformat().replace('+00:00','Z')});req=urllib.request.Request(e['POSTIZ_BASE_URL'].rstrip('/')+'/posts?'+q,headers={'Authorization':e['POSTIZ_API_KEY'],'Accept':'application/json'})
 with urllib.request.urlopen(req,timeout=60) as r:raw=json.load(r)
 posts=raw if isinstance(raw,list) else raw.get('posts') or raw.get('data') or []
 expected={x['integration_id']:x['post_id'] for x in schedule['calendar_verification']};found=[]
 for p in posts:
  integ=p.get('integration') or {};iid=integ.get('id') if isinstance(integ,dict) else p.get('integrationId');pd=p.get('publishDate') or p.get('date');pid=p.get('id') or p.get('postId')
  if iid in expected and pd and parse_dt(pd)==slot and pid==expected[iid] and str(p.get('state','')).upper()=='QUEUE':found.append({'post_id':pid,'integration_id':iid,'state':'QUEUE','publishDate':pd})
 if len(found)!=2 or {x['integration_id'] for x in found}!=set(expected):raise RuntimeError('calendar does not prove exact two QUEUE posts')
 return found
def main():
 a=argparse.ArgumentParser();a.add_argument('mode',choices=['pre','post']);mode=a.parse_args().mode;guard();mp=ROOT/'script/project-manifest.json';m=json.loads(mp.read_text());events=m.get('gate_events',{});schedule=json.loads((ROOT/'log/schedule.json').read_text());calendar=verify_calendar(schedule);canon=m['canon_sha256']
 required=GATES[:-1] if mode=='pre' else GATES
 if any(events.get(g,{}).get('status')!='completed' or events.get(g,{}).get('verified') is not True for g in required):raise RuntimeError('gate set incomplete for '+mode)
 retained=ROOT/'output/final-upload.mp4';upload=json.loads((ROOT/'log/upload.json').read_text());trans=json.loads((ROOT/'log/transcode-upload.json').read_text())
 if not retained.is_file() or retained.stat().st_size>=1_000_000_000 or sha(retained)!=upload['artifact_sha256'] or sha(retained)!=trans['artifact_sha256']:raise RuntimeError('retained upload mismatch')
 audit_path=ROOT/'log/completion-audit.json';old=json.loads(audit_path.read_text()) if audit_path.exists() else {};artifacts=old.get('artifacts',{})
 if mode=='pre':
  for key,rel in [('final','output/final.mp4'),('footage','output/footage/footage.mp4')]:
   p=ROOT/rel
   if not p.is_file():raise RuntimeError('pre-cleanup target missing '+rel)
   artifacts[key]={'path':str(p),'relative_path':rel,'bytes':p.stat().st_size,'sha256':sha(p),'exists':True}
  # Cleanup authority requires the pre-cleanup completion transition plus locked evidence.
  m.update({'status':'completed','verified':True,'scheduled':True,'published':False,'publish_blocked':False,'completed_at':now(),'updated_at':now()});atomic(mp,m)
 else:
  cleanup=json.loads((ROOT/'log/cleanup.json').read_text())
  if cleanup.get('status')!='completed' or cleanup.get('verified') is not True:raise RuntimeError('cleanup receipt invalid')
  for key in ['final','footage']:
   p=Path(artifacts[key]['path'])
   if p.exists():raise RuntimeError('transient artifact remains '+key)
   artifacts[key]['exists']=False
 artifacts['upload_copy']={'path':str(retained),'relative_path':'output/final-upload.mp4','bytes':retained.stat().st_size,'sha256':sha(retained),'exists':True}
 d={'schema_version':1,'project_id':PID,'run_id':RUN,'status':'completed','verified':True,'phase':'pre_cleanup' if mode=='pre' else 'post_cleanup_final','source_canon_sha256':canon,'gate_count':len(required),'verified_gates':required,'schedule':{'slot_local':schedule['slot_local'],'slot_utc':schedule['slot_utc'],'calendar_verification':calendar},'artifacts':artifacts,'checks':{'all_required_gates_verified':True,'exact_two_queue_posts':True,'retained_upload_hash_verified':True,'retained_upload_under_one_gb':True,'transient_targets_present_and_locked':mode=='pre','transient_targets_absent':mode=='post'},'completed_at':now()};atomic(audit_path,d)
 if mode=='post':
  m=json.loads(mp.read_text());m.update({'status':'completed','verified':True,'scheduled':True,'published':False,'publish_blocked':False,'completion_audit':'log/completion-audit.json','completion_audit_sha256':sha(audit_path),'updated_at':now()});atomic(mp,m)
 print(json.dumps({'status':'completed','verified':True,'mode':mode,'gate_count':len(required),'queue_posts':len(calendar),'retained_bytes':retained.stat().st_size},ensure_ascii=False))
if __name__=='__main__':main()
