#!/usr/bin/env python3
from __future__ import annotations
import datetime as dt, hashlib, json, os, stat, subprocess, time, urllib.parse, urllib.request
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT=Path('/data/video-pipeline/HaTramAudio/project/018-Hon-Uoc-Khong-Duoc-Phep-Yeu');PROJECT=ROOT.name;RUN='run-20260721T040505Z-cd9f944a';CANON='d12e1733f81bf173b5db789b0028a0be478d0d2e565c05a91e236c95cd33bd78';VIDEO_SHA='bbadb5780a1565038d7df378cb0bfffa75a2cac07020ab1764c5d89f3cba47ae';THUMB_SHA='e9da2b4a8ed90e9f947392bc8f7099ecec080e0d567769b8cff8477c04dd5722';META_SHA='2b12350b71dab64af7b92820f1db14c331e240d4eed15c512c68b83aef59c662';VIDEO=ROOT/'output/final-upload.mp4';THUMB=ROOT/'image/normalized/intro-poster-1920x1080.png';ENV_FILE=Path.home()/'.config/ha-tram-audio/postiz.env'
def now():return dt.datetime.now(dt.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 atomic(rel,v):
 p=ROOT/rel;p.parent.mkdir(parents=True,exist_ok=True);q=p.with_suffix(p.suffix+'.tmp');q.write_text(json.dumps(v,ensure_ascii=False,indent=2)+'\n',encoding='utf-8');os.replace(q,p)
def load_env():
 assert ENV_FILE.is_file() and stat.S_IMODE(ENV_FILE.stat().st_mode)==0o600
 vals={}
 for line in ENV_FILE.read_text().splitlines():
  line=line.strip()
  if not line or line.startswith('#') or '=' not in line:continue
  k,v=line.split('=',1);vals[k.strip()]=v.strip().strip('"').strip("'")
 names=['POSTIZ_URL','POSTIZ_BASE_URL','POSTIZ_API_KEY','POSTIZ_FACEBOOK_INTEGRATION_ID','POSTIZ_YOUTUBE_INTEGRATION_ID','POSTIZ_TIMEZONE','POSTIZ_SLOTS']
 out={k:os.environ.get(k) or vals.get(k) for k in names};assert all(out.values());return out
def api(base,key,path,method='GET',payload=None,attempts=3):
 data=None if payload is None else json.dumps(payload,ensure_ascii=False).encode();headers={'Authorization':key,'Content-Type':'application/json'}
 for n in range(attempts):
  try:
   with urllib.request.urlopen(urllib.request.Request(base.rstrip('/')+path,data=data,headers=headers,method=method),timeout=90) as r:return r.status,json.load(r)
  except Exception:
   if method!='GET' or n+1==attempts:raise
   time.sleep(2**n)
def list_posts(base,key,start,end):
 q=urllib.parse.urlencode({'startDate':start.isoformat().replace('+00:00','Z'),'endDate':end.isoformat().replace('+00:00','Z')});_,x=api(base,key,'/posts?'+q);return x.get('posts',x if isinstance(x,list) else [])
def parse_ts(s):
 if not s:return None
 s=s.replace('Z','+00:00');x=dt.datetime.fromisoformat(s)
 if x.tzinfo is None:x=x.replace(tzinfo=dt.timezone.utc)
 return x.astimezone(dt.timezone.utc)
def integ_id(p):
 x=p.get('integration') or {};return x.get('id') if isinstance(x,dict) else x
def state(p):return str(p.get('state') or p.get('status') or '').upper()
def post_ts(p):return parse_ts(p.get('publishDate') or p.get('date'))
def calendar(base,key,fb,yt,start,end):
 posts=list_posts(base,key,start,end);target=[p for p in posts if integ_id(p) in (fb,yt)];other=len(posts)-len(target);return posts,target,other
def candidates(tz,slots,days=30):
 local=dt.datetime.now(tz);out=[]
 for n in range(days+1):
  day=local.date()+dt.timedelta(days=n)
  for s in slots:
   hh,mm=map(int,s.split(':'));x=dt.datetime.combine(day,dt.time(hh,mm),tzinfo=tz)
   if x>local:out.append(x)
 return sorted(out)
def choose_slot(target,fb,yt,tz,slots):
 occupied={(integ_id(p),post_ts(p)) for p in target if state(p) in ('QUEUE','PUBLISHED') and post_ts(p)}
 for local in candidates(tz,slots):
  utc=local.astimezone(dt.timezone.utc)
  if (fb,utc) not in occupied and (yt,utc) not in occupied:return local,utc
 raise RuntimeError('no common slot in 30-day window')
def curl_upload(base,key,path,mime):
 cfg='\n'.join([f'url = "{base.rstrip("/")}/upload"','request = "POST"',f'header = "Authorization: {key}"',f'form = "file=@{path};type={mime}"','silent','show-error','fail-with-body'])+'\n'
 cp=subprocess.run(['curl','--config','-'],input=cfg,text=True,capture_output=True,timeout=7200)
 if cp.returncode!=0:raise RuntimeError('upload failed: '+cp.stderr.replace(key,'[REDACTED]')[:1000])
 x=json.loads(cp.stdout);assert x.get('id') and x.get('path');return x
def response_integrations(x):
 found=set()
 def walk(v):
  if isinstance(v,dict):
   ii=integ_id(v)
   if ii:found.add(ii)
   for z in v.values():walk(z)
  elif isinstance(v,list):
   for z in v:walk(z)
 walk(x);return found
def guard():
 lock=json.loads((ROOT/'.ownership-lock.json').read_text());ready=json.loads((ROOT/'log/publish-ready.json').read_text());assert (lock['project_id'],lock['run_id'],lock['owner'],lock['status'])==(PROJECT,RUN,'zoro','main_session_pipeline');assert ready['verified'] and ready['status']=='ready' and ready['source_canon_sha256']==CANON and ready['upload_copy_sha256']==VIDEO_SHA;assert sha(VIDEO)==VIDEO_SHA and VIDEO.stat().st_size==776257735<1_000_000_000 and sha(THUMB)==THUMB_SHA and sha(ROOT/'output/info.txt')==META_SHA

def main():
 guard();assert not (ROOT/'log/postiz-schedule-intent.json').exists() and not (ROOT/'log/postiz-schedule.json').exists();env=load_env();base,key=env['POSTIZ_BASE_URL'],env['POSTIZ_API_KEY'];fb,yt=env['POSTIZ_FACEBOOK_INTEGRATION_ID'],env['POSTIZ_YOUTUBE_INTEGRATION_ID'];assert (fb,yt)==('cmrijlulx000jj7c8jo4ybyn6','cmrhxbsdv000bj7c868iio3c7');tz=ZoneInfo(env['POSTIZ_TIMEZONE']);assert env['POSTIZ_TIMEZONE']=='Asia/Ho_Chi_Minh';slots=[x.strip() for x in env['POSTIZ_SLOTS'].split(',') if x.strip()];assert slots==['09:20','19:00']
 _,conn=api(base,key,'/is-connected');assert conn.get('connected') is True
 _,ints=api(base,key,'/integrations');items=ints if isinstance(ints,list) else ints.get('integrations',[]);by={x.get('id'):x for x in items};assert by[fb].get('identifier')=='facebook' and by[yt].get('identifier')=='youtube' and by[fb].get('name')=='Hạ Trâm Audio' and by[yt].get('name')=='Hạ Trâm Audio' and by[fb].get('disabled') is not True and by[yt].get('disabled') is not True
 find={};
 for iid in (fb,yt):
  _,x=api(base,key,'/find-slot/'+iid);find[iid]=x.get('date')
 start=dt.datetime.now(dt.timezone.utc)-dt.timedelta(days=1);end=start+dt.timedelta(days=32);_,target,other=calendar(base,key,fb,yt,start,end);pre_local,pre_utc=choose_slot(target,fb,yt,tz,slots);atomic('log/postiz-preflight.json',{'schema_version':1,'project_id':PROJECT,'run_id':RUN,'status':'passed','verified':True,'integration_ids':{'facebook':fb,'youtube':yt},'find_slot_reference':find,'candidate_local':pre_local.isoformat(),'candidate_utc':pre_utc.isoformat().replace('+00:00','Z'),'target_posts_seen':len(target),'other_integration_posts_ignored':other,'credential':'[REDACTED]','created_at':now()})
 guard();video=curl_upload(base,key,VIDEO,'video/mp4');atomic('log/postiz-upload-video.json',{'schema_version':1,'project_id':PROJECT,'run_id':RUN,'status':'completed','verified':True,'source_canon_sha256':CANON,'local_path':str(VIDEO),'local_sha256':VIDEO_SHA,'local_bytes':VIDEO.stat().st_size,'remote':{'id':video['id'],'path':video['path']},'credential':'[REDACTED]','completed_at':now()})
 thumb=curl_upload(base,key,THUMB,'image/png');atomic('log/postiz-upload-thumbnail.json',{'schema_version':1,'project_id':PROJECT,'run_id':RUN,'status':'completed','verified':True,'source_canon_sha256':CANON,'local_path':str(THUMB),'local_sha256':THUMB_SHA,'local_bytes':THUMB.stat().st_size,'remote':{'id':thumb['id'],'path':thumb['path']},'credential':'[REDACTED]','completed_at':now()})
 _,target2,other2=calendar(base,key,fb,yt,start,end);local,utc=choose_slot(target2,fb,yt,tz,slots);info=(ROOT/'output/info.txt').read_text();title=info.splitlines()[0];assert 2<=len(title)<=100
 media={'id':video['id'],'path':video['path']};payload={'type':'schedule','date':utc.isoformat().replace('+00:00','Z'),'shortLink':False,'tags':[],'posts':[{'integration':{'id':fb},'value':[{'content':info,'image':[media]}],'settings':{'__type':'facebook'}},{'integration':{'id':yt},'value':[{'content':info,'image':[media]}],'settings':{'__type':'youtube','title':title,'type':'public','selfDeclaredMadeForKids':'no','thumbnail':{'id':thumb['id'],'path':thumb['path']},'tags':[]}}]};roundtrip=json.loads(json.dumps(payload,ensure_ascii=False));assert len(roundtrip['posts'])==2 and {p['integration']['id'] for p in roundtrip['posts']}=={fb,yt} and all(p['value'][0]['image'][0].get('id') and p['value'][0]['image'][0].get('path') for p in roundtrip['posts'])
 ph=hashlib.sha256(json.dumps(payload,ensure_ascii=False,sort_keys=True,separators=(',',':')).encode()).hexdigest();idem=hashlib.sha256('|'.join([PROJECT,CANON,RUN,VIDEO_SHA,THUMB_SHA,fb,yt]).encode()).hexdigest();intent={'schema_version':1,'project_id':PROJECT,'run_id':RUN,'status':'creating','idempotency_key':idem,'source_canon_sha256':CANON,'video_sha256':VIDEO_SHA,'thumbnail_sha256':THUMB_SHA,'remote_media_ids':{'video':video['id'],'thumbnail':thumb['id']},'slot_local':local.isoformat(),'slot_utc':utc.isoformat().replace('+00:00','Z'),'payload_sha256':ph,'other_integration_posts_ignored':other2,'credential':'[REDACTED]','created_at':now()};atomic('log/postiz-schedule-intent.json',intent)
 try:status,created=api(base,key,'/posts','POST',payload,attempts=1)
 except Exception as e:
  intent.update({'status':'ambiguous','error':str(e).replace(key,'[REDACTED]')[:1000],'updated_at':now()});atomic('log/postiz-schedule-intent.json',intent);raise
 found=response_integrations(created);assert fb in found and yt in found
 intent.update({'status':'created_response_received','http_status':status,'response':created,'updated_at':now()});atomic('log/postiz-schedule-intent.json',intent)
 matches=[]
 for _ in range(18):
  _,t,_=calendar(base,key,fb,yt,utc-dt.timedelta(hours=1),utc+dt.timedelta(hours=1));matches=[p for p in t if post_ts(p) and abs((post_ts(p)-utc).total_seconds())<1 and state(p)=='QUEUE'];ids={integ_id(p) for p in matches}
  if ids=={fb,yt} and sum(integ_id(p)==fb for p in matches)==1 and sum(integ_id(p)==yt for p in matches)==1:break
  time.sleep(5)
 else:
  intent.update({'status':'manual_reconciliation_required','calendar_matches':[{'id':p.get('id'),'integration_id':integ_id(p),'state':state(p)} for p in matches],'updated_at':now()});atomic('log/postiz-schedule-intent.json',intent);raise RuntimeError('calendar read-back did not show exactly one QUEUE per integration')
 posts={integ_id(p):p for p in matches};receipt={'schema_version':1,'project_id':PROJECT,'run_id':RUN,'status':'scheduled_verified','verified':True,'scheduled':True,'published':False,'source_canon_sha256':CANON,'idempotency_key':idem,'slot_local':local.isoformat(),'slot_utc':utc.isoformat().replace('+00:00','Z'),'video':{'local_sha256':VIDEO_SHA,'local_bytes':VIDEO.stat().st_size,'remote_id':video['id'],'remote_path':video['path']},'thumbnail':{'local_sha256':THUMB_SHA,'remote_id':thumb['id'],'remote_path':thumb['path']},'posts':{'facebook':{'integration_id':fb,'post_id':posts[fb].get('id'),'state':'QUEUE'},'youtube':{'integration_id':yt,'post_id':posts[yt].get('id'),'state':'QUEUE'}},'other_integration_posts_ignored':other2,'credential':'[REDACTED]','verified_at':now()};atomic('log/postiz-schedule.json',receipt);intent.update({'status':'completed','post_ids':[posts[fb].get('id'),posts[yt].get('id')],'completed_at':now()});intent.pop('response',None);atomic('log/postiz-schedule-intent.json',intent);print(json.dumps({'status':'scheduled_verified','slot_local':receipt['slot_local'],'slot_utc':receipt['slot_utc'],'facebook_post_id':posts[fb].get('id'),'youtube_post_id':posts[yt].get('id')}))
if __name__=='__main__':main()
