"""Offline portable assembly. No accounts, network calls or live ad writes.
Use an already edited SILENT 9:16 base whose length matches the treated narration.
The builder chooses the crop/padding and timing in the editor, then this tool adds
the banner, exact narration and script-authored word-highlight caption cards.
"""
import argparse, json, os, shutil, subprocess, tempfile
from pathlib import Path
from PIL import Image, ImageDraw
import burn_captions, karaoke_burn
from caption_cards import parse_word_srt, word_count

def run(cmd):
    subprocess.run(cmd, check=True)

def probe(path):
    return json.loads(subprocess.check_output(['ffprobe','-v','error','-show_streams','-show_format','-of','json',str(path)],text=True))

def main():
    p=argparse.ArgumentParser(description=__doc__)
    for name in ['video','audio','cards','words','banner','out','font']:p.add_argument('--'+name,required=True)
    p.add_argument('--caption-y',type=float,default=.68)
    p.add_argument('--banner-y',type=int,default=150)
    a=p.parse_args()
    for name in ['ffmpeg','ffprobe']:
        if not shutil.which(name):p.error(name+' is not on PATH')
    for name in ['video','audio','cards','words','font']:
        if not Path(getattr(a,name)).is_file():p.error('Missing '+name)
    if Path(a.out).exists():p.error('Output already exists; choose a new revision filename')
    os.environ['CREATIVE_FONT']=str(Path(a.font).resolve())
    cs=json.loads(Path(a.cards).read_text(encoding='utf-8'))
    words=parse_word_srt(a.words)
    if not cs or not all(isinstance(c,str) and 1<=word_count(c)<=3 for c in cs):p.error('Cards must be a JSON list of 1–3 word strings')
    if sum(word_count(c) for c in cs)!=len(words):p.error('Script/word-clock counts differ; repair alignment before rendering')
    vd,ad=probe(a.video),probe(a.audio)
    vs=next(s for s in vd['streams'] if s['codec_type']=='video')
    if (vs['width'],vs['height'])!=(1080,1920):p.error('Prepare a 1080x1920 silent base first; do not crop away the repair')
    duration=float(ad['format']['duration'])
    if abs(float(vd['format']['duration'])-duration)>.15:p.error('Base duration must match treated audio within 0.15 seconds')
    if not words or words[-1][1]>duration+.1:p.error('Word clock extends beyond audio')
    if not .15<=a.caption_y<=.86:p.error('caption-y must be 0.15–0.86; inspect platform overlays')
    for i,(s,e,_) in enumerate(words):
        if e<=s or s<0 or (i and s<words[i-1][1]-.002):p.error('Invalid or overlapping word timestamps')
    karaoke_burn.cards=lambda _:cs
    with tempfile.TemporaryDirectory() as td:
        td=Path(td); f=burn_captions.font(64)
        d=ImageDraw.Draw(Image.new('RGBA',(1,1)));box=d.textbbox((0,0),a.banner,font=f)
        if box[2]-box[0]>960:p.error('Banner too long; shorten it')
        banner=Image.new('RGBA',(box[2]-box[0]+64,box[3]-box[1]+42),(0,0,0,0))
        d=ImageDraw.Draw(banner);d.rounded_rectangle((0,0,banner.width-1,banner.height-1),radius=35,fill='white')
        d.text((32-box[0],21-box[1]),a.banner,font=f,fill='black');banner.save(td/'banner.png')
        base=td/'base.mp4'
        run(['ffmpeg','-v','error','-i',a.video,'-i',a.audio,'-i',str(td/'banner.png'),'-filter_complex',f'[0:v][2:v]overlay=(W-w)/2:{a.banner_y}[v]','-map','[v]','-map','1:a:0','-t',str(duration),'-r','30','-c:v','libx264','-crf','18','-pix_fmt','yuv420p','-c:a','aac','-b:a','192k','-movflags','+faststart',str(base)])
        karaoke_burn.burn_karaoke(str(base),'custom',a.words,a.out,a.caption_y)
    run(['ffmpeg','-v','error','-i',a.out,'-f','null','-'])
    Path(a.out+'.build.json').write_text(json.dumps({'inputs':vars(a),'duration':duration,'cards':cs,'full_decode':'pass','visual_qa':'pending','native_windows_test':'not performed by author'},indent=2))
    print('Rendered and decoded. Watch the entire video, including caption boundaries, before marking visual QA pass.')

if __name__=='__main__':main()
