#!/usr/bin/env python3
"""Render Everyday Red Flags 007: Fake support popup call-now 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-fake-support-popup-scam"
VERSION_DIR = OUT_DIR / "chatterbox_taylor_scientific_v2_readable_silent_2line"
AUDIO = VERSION_DIR / "erf-007_voiceover_chatterbox_taylor_scientific.wav"
WORDS_JSON = VERSION_DIR / "erf-007_voiceover_chatterbox_taylor_scientific.faster_whisper_words.json"
VIDEO_NO_AUDIO = VERSION_DIR / "video_no_audio_forced_words_2line.mp4"
FINAL = VERSION_DIR / "erf-007_fake_support_popup_chatterbox_taylor_readable_silent_2line_1080x1920_review.mp4"
CONTACT = VERSION_DIR / "contact_sheet_keyframes.png"
QA_SECONDS = (1, 9, 18, 28, 38, 46)
QA_FRAMES = [VERSION_DIR / f"qa_frame_{sec:02d}.png" for sec in QA_SECONDS]
WIDTH, HEIGHT, FPS = 1080, 1920, 30
SCRIPT = (
    "If a website says virus found, stop. "
    "If it says call support now, stop again. "
    "That popup wants to control your next move. "
    "The red flag is the phone number on the warning screen. "
    "Real support does not trap you in a browser window. "
    "It does not need remote access from a scare popup. "
    "If you call, the scammer may ask you to install a help tool. "
    "Then they may ask for codes, payments, or your bank login. "
    "Do not call the number on the popup. Close the tab. "
    "If you need help, open the real app or website yourself. "
    "Use the support number listed in your account. "
    "Rule: real support does not hijack your screen. "
    "Share this with someone who panics when a warning appears."
)
SCENES = [
    (0.0, 7.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260528_084856_94d7e493.png"),
    (7.0, 14.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260528_085022_fcc0ede5.png"),
    (14.0, 21.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260528_085139_7d11a4c1.png"),
    (21.0, 29.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260528_085336_15d7a3f4.png"),
    (29.0, 37.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260528_085522_9ad6b22f.png"),
    (37.0, 45.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260528_085701_e79f3998.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:
    VERSION_DIR.mkdir(parents=True, exist_ok=True)
    if AUDIO.exists(): return
    # Render sentence-by-sentence and insert small pauses so silent viewers can read
    # complete caption ideas without the text racing like a caffeinated lawyer.
    import re
    sentences=[s.strip() for s in re.findall(r'[^.!?]+[.!?]', SCRIPT) if s.strip()]
    chunk_dir=VERSION_DIR/'tts_chunks'; chunk_dir.mkdir(parents=True, exist_ok=True)
    chunk_paths=[]; chunk_meta=[]
    for idx,sentence in enumerate(sentences, start=1):
        chunk_path=chunk_dir/f'chunk_{idx:02d}.wav'
        if not chunk_path.exists():
            payload={'text':sentence,'voice_mode':'predefined','predefined_voice_id':'Taylor.wav','output_format':'wav','temperature':0.7,'exaggeration':0.3,'cfg_weight':0.5,'seed':4207+idx,'speed_factor':1.0,'language':'en','split_text':False,'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/'):
                chunk_path.write_bytes(r.content)
            if not r.ok or not chunk_path.exists(): raise RuntimeError({'sentence':sentence,'http_status':r.status_code,'content_type':r.headers.get('content-type'),'body':r.text[:500]})
            chunk_meta.append({'sentence':sentence,'path':str(chunk_path),'duration':ffprobe_duration(chunk_path),'elapsed_sec':elapsed,'sha256':sha256(chunk_path)})
        else:
            chunk_meta.append({'sentence':sentence,'path':str(chunk_path),'duration':ffprobe_duration(chunk_path),'sha256':sha256(chunk_path)})
        chunk_paths.append(chunk_path)
    silence=VERSION_DIR/'pause_650ms.wav'
    if not silence.exists():
        subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-f','lavfi','-i','anullsrc=r=24000:cl=mono','-t','0.65',str(silence)])
    concat_file=VERSION_DIR/'tts_concat.txt'
    lines=[]
    for i,p in enumerate(chunk_paths):
        lines.append(f"file '{p.as_posix()}'")
        if i != len(chunk_paths)-1:
            lines.append(f"file '{silence.as_posix()}'")
    concat_file.write_text('\n'.join(lines)+'\n',encoding='utf-8')
    subprocess.check_call(['ffmpeg','-y','-hide_banner','-loglevel','error','-f','concat','-safe','0','-i',str(concat_file),'-c','copy',str(AUDIO)])
    meta={'provider':'chatterbox_http_sentence_chunks','voice':'Taylor.wav','preset':'Taylor + Scientific Abstract Reading','endpoint':'http://100.101.173.25:8004/tts','script_sha256':hashlib.sha256(SCRIPT.encode()).hexdigest(),'sentence_count':len(sentences),'inter_sentence_pause_ms':650,'chunks':chunk_meta,'success':True,'output_path':str(AUDIO),'duration':ffprobe_duration(AUDIO),'output_sha256':sha256(AUDIO),'created_at':datetime.datetime.now(datetime.timezone.utc).isoformat()}
    (VERSION_DIR/'erf-007_voiceover_chatterbox_taylor_scientific.metadata.json').write_text(json.dumps(meta,indent=2),encoding='utf-8')

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]]]:
    # Sentence-first, silent-viewer-friendly caption groups.
    # Build segment boundaries from the written script, then map them onto ASR words.
    # This prevents sentence splices and orphan words such as one final word on a new page.
    import re
    sentences=[s.strip() for s in re.findall(r'[^.!?]+[.!?]', SCRIPT) if s.strip()]
    def token_count(sentence:str)->int:
        # Match Whisper behaviour for hyphenated words like pop-up: usually two word cues.
        toks=sentence.replace('—',' ').replace('-',' ').replace('popup','pop up').replace('Popup','Pop up').split()
        return len(toks)
    def balanced_sizes(n:int)->list[int]:
        if n <= 0: return []
        # For silent comprehension, keep short sentences complete even if the
        # font shrinks slightly. A complete 9-10 word idea is better than an
        # incomplete page such as 'DO NOT CALL THE'.
        if n <= 11: return [n]
        if n <= 14:
            a=(n+1)//2
            return [a, n-a]
        # Prefer 6-8 word blocks, never leave <3 word tail.
        parts=[]; remaining=n
        while remaining > 0:
            if remaining <= 11:
                parts.append(remaining); break
            take=7
            if remaining - take < 3:
                take=max(3, remaining-3)
            parts.append(take); remaining-=take
        return parts
    segments=[]; cursor=0
    for sentence in sentences:
        n=token_count(sentence)
        sentence_words=words[cursor:cursor+n]
        cursor += n
        local=0
        for size in balanced_sizes(len(sentence_words)):
            seg=sentence_words[local:local+size]
            if seg: segments.append(seg)
            local += size
    if cursor < len(words):
        tail=words[cursor:]
        if segments and len(tail) < 3 and len(segments[-1])+len(tail) <= 8:
            segments[-1].extend(tail)
        elif tail:
            segments.append(tail)
    return [seg for seg in segments if len(seg) >= 1]

def segment_for_time(t:float, segments):
    for seg in segments:
        start=float(seg[0]['start'])
        if t < start: continue
        idx=segments.index(seg)
        next_start=float(segments[idx+1][0]['start']) if idx+1 < len(segments) else float(seg[-1]['start'])+float(seg[-1]['duration'])+0.9
        end=max(float(seg[-1]['start'])+float(seg[-1]['duration'])+0.35, next_start-0.05)
        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)
    units=[]
    for idx,w in enumerate(seg):
        raw=str(w['word']).strip().upper()
        if not raw: continue
        cleaned=raw.replace(' ', '')
        if units and cleaned in {'-UP','UP'} and units[-1][0].replace(' ', '') == 'POP':
            units[-1]=(units[-1][0]+'-UP', units[-1][1]+[idx])
        elif units and cleaned.startswith('-') and len(cleaned) <= 4:
            units[-1]=(units[-1][0]+cleaned, units[-1][1]+[idx])
        else:
            units.append((raw,[idx]))
    words=[u[0] for u in units]
    if not words: return img
    active_unit=0
    for ui,(_,orig) in enumerate(units):
        if active in orig:
            active_unit=ui; break
    lines=[words] if len(words)<=5 else [words[:math.ceil(len(words)/2)], words[math.ceil(len(words)/2):]]
    max_total=WIDTH-130; gap=28; fnt=font(58,True)
    for size in range(60,34,-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=1458 if len(lines)>1 else 1518; line_h=72 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_unit 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:
    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='erf007_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); VERSION_DIR.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-007-fake-support-popup-scam','version':'chatterbox_taylor_scientific_v2_readable_silent_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. Standard red flag proof short extended for silent reading; sentence-chunk TTS pauses; orphan-word captions avoided; thumbnail intentionally omitted; user sets thumbnail manually.'}
    (VERSION_DIR/'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))
