#!/usr/bin/env python3
"""Render next post candidate ai-004 as a 9:16 final-near approval video.

Deterministic renderer owns all readable UI/cards/subtitles. EdgeTTS provides
English voiceover and word-boundary timing; no platform publishing side effects.
"""

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" / "ai-004-stop-asking-ai-to-think-for-you"
AUDIO = OUT_DIR / "voiceover_en_gb_ryan.mp3"
VIDEO_NO_AUDIO = OUT_DIR / "video_no_audio.mp4"
FINAL = OUT_DIR / "ai-004_stop_asking_ai_to_think_for_you_1080x1920_review.mp4"
PACKAGE = OUT_DIR / "review_package.json"
PACKAGE_MD = OUT_DIR / "review_package.md"
QA_FRAME = OUT_DIR / "qa_frame_00_12.png"

WIDTH = 1080
HEIGHT = 1920
FPS = 30
VOICE = "en-GB-RyanNeural"

SCRIPT = (
    "Stop asking AI to think for you. Ask it to argue with you. "
    "When you hand AI a messy idea and say, what should I do, "
    "it can sound confident before you are ready. Use it as a critic instead. "
    "First: list my assumptions. Second: find the weak spots. Third: give me two alternatives. "
    "Fourth: tell me what I still need to decide myself. Now AI is not replacing your judgment. "
    "It is pressure-testing it. The rule is simple: never ask AI for the final answer "
    "before you ask it for the argument."
)

SCENES = [
    (0.00, 3.60, "HOOK", "Stop asking AI to think for you.", "Ask it to argue with you."),
    (3.60, 7.80, "VISIBLE FAIL", "Messy idea → instant answer", "Confidence arrives too early."),
    (7.80, 12.00, "MECHANISM", "Bad prompt: What should I do?", "The model fills missing judgment."),
    (12.00, 16.20, "FIX 1", "List my assumptions", "Make hidden guesses visible."),
    (16.20, 20.40, "FIX 2", "Find the weak spots", "Attack the plan before reality does."),
    (20.40, 24.80, "FIX 3", "Give two alternatives", "Options beat obedient answers."),
    (24.80, 29.60, "FIX 4", "What must I decide myself?", "Keep authority with the human."),
    (29.60, 36.00, "RULE", "Answer last. Argument first.", "Save this prompt structure."),
]


def font(size: int, bold: bool = False):
    candidates = [
        "/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",
    ]
    for c in candidates:
        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]:
    box = draw.textbbox((0, 0), text, font=fnt)
    return int(box[2] - box[0]), int(box[3] - box[1])


def wrap_by_pixels(draw: ImageDraw.ImageDraw, text: str, fnt, max_width: int) -> list[str]:
    words = text.split()
    lines: list[str] = []
    line = ""
    for word in words:
        cand = (line + " " + word).strip()
        if text_size(draw, cand, fnt)[0] <= max_width or not line:
            line = cand
        else:
            lines.append(line)
            line = word
    if line:
        lines.append(line)
    return lines


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="+8%", 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:
    raw = subprocess.check_output(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", str(path)],
        text=True,
    ).strip()
    return float(raw)


def build_segments(words: list[dict[str, float | str]]) -> list[list[dict[str, float | str]]]:
    segments: list[list[dict[str, float | str]]] = []
    current: list[dict[str, float | str]] = []
    for w in words:
        current.append(w)
        token = str(w["word"])
        # Keep active-word caption groups compact on mobile and avoid visually
        # merging adjacent uppercased words.
        if len(current) >= 2 or token.endswith(('.', '?', '!')):
            segments.append(current)
            current = []
    if current:
        segments.append(current)
    return segments


def scene_for_time(t: float, duration: float):
    # Stretch final scene if TTS runs longer than the planned visual beat.
    for scene in SCENES:
        if scene[0] <= t < scene[1]:
            return scene
    return SCENES[-1] if t <= duration + 1 else SCENES[0]


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.2
        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.08
        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 rounded(draw, box, radius, fill, outline=None, width=1):
    draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)


