#!/usr/bin/env python3
"""Render Everyday Red Flags 003: Bank alert / one-time-code scam.

Policy: AI-generated scene images + text-only active-word subtitles.
No renderer arrows, boxes, circles, progress bars, cards, labels, or caption boxes.
"""
from __future__ import annotations
import hashlib, json, math, subprocess, tempfile, time, datetime
from pathlib import Path
from typing import Any
import requests
from PIL import Image, ImageDraw, ImageFont

ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "data" / "post_candidates" / "everyday-red-flags-bank-code-scam"
AUDIO = OUT_DIR / "chatterbox_taylor_scientific_v1_forced_words_2line" / "erf-003_voiceover_chatterbox_taylor_scientific.wav"
WORDS_JSON = OUT_DIR / "chatterbox_taylor_scientific_v1_forced_words_2line" / "erf-003_voiceover_chatterbox_taylor_scientific.faster_whisper_words.json"
VIDEO_NO_AUDIO = OUT_DIR / "chatterbox_taylor_scientific_v1_forced_words_2line" / "video_no_audio_forced_words_2line.mp4"
FINAL = OUT_DIR / "chatterbox_taylor_scientific_v1_forced_words_2line" / "erf-003_bank_code_scam_chatterbox_taylor_forced_words_2line_1080x1920_review.mp4"
CONTACT = OUT_DIR / "chatterbox_taylor_scientific_v1_forced_words_2line" / "contact_sheet_keyframes.png"
QA_SECONDS = (1, 8, 16, 25, 34)
QA_FRAMES = [OUT_DIR / "chatterbox_taylor_scientific_v1_forced_words_2line" / f"qa_frame_{sec:02d}.png" for sec in QA_SECONDS]
WIDTH, HEIGHT, FPS = 1080, 1920, 30
SCRIPT = (
    "If a bank message says, fraud alert, and a caller asks for your security code, stop. "
    "That code is not proof they are the bank. It is the key that lets them log in as you. "
    "The red flag is the switch. A text creates fear. Then a voice asks you to read the code out loud. "
    "Do not reply. Do not read any code. Hang up. "
    "Open your banking app yourself, or call the number on the back of your card. "
    "If the alert is real, it will still be there. Rule: your code is for you, never for the caller."
)
SCENES = [
    (0.0, 6.4, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_220605_b918c633.png"),
    (6.4, 12.6, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_220718_0601a12b.png"),
    (12.6, 20.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_220820_9cb5e17b.png"),
    (20.0, 27.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_220919_97f74661.png"),
    (27.0, 33.8, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_221015_1650be73.png"),
    (33.8, 42.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_221108_2cd1d090.png"),
]

def font(size:int,bold:bool=False):
    for c in ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf"]:
        if Path(c).exists(): return ImageFont.truetype(c,size=size)
    return ImageFont.load_default()

def text_size(draw:ImageDraw.ImageDraw,text:str,fnt)->tuple[int,int]:
    b=draw.textbbox((0,0),text,font=fnt); return int(b[2]-b[0]), int(b[3]-b[1])

def ffprobe_duration(path:Path)->float:
    return float(subprocess.check_output(["ffprobe","-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",str(path)],text=True).strip())

def sha256(path:Path)->str:
    h=hashlib.sha256()
    with path.open('rb') as f:
        for chunk in iter(lambda:f.read(1024*1024), b''): h.update(chunk)
    return h.hexdigest()

def synthesize_chatterbox()->None:
    AUDIO.parent.mkdir(parents=True, exist_ok=True)
    if AUDIO.exists(): return
    payload={'text':SCRIPT,'voice_mode':'predefined','predefined_voice_id':'Taylor.wav','output_format':'wav','temperature':0.7,'exaggeration':0.3,'cfg_weight':0.5,'seed':4103,'speed_factor':1.0,'language':'en','split_text':True,'chunk_size':180,'stream':False}
    t=time.time(); r=requests.post('http://100.101.173.25:8004/tts',json=payload,timeout=180); elapsed=round(time.time()-t,3)
    if r.ok and r.headers.get('content-type','').startswith('audio/'):
        AUDIO.write_bytes(r.content)
    meta={'provider':'chatterbox_http','voice':'Taylor.wav','preset':'Taylor + Scientific Abstract Reading','endpoint':'http://100.101.173.25:8004/tts','params':payload,'script_sha256':hashlib.sha256(SCRIPT.encode()).hexdigest(),'success':r.ok,'http_status':r.status_code,'content_type':r.headers.get('content-type'),'elapsed_sec':elapsed,'output_path':str(AUDIO),'duration':ffprobe_duration(AUDIO) if AUDIO.exists() else None,'output_sha256':sha256(AUDIO) if AUDIO.exists() else None,'created_at':datetime.datetime.now(datetime.timezone.utc).isoformat()}
    (AUDIO.parent/'erf-003_voiceover_chatterbox_taylor_scientific.metadata.json').write_text(json.dumps(meta,indent=2),encoding='utf-8')
    if not r.ok or not AUDIO.exists(): raise RuntimeError(meta)

def force_align_words()->list[dict[str,Any]]:
    if WORDS_JSON.exists():
        data=json.loads(WORDS_JSON.read_text()); return [{'word':w['word'],'start':float(w['start']),'duration':max(0.05,float(w.get('duration',float(w['end'])-float(w['start']))))} for w in data['words']]
    from faster_whisper import WhisperModel
    t=time.time(); model=WhisperModel('base.en', device='cpu', compute_type='int8')
    segments, info=model.transcribe(str(AUDIO), language='en', word_timestamps=True, vad_filter=False, beam_size=5)
    words=[]; segs=[]
    for s in segments:
        seg={'start':s.start,'end':s.end,'text':s.text,'words':[]}
        if s.words:
            for w in s.words:
                if not w.word.strip(): continue
                item={'word':w.word.strip(),'start':float(w.start),'end':float(w.end),'duration':max(0.05,float(w.end-w.start))}
                words.append(item); seg['words'].append(item)
        segs.append(seg)
    WORDS_JSON.write_text(json.dumps({'model':'base.en','language':info.language,'duration':info.duration,'elapsed':round(time.time()-t,3),'word_count':len(words),'segments':segs,'words':words},indent=2),encoding='utf-8')
    return [{'word':w['word'],'start':float(w['start']),'duration':float(w['duration'])} for w in words]

def build_segments(words:list[dict[str,Any]])->list[list[dict[str,Any]]]:
    source_tokens=SCRIPT.replace('—',' ').split(); phrase_breakers={'stop','you','loud','reply','code','yourself','card','there','caller'}
    segments=[]; cur=[]
    for i,w in enumerate(words):
        cur.append(w); raw=source_tokens[i] if i < len(source_tokens) else str(w['word'])
        cleaned=raw.strip().strip('“”"').lower().rstrip('.,?!:;')
        hard=raw.endswith(('.', '?', '!', ':'))
        gap=False
        if i+1 < len(words): gap=float(words[i+1]['start'])-(float(w['start'])+float(w['duration']))>0.42
        if hard or len(cur)>=7 or (len(cur)>=4 and cleaned in phrase_breakers) or (gap and len(cur)>=4): segments.append(cur); cur=[]
    if cur: segments.append(cur)
    return segments

def segment_for_time(t:float, segments):
    for seg in segments:
        start=float(seg[0]['start']); end=float(seg[-1]['start'])+float(seg[-1]['duration'])+0.16
        if start <= t <= end: return seg
    return []

def active_word_index(t:float, seg)->int:
    for i,w in enumerate(seg):
        start=float(w['start']); end=start+float(w['duration'])+0.07
        if start <= t <= end: return i
    return max(0, min(len(seg)-1, sum(1 for w in seg if float(w['start']) <= t)-1))

def load_images()->list[Image.Image]:
    out=[]
    for _,_,p in SCENES:
        path=Path(p)
        if not path.exists(): raise FileNotFoundError(path)
        img=Image.open(path).convert('RGB')
        scale=max(WIDTH/img.width, HEIGHT/img.height); new=(math.ceil(img.width*scale), math.ceil(img.height*scale))
        img=img.resize(new, Image.Resampling.LANCZOS); left=(img.width-WIDTH)//2; top=(img.height-HEIGHT)//2
        out.append(img.crop((left,top,left+WIDTH,top+HEIGHT)))
    return out

def scene_index(t:float)->int:
    for i,(s,e,_) in enumerate(SCENES):
        if s <= t < e: return i
    return len(SCENES)-1

def local_progress(t:float, idx:int)->float:
    s,e,_=SCENES[idx]; return max(0.0,min(1.0,(t-s)/max(0.001,e-s)))

def motion(img:Image.Image,p:float,idx:int)->Image.Image:
    zoom=1.015+0.025*p; w=int(WIDTH/zoom); h=int(HEIGHT/zoom)
    dx=int(24*math.sin((p+idx*0.19)*math.tau)*0.5); dy=int(18*math.cos((p+idx*0.13)*math.tau)*0.5)
    left=max(0,min(WIDTH-w,WIDTH//2+dx-w//2)); top=max(0,min(HEIGHT-h,HEIGHT//2+dy-h//2))
    return img.crop((left,top,left+w,top+h)).resize((WIDTH,HEIGHT), Image.Resampling.LANCZOS)

def crossfade(frame:Image.Image, images:list[Image.Image], t:float, idx:int, p:float)->Image.Image:
    if idx>0 and p<0.25:
        start,_,_=SCENES[idx]; a=max(0.0,min(1.0,(t-start)/0.25)); prev=motion(images[idx-1],1.0,idx-1); return Image.blend(prev,frame,a)
    return frame

def draw_subtitles(img:Image.Image, seg, active:int)->Image.Image:
    if not seg: return img
    draw=ImageDraw.Draw(img); words=[str(w['word']).strip().upper() for w in seg if str(w['word']).strip()]
    if not words: return img
    lines=[words] if len(words)<=4 else [words[:math.ceil(len(words)/2)], words[math.ceil(len(words)/2):]]
    max_total=WIDTH-150; gap=30; fnt=font(58,True)
    for size in range(60,38,-2):
        cand=font(size,True); ok=True
        for line in lines:
            widths=[text_size(draw,word,cand)[0] for word in line]
            if sum(widths)+gap*(len(line)-1)>max_total: ok=False; break
        if ok: fnt=cand; break
    y0=1465 if len(lines)>1 else 1518; line_h=68 if len(lines)>1 else 74; gi=0
    for li,line in enumerate(lines):
        widths=[text_size(draw,word,fnt)[0] for word in line]; total=sum(widths)+gap*(len(line)-1); x=(WIDTH-total)//2; y=y0+li*line_h
        for word,ww in zip(line,widths):
            fill=(255,214,64) if gi==active else (255,255,255)
            draw.text((x,y),word,font=fnt,fill=fill,stroke_width=5,stroke_fill=(0,0,0)); x += ww+gap; gi += 1
    return img

def make_contact_sheet(images:list[Image.Image])->None:
    sheet=Image.new('RGB',(810,960),(16,16,16))
    for i,img in enumerate(images): sheet.paste(img.resize((270,480),Image.Resampling.LANCZOS),((i%3)*270,(i//3)*480))
    sheet.save(CONTACT,quality=94)

def render(words:list[dict[str,Any]], audio_duration:float)->None:
    # Scale scene timings to actual Chatterbox duration + small tail.
    global SCENES
    old_total=SCENES[-1][1]; scene_total=audio_duration+0.45; SCENES=[(s/old_total*scene_total,e/old_total*scene_total,p) for s,e,p in SCENES]
    images=load_images(); make_contact_sheet(images); segments=build_segments(words)
    total_duration=max(audio_duration+0.45, SCENES[-1][1]); total_frames=math.ceil(total_duration*FPS)
    with tempfile.TemporaryDirectory(prefix='erf003_frames_') as tmp:
        frame_dir=Path(tmp)/'frames'; frame_dir.mkdir()
        for n in range(total_frames):
            t=n/FPS; idx=scene_index(t); p=local_progress(t,idx); frame=motion(images[idx],p,idx); frame=crossfade(frame,images,t,idx,p)
            seg=segment_for_time(t,segments); frame=draw_subtitles(frame,seg,active_word_index(t,seg) if seg else 0)
            frame.save(frame_dir/f'frame_{n:06d}.jpg',quality=94,subsampling=1)
        subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-framerate',str(FPS),'-i',str(frame_dir/'frame_%06d.jpg'),'-vf','setsar=1,setdar=9/16,format=yuv420p','-c:v','libx264','-preset','slow','-crf','16','-movflags','+faststart',str(VIDEO_NO_AUDIO)])
    subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-i',str(VIDEO_NO_AUDIO),'-i',str(AUDIO),'-map','0:v:0','-map','1:a:0','-c:v','copy','-c:a','aac','-b:a','192k','-shortest','-movflags','+faststart',str(FINAL)])
    for sec,out in zip(QA_SECONDS,QA_FRAMES): subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-ss',str(sec),'-i',str(FINAL),'-frames:v','1',str(out)])

def probe(path:Path)->dict[str,Any]:
    meta=json.loads(subprocess.check_output(['ffprobe','-v','error','-select_streams','v:0','-show_entries','stream=width,height,sample_aspect_ratio,display_aspect_ratio,r_frame_rate','-show_entries','format=duration,size,bit_rate','-of','json',str(path)],text=True))
    s=meta['streams'][0]; f=meta['format']
    return {'width':s['width'],'height':s['height'],'sample_aspect_ratio':s.get('sample_aspect_ratio'),'display_aspect_ratio':s.get('display_aspect_ratio'),'frame_rate':s.get('r_frame_rate'),'duration':float(f['duration']),'size_bytes':int(f['size']),'bit_rate':int(f.get('bit_rate',0))}

def main()->dict[str,Any]:
    OUT_DIR.mkdir(parents=True, exist_ok=True); AUDIO.parent.mkdir(parents=True, exist_ok=True)
    synthesize_chatterbox(); words=force_align_words(); audio_duration=ffprobe_duration(AUDIO); render(words,audio_duration)
    segments=build_segments(words); data={'candidate_id':'erf-003-bank-code-scam','version':'chatterbox_taylor_scientific_v1_forced_words_2line','script':SCRIPT,'scene_images':[p for _,_,p in SCENES],'caption_alignment':'faster_whisper_word_timestamps_source_punctuation_segments','caption_style':'static_phrase_segment_2line_active_word_highlight','word_count':len(words),'segment_count':len(segments),'audio_duration':audio_duration,'video_meta':probe(FINAL),'sha256':sha256(FINAL),'output':str(FINAL),'audio':str(AUDIO),'contact_sheet':str(CONTACT),'qa_frames':[str(p) for p in QA_FRAMES],'notes':'Chatterbox Taylor/Scientific voice, forced word timing, source-punctuation segment breaks, stable 2-line active-word subtitles, no non-subtitle renderer overlays. Thumbnail intentionally omitted; user sets thumbnail manually.'}
    (AUDIO.parent/'caption_alignment_metadata.json').write_text(json.dumps(data,indent=2),encoding='utf-8')
    return data
if __name__ == '__main__': print(json.dumps(main(),indent=2))
