#!/usr/bin/env python3
from pathlib import Path
from zoneinfo import ZoneInfo
import datetime as dt,hashlib,json,os,stat,subprocess,time,urllib.parse,urllib.request
ROOT=Path('/data/video-pipeline/HaTramAudio/project/023-Buc-Thu-Khong-Gui-O-Ngan-Ban-Cuoi');RUN='run-20260721T183334Z-cc725d7b';CANON='e4abc410d33fdde939459c170dccfc1c7b86bf8341d4f6a0cc1f3770d4c9566b';VIDEO=ROOT/'output/final-upload.mp4';THUMB=ROOT/'image/normalized/intro-poster-1920x1080.png';VH='83c4b4c394046b2d86a389b4bffa9144d25df8f92d44f2876b4f359fdefa28bd';TH='8f60544d5e8aa007964abae3c6172d568af8af3c65cbe036e15cdda46a77b31c'
def now():return dt.datetime.now(dt.timezone.utc).isoformat().replace('+00:00','Z')
def sha(p):
 h=hashlib.sha256()
 with open(p,'rb') as f:
  while b:=f.read(8*1024*1024):h.update(b)
 return h.hexdigest()
def atomic(p,o):
 p.parent.mkdir(parents=True,exist_ok=True);t=p.with_suffix(p.suffix+'.tmp');t.write_text(json.dumps(o,ensure_ascii=False,indent=2)+'\n');t.replace(p)
def env():
 e=os.environ.copy();p=Path.home()/'.config/ha-tram-audio/postiz.env';assert p.exists() and stat.S_IMODE(p.stat().st_mode)==0o600
 for line in p.read_text().splitlines():
  if '=' in line and not line.lstrip().startswith('#'):
   k,v=line.split('=',1);e.setdefault(k.strip(),v.strip().strip('"').strip("'"))
 keys=['POSTIZ_BASE_URL','POSTIZ_API_KEY','POSTIZ_FACEBOOK_INTEGRATION_ID','POSTIZ_YOUTUBE_INTEGRATION_ID','POSTIZ_TIMEZONE','POSTIZ_SLOTS'];assert all(e.get(k) for k in keys);return e
def req(e,method,path,data=None):
 u=e['POSTIZ_BASE_URL'].rstrip('/')+path;headers={'Authorization':e['POSTIZ_API_KEY']}
 if data is not None:headers['Content-Type']='application/json';data=json.dumps(data).encode()
 for n in range(3):
  try:
   with urllib.request.urlopen(urllib.request.Request(u,data=data,headers=headers,method=method),timeout=60) as r:return json.loads(r.read() or b'{}')
  except Exception:
   if method!='GET' or n==2:raise
   time.sleep(2**n)
def upload(e,path,key,expected):
 rp=ROOT/f'log/postiz-upload-{key}.json'
 if rp.exists():
  r=json.loads(rp.read_text());assert r['local_sha256']==expected and r['local_size_bytes']==path.stat().st_size;return r['media']
 safe_path=str(path).replace('"','\\"')
 cfg=(f'url = "{e["POSTIZ_BASE_URL"].rstrip("/")}/upload"\n'
      f'header = "Authorization: {e["POSTIZ_API_KEY"]}"\n'
      f'form = "file=@{safe_path}"\nfail-with-body\nsilent\nshow-error\n')
 cp=subprocess.run(['curl','--config','-'],input=cfg,text=True,capture_output=True)
 if cp.returncode!=0:raise RuntimeError('upload failed: '+cp.stderr[-500:])
 raw=json.loads(cp.stdout);m=raw.get('data',raw);m=m[0] if isinstance(m,list) else m;assert isinstance(m,dict) and m.get('id') and m.get('path')
 atomic(rp,{'schema_version':1,'project_id':ROOT.name,'run_id':RUN,'status':'completed','verified':True,'source_canon_sha256':CANON,'local_path':str(path),'local_sha256':expected,'local_size_bytes':path.stat().st_size,'media':{'id':m['id'],'path':m['path']},'credential':'[REDACTED]','created_at':now()});return {'id':m['id'],'path':m['path']}
def posts(raw):return raw.get('posts',raw.get('data',[])) if isinstance(raw,dict) else raw
def stamp(x):return x.get('publishDate') or x.get('date')
def choose(e,fb,yt):
 tz=ZoneInfo(e['POSTIZ_TIMEZONE']);local=dt.datetime.now(tz);start=local.astimezone(dt.timezone.utc)-dt.timedelta(days=1);end=start+dt.timedelta(days=45);raw=req(e,'GET','/posts?'+urllib.parse.urlencode({'startDate':start.isoformat().replace('+00:00','Z'),'endDate':end.isoformat().replace('+00:00','Z')}));ps=posts(raw);occupied={i:set() for i in (fb,yt)};other=0
 for p in ps:
  iid=(p.get('integration') or {}).get('id');s=stamp(p);state=p.get('state')
  if iid in occupied and s and state in ('QUEUE','PUBLISHED'):
   d=dt.datetime.fromisoformat(s.replace('Z','+00:00') if ('Z' in s or '+' in s) else s+'+00:00');occupied[iid].add(d.astimezone(dt.timezone.utc).replace(microsecond=0))
  elif iid not in occupied:other+=1
 slots=[]
 for token in e['POSTIZ_SLOTS'].replace(',',' ').split():
  try:h,m=map(int,token.split(':'));slots.append((h,m))
  except:pass
 assert slots
 for day in range(45):
  date=local.date()+dt.timedelta(days=day)
  for h,m in sorted(slots):
   cand=dt.datetime.combine(date,dt.time(h,m),tzinfo=tz)
   if cand<=local:continue
   utc=cand.astimezone(dt.timezone.utc).replace(microsecond=0)
   if all(utc not in occupied[i] for i in (fb,yt)):return utc,cand,other
 raise RuntimeError('no slot')
