#!/usr/bin/env python3
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 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(any(x['state']=='QUEUE' for x in found[target]) 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')
 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())