def draw_gradient(draw: ImageDraw.ImageDraw, frame_idx: int, total_frames: int):
    p = frame_idx / max(1, total_frames - 1)
    for y in range(0, HEIGHT, 4):
        r = y / HEIGHT
        base = (
            int(4 + 11 * r + 8 * math.sin(p * math.tau)),
            int(8 + 20 * r),
            int(28 + 55 * r),
        )
        draw.rectangle([0, y, WIDTH, y + 4], fill=base)
    # soft moving blobs
    for i, col in enumerate([(37, 99, 235), (14, 165, 233), (168, 85, 247)]):
        cx = int(WIDTH * (0.22 + 0.28 * i + 0.05 * math.sin(p * math.tau + i)))
        cy = int(HEIGHT * (0.15 + 0.18 * i + 0.03 * math.cos(p * math.tau * 0.7 + i)))
        rr = int(260 + 70 * math.sin(p * math.tau + i))
        draw.ellipse([cx-rr, cy-rr, cx+rr, cy+rr], outline=tuple(min(255, c+30) for c in col), width=5)


def draw_ui_scene(draw: ImageDraw.ImageDraw, scene, p: float):
    _, _, label, main, sub = scene
    small = font(34, False)
    med = font(48, True)
    sub_font = font(42, True)
    large = font(58, True)
    card_x1, card_y1, card_x2, card_y2 = 90, 235, 990, 1265
    rounded(draw, [card_x1, card_y1, card_x2, card_y2], 44, (248, 250, 252), (59, 130, 246), 4)
    draw.text((115, 95), "TrueTraceShorts", font=font(36, True), fill=(226, 232, 240))
    draw.text((115, 145), "Trace the hidden system. Fix the outcome.", font=font(26, False), fill=(148, 163, 184))
    rounded(draw, [115, card_y1+45, 360, card_y1+105], 24, (15, 23, 42), None)
    draw.text((140, card_y1+58), label, font=font(28, True), fill=(125, 211, 252))
    # simulated prompt window
    top = card_y1 + 150
    rounded(draw, [135, top, 945, top+310], 32, (15, 23, 42), (203, 213, 225), 2)
    for i, c in enumerate([(248, 113, 113), (251, 191, 36), (34, 197, 94)]):
        draw.ellipse([165+i*42, top+28, 190+i*42, top+53], fill=c)
    draw.text((165, top+88), "USER PROMPT", font=font(27, True), fill=(148, 163, 184))
    for j, line in enumerate(wrap_by_pixels(draw, main, large, 690)[:3]):
        draw.text((165, top+132+j*70), line, font=large, fill=(248, 250, 252))
    # AI response/checklist cards
    base_y = top + 385
    items = ["ASSUMPTIONS", "WEAK SPOTS", "ALTERNATIVES", "HUMAN DECISION"]
    active = min(len(items)-1, int(p * len(items)))
    for i, item in enumerate(items):
        y = base_y + i * 105
        fill = (219, 234, 254) if i <= active else (226, 232, 240)
        outline = (59, 130, 246) if i == active else (203, 213, 225)
        rounded(draw, [165, y, 915, y+78], 24, fill, outline, 3)
        draw.text((200, y+19), item, font=font(30, True), fill=(15, 23, 42))
        if i <= active:
            draw.text((845, y+16), "✓", font=font(38, True), fill=(22, 163, 74))
    # subline / rule
    sub_lines = wrap_by_pixels(draw, sub, sub_font, 760)[:2]
    sub_start_y = 1188 if len(sub_lines) == 1 else 1138
    for j, line in enumerate(sub_lines):
        w, _ = text_size(draw, line, sub_font)
        draw.text(((WIDTH-w)//2, sub_start_y + j*48), line, font=sub_font, fill=(15, 23, 42))


def draw_subtitles(draw: ImageDraw.ImageDraw, seg, active: int):
    if not seg:
        return
    words = [str(w["word"]).strip() for w in seg if str(w["word"]).strip()]
    if not words:
        return
    fnt = font(52, True)
    pad_x, pad_y = 42, 28
    y = 1485
    # compute word positions centered
    gap = 74
    max_total = WIDTH - 220
    for size in range(52, 37, -2):
        fnt = font(size, True)
        widths = [text_size(draw, w, fnt)[0] for w in words]
        total = sum(widths) + gap * (len(words)-1)
        if total <= max_total:
            break
    x = (WIDTH - total) // 2
    box = [max(35, x-pad_x), y-pad_y, min(WIDTH-35, x+total+pad_x), y+90+pad_y]
    rounded(draw, box, 32, (3, 7, 18), (59, 130, 246), 3)
    for i, (word, ww) in enumerate(zip(words, widths)):
        fill = (250, 204, 21) if i == active else (248, 250, 252)
        draw.text((x, y), word.upper(), font=fnt, fill=fill)
        x += ww + gap


def render_video(words: list[dict[str, float | str]], duration: float):
    segments = build_segments(words)
    total_duration = max(duration + 0.8, SCENES[-1][1])
    total_frames = math.ceil(total_duration * FPS)
    with tempfile.TemporaryDirectory(prefix="autoshorts_ai004_frames_") as tmp:
        frame_dir = Path(tmp) / "frames"
        frame_dir.mkdir()
        for idx in range(total_frames):
            t = idx / FPS
            img = Image.new("RGB", (WIDTH, HEIGHT), (3, 7, 18))
            draw = ImageDraw.Draw(img)
            draw_gradient(draw, idx, total_frames)
            scene = scene_for_time(t, total_duration)
            sp = (t - scene[0]) / max(0.001, scene[1] - scene[0])
            draw_ui_scene(draw, scene, max(0, min(1, sp)))
            seg = segment_for_time(t, segments)
            draw_subtitles(draw, seg, active_word_index(t, seg) if seg else 0)
            # progress rail
            draw.rounded_rectangle([95, 1810, 985, 1824], radius=7, fill=(30, 41, 59))
            draw.rounded_rectangle([95, 1810, 95 + int(890 * min(1, t/total_duration)), 1824], radius=7, fill=(59, 130, 246))
            img.save(frame_dir / f"frame_{idx:06d}.jpg", quality=92)
        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", "veryfast", "-crf", "18",
            "-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", "160k",
        "-shortest", "-movflags", "+faststart", str(FINAL)
    ])
    subprocess.check_call([
        "ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
        "-ss", "12", "-i", str(FINAL), "-frames:v", "1", str(QA_FRAME)
    ])


