#!/usr/bin/env python3
from pathlib import Path
from datetime import datetime,timedelta,timezone
from zoneinfo import ZoneInfo
import json,hashlib,requests,time,os
P='gacmai_20260718_075153';R=Path('/data/video-pipeline/GacMaiAudio/project')/P;A=R/'artifacts';VIDEO=R/'render/final_campaign_native_rev2_upload.mp4';THUMB=R/'assets/images/intro_campaign_native_rev2.png';REC=A/'postiz_calendar_campaign_native_rev2.json';META=A/'platform_metadata.json';YT='cmriltjyk0013j7c8l7dp3o5i';FB='cmrilrryn0011j7c8kfzgx9zq'
vals={}
for line in Path('/opt/OpenMontage/.env').read_text().splitlines():
 if line.strip() and not line.lstrip().startswith('#') and '=' in line:
  k,v=line.split('=',1);vals[k]=v.strip().strip('"\'')
BASE=vals['POSTIZ_BASE_URL'].rstrip('/');H={'Authorization':vals['POSTIZ_API_KEY']}
def req(method,path,**kw):
 timeout=kw.pop('timeout',1800)
 for n in range(3):
  try:r=requests.request(method,BASE+path,headers=H,timeout=timeout,**kw)
  except requests.RequestException:
   if n==2:raise
   time.sleep(5*(n+1));continue
  if r.status_code<400:return r
  if r.status_code>=500 or r.status_code==429:time.sleep(5*(n+1));continue
  raise RuntimeError(f'{method} {path} HTTP {r.status_code}: {r.text[:500]}')
 raise RuntimeError('request exhausted')
def posts(start,end):
 b=req('GET','/posts',params={'startDate':start.isoformat().replace('+00:00','Z'),'endDate':end.isoformat().replace('+00:00','Z')}).json();return b.get('posts',b if isinstance(b,list) else [])
def instant(s):return datetime.fromisoformat(s.replace('Z','+00:00'))
def save(d):REC.write_text(json.dumps(d,ensure_ascii=False,indent=2)+'\n')
def upload(path,mime,rec,key):
 if rec.get('uploads',{}).get(key,{}).get('id'):return rec['uploads'][key]
 rec['upload_in_progress']={'filename':path.name,'size':path.stat().st_size,'mime':mime,'sha256':hashlib.sha256(path.read_bytes()).hexdigest()};save(rec)
 with path.open('rb') as f:b=req('POST','/upload',files={'file':(path.name,f,mime)}).json()
 q=b.get('output',b.get('data',b)) if isinstance(b,dict) else b
 if isinstance(q,list):q=q[0]
 if not isinstance(q,dict) or not q.get('id') or not q.get('path'):raise RuntimeError('upload missing id/path')
 rec.setdefault('uploads',{})[key]={'id':q['id'],'path':q['path'],'filename':path.name};rec.pop('upload_in_progress',None);save(rec);return rec['uploads'][key]
