#!/usr/bin/env python3
"""Render Everyday Red Flags 002: Hi Mom / new number money scam.

Policy: high-quality AI images + text-only active-word subtitles. No arrows,
boxes, circles, progress bars, cards, labels, caption boxes, or diagram overlays.
"""
from __future__ import annotations

import asyncio
import hashlib
import json
import math
import shutil
import subprocess
import tempfile
from pathlib import Path

import edge_tts
from PIL import Image, ImageDraw, ImageFont

ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "data" / "post_candidates" / "everyday-red-flags-hi-mom-scam"
AUDIO = OUT_DIR / "voiceover_en_gb_ryan.mp3"
VIDEO_NO_AUDIO = OUT_DIR / "video_no_audio.mp4"
FINAL = OUT_DIR / "erf-002_hi_mom_scam_1080x1920_review.mp4"
PACKAGE = OUT_DIR / "review_package.json"
PACKAGE_MD = OUT_DIR / "review_package.md"
CONTACT = OUT_DIR / "contact_sheet_keyframes.png"
QA_SECONDS = (1, 9, 17, 26, 36)
QA_FRAMES = [OUT_DIR / f"qa_frame_{sec:02d}.png" for sec in QA_SECONDS]

WIDTH, HEIGHT, FPS = 1080, 1920, 30
VOICE = "en-GB-RyanNeural"
SCRIPT = (
    "If your child texts from a new number and asks for money, pause. "
    "This scam works because it uses love, not technology. "
    "The message says, Hi Mom, I lost my phone. Then comes the emergency. "
    "A bill is due, a card is blocked, or rent has to be paid today. "
    "The red flag is not just the money. It is the new number, the urgency, "
    "and the pressure to stay in the chat. Do this instead. Do not pay from the message. "
    "Call the old number. Call another family member. Or ask a private question only the real person would know. "
    "New number plus urgent money means verify first. Send this to someone who would panic-text back."
)
SCENES = [
    (0.0, 6.6, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_124030_1c94605a.png"),
    (6.6, 13.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_124139_b894bb59.png"),
    (13.0, 21.8, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_124246_cbf29fd5.png"),
    (21.8, 29.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_124356_67a93727.png"),
    (29.0, 37.5, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_125141_9ba1547d.png"),
    (37.5, 48.0, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_124957_2ec08a7a.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])


async def synthesize() -> list[dict[str, float | str]]:
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    words: list[dict[str, float | str]] = []
    communicate = edge_tts.Communicate(SCRIPT, VOICE, rate="+7%", boundary="WordBoundary")
    with AUDIO.open("wb") as fh:
        async for chunk in communicate.stream():
            if chunk["type"] == "audio":
                fh.write(bytes(chunk.get("data", b"")))
            elif chunk["type"] == "WordBoundary":
                words.append({
                    "word": str(chunk.get("text", "")),
                    "start": float(chunk.get("offset", 0)) / 10_000_000,
                    "duration": max(0.05, float(chunk.get("duration", 0)) / 10_000_000),
                })
    return words


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 build_segments(words: list[dict[str, float | str]]) -> list[list[dict[str, float | str]]]:
    source_tokens = SCRIPT.replace("—", " ").split()
    phrase_breakers = {"pause", "technology", "phone", "emergency", "today", "money", "urgency", "chat", "instead", "message", "number", "member", "know", "first", "back"}
    segments: list[list[dict[str, float | str]]] = []
    current: list[dict[str, float | str]] = []
    for i, w in enumerate(words):
        current.append(w)
        raw = source_tokens[i] if i < len(source_tokens) else str(w["word"])
        cleaned = raw.strip().strip('“”"').lower().rstrip(".,?!:;")
        hard_break = raw.endswith((".", "?", "!", ":"))
        soft_break = len(current) >= 3 or (len(current) >= 2 and cleaned in phrase_breakers)
        if hard_break or soft_break:
            segments.append(current)
            current = []
    if current:
        segments.append(current)
    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
    max_total = WIDTH - 170
    gap = 38
    fnt = font(60, True)
    for size in range(64, 42, -2):
        cand = font(size, True)
        widths = [text_size(draw, word, cand)[0] for word in words]
        total = sum(widths) + gap * (len(words) - 1)
        if total <= max_total:
            fnt = cand
            break
    widths = [text_size(draw, word, fnt)[0] for word in words]
    total = sum(widths) + gap * (len(words) - 1)
    x = (WIDTH - total) // 2
    y = 1515
    for i, (word, ww) in enumerate(zip(words, widths)):
        fill = (255, 214, 64) if i == 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
    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):
        thumb = img.resize((270, 480), Image.Resampling.LANCZOS)
        sheet.paste(thumb, ((i % 3) * 270, (i // 3) * 480))
    sheet.save(CONTACT, quality=94)


def render(words: list[dict[str, float | str]], audio_duration: float) -> None:
    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="erf_himom_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, object]:
    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]
    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": round(float(meta["format"]["duration"]), 3),
        "size_bytes": int(meta["format"].get("size", 0)),
        "bit_rate": int(meta["format"].get("bit_rate", 0)),
    }


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


def main() -> None:
    if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
        raise RuntimeError("ffmpeg/ffprobe required")
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    words = asyncio.run(synthesize())
    if len(words) < 40:
        raise RuntimeError(f"Too few word boundaries: {len(words)}")
    audio_duration = ffprobe_duration(AUDIO)
    render(words, audio_duration)
    meta = probe(FINAL)
    digest = sha256(FINAL)
    package = {
        "schema_version": "everyday_red_flags.review.v1",
        "requires_human_approval": True,
        "side_effects": [],
        "candidate_id": "erf-002-hi-mom-scam",
        "title": "The Hi Mom New Number Scam",
        "series": "Everyday Red Flags",
        "final_video_language": "en",
        "approval_language": "de",
        "topic": "Hi Mom / new number family emergency scam",
        "hook": "If your child texts from a new number and asks for money, pause.",
        "red_flag": "New number, urgency, money, and pressure to stay in chat.",
        "safe_action": "Do not pay from the chat. Call the old number, another family member, or ask a private question.",
        "takeaway": "New number plus urgent money means verify first.",
        "media_path": str(FINAL.resolve()),
        "contact_sheet_path": str(CONTACT.resolve()),
        "qa_frame_paths": [str(p.resolve()) for p in QA_FRAMES],
        "sha256": digest,
        "video_meta": meta,
        "voice": VOICE,
        "word_boundaries": len(words),
        "visual_policy": "Realistic AI images only + text-only active-word subtitles; no overlays beyond subtitles.",
        "caption": "New number asking for urgent money? Verify before you pay.",
        "hashtags": ["#ScamAlert", "#DigitalSafety", "#EverydayRedFlags", "#OnlineSafety", "#FamilySafety"],
        "approval_commands": ["Freigabe YouTube", "Freigabe TikTok", "Freigabe Instagram", "Freigabe alle", "Ablehnen", "Ändern: <Wunsch>"],
        "script": SCRIPT,
    }
    PACKAGE.write_text(json.dumps(package, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    PACKAGE_MD.write_text(f"""## Everyday Red Flags Review — erf-002

MEDIA:{FINAL.resolve()}

**Titel:** The Hi Mom New Number Scam
**Hook:** If your child texts from a new number and asks for money, pause.
**Red Flag:** Neue Nummer + Dringlichkeit + Geld + Druck, im Chat zu bleiben.
**Safe Action:** Nicht im Chat zahlen; alte Nummer oder Familienmitglied anrufen; private Frage stellen.
**Takeaway:** New number plus urgent money means verify first.
**Format:** {meta['width']}x{meta['height']}, SAR {meta['sample_aspect_ratio']}, DAR {meta['display_aspect_ratio']}, {meta['duration']}s
**SHA256:** `{digest}`

**Visual Policy:** Realistische hochwertige KI-Bilder + aktive Wort-Untertitel. Keine Pfeile, Kästen, Kreise, UI-Rahmen, Progress Bars oder Caption-Boxen.
**Contact sheet:** `{CONTACT.resolve()}`
**QA frames:** {', '.join(str(p.resolve()) for p in QA_FRAMES)}

**Caption:** New number asking for urgent money? Verify before you pay.
**Hashtags:** {' '.join(package['hashtags'])}

**Freigabe-Befehle:**
- `Freigabe YouTube`
- `Freigabe TikTok`
- `Freigabe Instagram`
- `Freigabe alle`
- `Ablehnen`
- `Ändern: <Wunsch>`
""", encoding="utf-8")
    print(json.dumps({"final": str(FINAL), "package": str(PACKAGE), "contact": str(CONTACT), "meta": meta, "sha256": digest, "word_boundaries": len(words)}, indent=2))


if __name__ == "__main__":
    main()