def probe_video(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", "-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),
    }


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 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())
    audio_duration = ffprobe_duration(AUDIO)
    render_video(words, audio_duration)
    vmeta = probe_video(FINAL)
    digest = sha256(FINAL)
    package = {
        "schema_version": "autoshorts_final_review.v1",
        "requires_human_approval": True,
        "side_effects": [],
        "candidate_id": "ai-004",
        "title": "Stop asking AI to think for you",
        "series": "AI Output Autopsy",
        "pillar": "ai_life_systems",
        "final_video_language": "en",
        "approval_language": "de",
        "media_path": str(FINAL.resolve()),
        "qa_frame_path": str(QA_FRAME.resolve()),
        "sha256": digest,
        "video_meta": vmeta,
        "voice": VOICE,
        "hook": "Stop asking AI to think for you. Ask it to argue with you.",
        "caption": "AI is more useful as a critic than as a guru.",
        "hashtags": ["#AI", "#Productivity", "#Workflow", "#DecisionMaking", "#TrueTraceShorts"],
        "platform_targets": ["YouTube Shorts", "TikTok", "Instagram Reels"],
        "quality_notes": [
            "1080x1920 export with SAR 1:1 and DAR 9:16",
            "EdgeTTS English voiceover with word-boundary driven active-word captions",
            "Readable UI/cards are deterministic renderer-owned, not AI-generated",
            "No platform posting side effects performed",
        ],
        "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")
    md = f"""## AutoShortsBot Review — ai-004\n\nMEDIA:{FINAL.resolve()}\n\n**Titel:** Stop asking AI to think for you\n**Serie:** AI Output Autopsy\n**Hook:** Stop asking AI to think for you. Ask it to argue with you.\n**Sprache final:** Englisch\n**Zielplattformen:** YouTube Shorts, TikTok, Instagram Reels\n**Format:** {vmeta['width']}x{vmeta['height']}, SAR {vmeta['sample_aspect_ratio']}, DAR {vmeta['display_aspect_ratio']}, {vmeta['duration']}s\n**SHA256:** `{digest}`\n\n**Caption:** AI is more useful as a critic than as a guru.\n**Hashtags:** {' '.join(package['hashtags'])}\n\n**QA:** 9:16 mobile-safe, aktive Wort-Untertitel, deterministic UI/cards, keine Posting-Side-Effects.\n**QA frame:** `{QA_FRAME.resolve()}`\n\n**Freigabe-Befehle:**\n- `Freigabe YouTube`\n- `Freigabe TikTok`\n- `Freigabe Instagram`\n- `Freigabe alle`\n- `Ablehnen`\n- `Ändern: <Wunsch>`\n"""
    PACKAGE_MD.write_text(md, encoding="utf-8")
    print(json.dumps({"final": str(FINAL), "package": str(PACKAGE), "meta": vmeta, "sha256": digest}, indent=2))


if __name__ == "__main__":
    main()