def main():
 meta=json.loads(META.read_text());assert all(meta['gates'][k] is True for k in ('title_format','youtube_complete','facebook_adapted','tags_valid','current_project_only')) and meta['gates']['inherited_metadata'] is False
 integ=req('GET','/integrations').json();items=integ if isinstance(integ,list) else integ.get('integrations',integ.get('data',[]));imap={x['id']:x for x in items};assert imap[YT]['identifier']=='youtube' and imap[FB]['identifier']=='facebook' and not imap[YT].get('disabled') and not imap[FB].get('disabled')
 now=datetime.now(timezone.utc);end=now+timedelta(days=14);live=posts(now-timedelta(hours=1),end);tz=ZoneInfo('Asia/Ho_Chi_Minh');local=now.astimezone(tz);cands=[]
 for day in range(15):
  date=(local+timedelta(days=day)).date()
  for hh,mm in ((9,20),(19,0)):
   x=datetime(date.year,date.month,date.day,hh,mm,tzinfo=tz).astimezone(timezone.utc)
   if x>now:cands.append(x)
 occ=[];slot=None
 for x in cands:
  busy=[p.get('integration',{}).get('id') for p in live if p.get('integration',{}).get('id') in (YT,FB) and p.get('publishDate') and instant(p['publishDate'])==x]
  occ.append({'utc':x.isoformat().replace('+00:00','Z'),'busy':busy})
  if not busy:slot=x;break
 assert slot
 rec=json.loads(REC.read_text()) if REC.exists() else {'project_id':P,'title':'Đường Nước Trở Về','uploads':{},'channels':{}}
 rec.update({'status':'calendar_selected','total_api_posts':len(live),'target_match_count':sum(1 for p in live if p.get('integration',{}).get('id') in (YT,FB)),'candidates':occ,'slot_utc':slot.isoformat().replace('+00:00','Z'),'slot_local':slot.astimezone(tz).isoformat(),'integration_ids':[YT,FB]});save(rec)
 # Three consecutive lightweight auth reads before large upload.
 for _ in range(3):assert req('GET','/integrations',timeout=30).status_code==200
 video=upload(VIDEO,'video/mp4',rec,'video');thumb=upload(THUMB,'image/png',rec,'thumbnail')
 # Recheck occupancy immediately before creation.
 live2=posts(now-timedelta(hours=1),end);assert not [p for p in live2 if p.get('integration',{}).get('id') in (YT,FB) and p.get('publishDate') and instant(p['publishDate'])==slot]
 date=slot.isoformat().replace('+00:00','Z');yt={'type':'schedule','date':date,'shortLink':False,'tags':[],'posts':[{'integration':{'id':YT},'value':[{'content':meta['youtube_description'],'image':[video]}],'settings':{'__type':'youtube','title':meta['youtube_title'],'type':'public','selfDeclaredMadeForKids':'no','thumbnail':thumb,'tags':[{'value':x,'label':x} for x in meta['youtube_tags']]}}]};fb={'type':'schedule','date':date,'shortLink':False,'tags':[],'posts':[{'integration':{'id':FB},'value':[{'content':meta['facebook_caption'],'image':[video]}],'settings':{'__type':'facebook','post_type':'post'}}]}
 # Create separately but checkpoint each response immediately; recover live before any retry.
 for cid,payload in ((YT,yt),(FB,fb)):
  if rec.get('channels',{}).get(cid,{}).get('post_id'):continue
  prior=[p for p in posts(now-timedelta(hours=1),end) if p.get('integration',{}).get('id')==cid and p.get('publishDate') and instant(p['publishDate'])==slot]
  if prior:rec['channels'][cid]={'post_id':prior[0].get('id'),'group_id':prior[0].get('group'),'recovered':True};save(rec);continue
  b=req('POST','/posts',json=payload).json();out=b if isinstance(b,list) else b.get('output',b.get('data',b));arr=out if isinstance(out,list) else [out];q=next((x for x in arr if isinstance(x,dict) and (x.get('id') or x.get('postId'))),None);assert q
  rec.setdefault('channels',{})[cid]={'post_id':q.get('id') or q.get('postId'),'group_id':q.get('group') or q.get('groupId'),'response':b};save(rec)
 # Direct-ID missing verification plus calendar readback.
 live3=posts(now-timedelta(hours=1),end);errors=[]
 for cid,provider in ((YT,'youtube'),(FB,'facebook')):
  q=rec['channels'][cid];pid=q['post_id'];missing=req('GET',f'/posts/{pid}/missing').json();found=next((p for p in live3 if p.get('id')==pid),None)
  q.update({'missing':missing,'provider':provider,'readback':found})
  if missing!=[]:errors.append(f'missing {cid}')
  if not found or instant(found.get('publishDate'))!=slot or found.get('state')!='QUEUE' or found.get('integration',{}).get('id')!=cid:errors.append(f'readback {cid}')
 rec['status']='passed' if not errors else 'failed';rec['errors']=errors;save(rec)
 if errors:raise RuntimeError(str(errors))
 print(json.dumps({'status':'passed','slot_utc':date,'channels':{k:{'post_id':v['post_id'],'provider':v['provider']} for k,v in rec['channels'].items()},'uploads':rec['uploads']},ensure_ascii=False))
if __name__=='__main__':main()