def main():
 e=env();assert sha(VIDEO)==VH and VIDEO.stat().st_size<1_000_000_000 and sha(THUMB)==TH;ready=json.loads((ROOT/'log/publish-ready.json').read_text());assert ready['verified'] and ready['source_canon_sha256']==CANON
 assert req(e,'GET','/is-connected')['connected'] is True;ints=req(e,'GET','/integrations');ints=ints.get('integrations',ints.get('data',ints)) if isinstance(ints,dict) else ints;fb=e['POSTIZ_FACEBOOK_INTEGRATION_ID'];yt=e['POSTIZ_YOUTUBE_INTEGRATION_ID']
 for iid,ident in ((fb,'facebook'),(yt,'youtube')):
  found=[x for x in ints if x.get('id')==iid];assert len(found)==1 and found[0].get('identifier')==ident and found[0].get('name')=='Hạ Trâm Audio' and found[0].get('disabled') is not True;req(e,'GET','/find-slot/'+iid)
 slot,local,other=choose(e,fb,yt);video=upload(e,VIDEO,'video',VH);thumb=upload(e,THUMB,'thumbnail',TH);slot,local,other=choose(e,fb,yt)
 info=(ROOT/'output/info.txt').read_text();title=info.splitlines()[0].strip();assert 2<=len(title)<=100
 payload={'type':'schedule','date':slot.isoformat().replace('+00:00','Z'),'shortLink':False,'tags':[],'posts':[{'integration':{'id':fb},'value':[{'content':info,'image':[video]}],'settings':{'__type':'facebook'}},{'integration':{'id':yt},'value':[{'content':info,'image':[video]}],'settings':{'__type':'youtube','title':title,'type':'public','selfDeclaredMadeForKids':'no','thumbnail':thumb,'tags':[]}}]};assert len(payload['posts'])==2 and len({x['integration']['id'] for x in payload['posts']})==2
 intentp=ROOT/'log/postiz-schedule-intent.json';assert not intentp.exists();ph=hashlib.sha256(json.dumps(payload,sort_keys=True,separators=(',',':')).encode()).hexdigest();idem=hashlib.sha256((ROOT.name+CANON+RUN+VH+TH+fb+yt).encode()).hexdigest();atomic(intentp,{'schema_version':1,'project_id':ROOT.name,'run_id':RUN,'status':'creating','idempotency_key':idem,'slot_utc':payload['date'],'payload_sha256':ph,'video_sha256':VH,'thumbnail_sha256':TH,'remote_media_ids':[video['id'],thumb['id']],'created_at':now()})
 response=req(e,'POST','/posts',payload);time.sleep(2);window_start=(slot-dt.timedelta(minutes=2)).isoformat().replace('+00:00','Z');window_end=(slot+dt.timedelta(minutes=2)).isoformat().replace('+00:00','Z');cal=posts(req(e,'GET','/posts?'+urllib.parse.urlencode({'startDate':window_start,'endDate':window_end})));found={}
 for iid in (fb,yt):
  q=[x for x in cal if (x.get('integration') or {}).get('id')==iid and x.get('state')=='QUEUE' and stamp(x) and dt.datetime.fromisoformat(stamp(x).replace('Z','+00:00') if ('Z' in stamp(x) or '+' in stamp(x)) else stamp(x)+'+00:00').astimezone(dt.timezone.utc).replace(microsecond=0)==slot]
  assert len(q)==1,(iid,len(q));found[iid]={'post_id':q[0].get('id'),'state':'QUEUE'}
 rec={'schema_version':1,'project_id':ROOT.name,'run_id':RUN,'status':'scheduled_verified','verified':True,'scheduled':True,'published':False,'source_canon_sha256':CANON,'slot_utc':payload['date'],'slot_local':local.isoformat(),'integration_ids':{'facebook':fb,'youtube':yt},'posts':found,'video_media':video,'thumbnail_media':thumb,'video_sha256':VH,'thumbnail_sha256':TH,'other_integration_posts_ignored':other,'create_response':response,'credential':'[REDACTED]','verified_at':now()};atomic(ROOT/'log/postiz-schedule.json',rec);print(json.dumps({'status':'scheduled_verified','slot_local':local.strftime('%H:%M %d/%m/%Y'),'slot_utc':payload['date'],'posts':found,'other_ignored':other},ensure_ascii=False))
if __name__=='__main__':main()
