#!/usr/bin/env python3
import hashlib
import json
import urllib.error
import urllib.parse
import urllib.request
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo

PROJECT=Path(__file__).resolve().parents[1]
CONFIG=Path.home()/'.config/huyen-an-audio/postiz.env'
OUT=PROJECT/'log/postiz-schedule.json'
LOCAL=ZoneInfo('Asia/Ho_Chi_Minh')
OCCUPIED={'QUEUE','PUBLISHED'}
EXPECTED_FB='cmrq10vod000hj7cbt8zvuj6a';EXPECTED_YT='cmrpr0j9u000bj7cboqonlx4b'

def env():
 d={}
 for raw in CONFIG.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('"').strip("'")
 return d
def sha(path):
 h=hashlib.sha256()
 with path.open('rb') as f:
  for block in iter(lambda:f.read(8*1024*1024),b''):h.update(block)
 return h.hexdigest()
def request(base,key,method,path,payload=None):
 body=json.dumps(payload,ensure_ascii=False).encode() if payload is not None else None
 req=urllib.request.Request(base.rstrip('/')+path,data=body,headers={'Authorization':key,**({'Content-Type':'application/json'} if body else {})},method=method)
 try:
  with urllib.request.urlopen(req,timeout=120) as r:return json.load(r)
 except urllib.error.HTTPError as e:raise RuntimeError(f'Postiz HTTP {e.code}: {e.read().decode(errors="replace")[:4000]}') from e
def parse(value):
 dt=datetime.fromisoformat(str(value).replace('Z','+00:00'));return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
def iid(post):
 x=post.get('integration') or {};return x.get('id') if isinstance(x,dict) else None
def when(post):
 x=post.get('publishDate') or post.get('date');return parse(x) if x else None
def rows(payload):return payload.get('posts',payload.get('data',payload)) if isinstance(payload,dict) else payload
def calendar(base,key,start,end,targets):
 q=urllib.parse.urlencode({'startDate':start.isoformat().replace('+00:00','Z'),'endDate':end.isoformat().replace('+00:00','Z')})
 filtered={x:[] for x in targets}
 for post in rows(request(base,key,'GET','/posts?'+q)):
  integration=iid(post);state=str(post.get('state','')).upper();stamp=when(post)
  if integration in filtered and stamp and state in OCCUPIED:filtered[integration].append({'id':post.get('id'),'state':state,'date':stamp})
 return filtered
def candidates(now_utc,slots,days=31):
 now_local=now_utc.astimezone(LOCAL);result=[]
 parsed_slots=[]
 for item in slots.split(','):
  h,m=map(int,item.strip().split(':'));parsed_slots.append((h,m))
 for n in range(days):
  day=now_local.date()+timedelta(days=n)
  for h,m in parsed_slots:
   stamp=datetime(day.year,day.month,day.day,h,m,tzinfo=LOCAL).astimezone(timezone.utc)
   if stamp>now_utc:result.append(stamp)
 return sorted(result)
def choose(cands,filtered,targets):
 for stamp in cands:
  if all(not any(abs((x['date']-stamp).total_seconds())<1 for x in filtered[target]) for target in targets):return stamp
 raise RuntimeError('No common slot in candidate window')
def exact_found(base,key,slot,targets):
 exact=calendar(base,key,slot-timedelta(minutes=5),slot+timedelta(minutes=5),targets)
 return {target:[{'id':x['id'],'state':x['state'],'publishDate':x['date'].isoformat()} for x in exact[target] if abs((x['date']-slot).total_seconds())<1] for target in targets}
def both_queued(found,targets):
 return all(len(found[target])==1 and found[target][0]['state']=='QUEUE' for target in targets)
def main():
 required=[PROJECT/'log/postiz-media.json',PROJECT/'story/promotion-report.json',PROJECT/'log/upload-transcode.json']
 missing=[str(path) for path in required if not path.exists()]
 if missing:raise SystemExit('Postiz schedule blocked: missing verified prerequisites: '+', '.join(missing))
 gate=subprocess.run([sys.executable,str(PROJECT/'script/assert-publish-ready.py')],cwd=PROJECT)
 if gate.returncode:raise SystemExit('Postiz schedule blocked: publish gate failed')
 e=env();base=e['POSTIZ_BASE_URL'];key=e['POSTIZ_API_KEY'];fb=e['POSTIZ_FACEBOOK_INTEGRATION_ID'];yt=e['POSTIZ_YOUTUBE_INTEGRATION_ID'];slots=e.get('POSTIZ_SLOTS','09:20,19:00')
 if (fb,yt)!=(EXPECTED_FB,EXPECTED_YT):raise RuntimeError('Target integration IDs differ from skill authority')
 media=json.loads((PROJECT/'log/postiz-media.json').read_text());promotion=json.loads((PROJECT/'story/promotion-report.json').read_text());transcode=json.loads((PROJECT/'log/upload-transcode.json').read_text());canon=promotion['spoken_sha256']
 if media.get('verified') is not True or media.get('source_canon_sha256')!=canon:raise RuntimeError('Media receipt canon mismatch')
 if transcode.get('verified') is not True or transcode.get('source_canon_sha256')!=canon or transcode.get('server_verification',{}).get('under_one_gb') is not True:raise RuntimeError('Transcode receipt invalid')
 video_local=PROJECT/'output/final-upload.mp4';thumb_local=PROJECT/'image/intro-poster-1920x1080.png'
 if not video_local.exists() or not thumb_local.exists() or media.get('media',{}).get('video',{}).get('sha256')!=sha(video_local) or media.get('media',{}).get('thumbnail',{}).get('sha256')!=sha(thumb_local):raise RuntimeError('Media receipt hashes no longer match local publish assets')
 if OUT.exists():
  previous=json.loads(OUT.read_text())
  if previous.get('verified') is True:raise RuntimeError('Verified schedule receipt already exists; do not duplicate')
  if previous.get('status')=='partial_failure':raise RuntimeError('Partial schedule receipt requires manual review; refusing duplicate POST')
  if previous.get('status')=='submitting' and previous.get('source_canon_sha256')==canon and previous.get('slot_utc'):
   previous_slot=parse(previous['slot_utc']);found=exact_found(base,key,previous_slot,(fb,yt))
   if both_queued(found,(fb,yt)):
    previous.update({'verified':True,'status':'completed','posts':found,'resumed_after_uncertain_post':True,'checked_at':datetime.now(timezone.utc).isoformat()});OUT.write_text(json.dumps(previous,ensure_ascii=False,indent=2)+'\n');print(json.dumps({'verified':True,'resumed':True,'slot_local':previous['slot_local'],'posts':found},ensure_ascii=False));return 0
   if any(found[target] for target in (fb,yt)):raise RuntimeError('Partial or foreign occupancy at pending exact slot; manual review required')
 video=media['media']['video'];thumb=media['media']['thumbnail'];find={}
 for target in (fb,yt):
  data=request(base,key,'GET','/find-slot/'+target);find[target]=parse(data['date'])
 now=datetime.now(timezone.utc);cands=candidates(now,slots);filtered=calendar(base,key,now-timedelta(minutes=1),cands[-1]+timedelta(days=1),(fb,yt));slot=choose(cands,filtered,(fb,yt))
 # Calendar is the authority. Re-read immediately before create and choose again if needed.
 preflight=calendar(base,key,now-timedelta(minutes=1),cands[-1]+timedelta(days=1),(fb,yt));slot=choose(cands,preflight,(fb,yt))
 info=(PROJECT/'output/info.txt').read_text();title=info.split('TITLE\n',1)[1].split('\n\nDESCRIPTION\n',1)[0].strip();description=info.split('\n\nDESCRIPTION\n',1)[1].strip()
 payload={'type':'schedule','date':slot.isoformat().replace('+00:00','Z'),'shortLink':False,'tags':[],'posts':[{'integration':{'id':fb},'value':[{'content':description,'image':[{'id':video['id'],'path':video['path']}]}],'settings':{'__type':'facebook'}},{'integration':{'id':yt},'value':[{'content':description,'image':[{'id':video['id'],'path':video['path']}]}],'settings':{'__type':'youtube','title':title,'type':'public','selfDeclaredMadeForKids':'no','thumbnail':{'id':thumb['id'],'path':thumb['path']},'tags':[]}}]}
 receipt={'version':1,'verified':False,'status':'submitting','source_canon_sha256':canon,'selection_authority':'GET /posts filtered by exact target integration IDs from current time','slot_local':slot.astimezone(LOCAL).isoformat(),'slot_utc':slot.isoformat(),'find_slot_advisory_utc':{target:x.isoformat() for target,x in find.items()},'find_slot_was_not_lower_bound':True,'occupied_counts_filtered':{target:len(preflight[target]) for target in (fb,yt)},'ignored_other_integrations':True,'media':{'video':video,'thumbnail':thumb},'posts':{},'submitting_at':datetime.now(timezone.utc).isoformat()}
 OUT.write_text(json.dumps(receipt,ensure_ascii=False,indent=2)+'\n')
 request(base,key,'POST','/posts',payload)
 found=exact_found(base,key,slot,(fb,yt));verified=both_queued(found,(fb,yt))
 receipt.update({'verified':verified,'status':'completed' if verified else 'partial_failure','posts':found,'checked_at':datetime.now(timezone.utc).isoformat()})
 OUT.write_text(json.dumps(receipt,ensure_ascii=False,indent=2)+'\n');print(json.dumps({'verified':verified,'slot_local':receipt['slot_local'],'slot_utc':receipt['slot_utc'],'occupied_counts_filtered':receipt['occupied_counts_filtered'],'posts':found},ensure_ascii=False));return 0 if verified else 1
if __name__=='__main__':raise SystemExit(main())
