#!/usr/bin/env python3
"""Render ai-004 as clean AI-image cut: AI images + voiceover + subtitles only.

No renderer-owned cards, arrows, boxes, focus lines, labels, progress bars, or UI 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, ImageEnhance, ImageFilter, ImageFont

ROOT = Path(__file__).resolve().parents[1]
OUT_DIR = ROOT / "data" / "post_candidates" / "ai-004-stop-asking-ai-to-think-for-you" / "clean_ai_image_cut_v2"
AUDIO = OUT_DIR / "voiceover_en_gb_ryan.mp3"
VIDEO_NO_AUDIO = OUT_DIR / "video_no_audio.mp4"
FINAL = OUT_DIR / "ai-004_clean_ai_image_cut_1080x1920_review.mp4"
PACKAGE = OUT_DIR / "review_package.json"
PACKAGE_MD = OUT_DIR / "review_package.md"
CONTACT = OUT_DIR / "contact_sheet_keyframes.png"
QA_FRAME_06 = OUT_DIR / "qa_frame_00_06.png"
QA_FRAME_18 = OUT_DIR / "qa_frame_00_18.png"
QA_FRAME_32 = OUT_DIR / "qa_frame_00_32.png"

WIDTH, HEIGHT, FPS = 1080, 1920, 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."
)

# scene_start, scene_end, image_path
SCENES = [
    (0.00, 5.10, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_083447_25b8daf0.png"),
    (5.10, 10.20, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_083551_9fc90cb0.png"),
    (10.20, 15.30, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_083659_080b1564.png"),
    (15.30, 22.20, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_083758_a211a2a4.png"),
    (22.20, 29.20, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_083904_33f1116d.png"),
    (29.20, 40.00, "/home/agent/.hermes/cache/images/openai_codex_gpt-image-2-high_20260527_084012_037a1cc6.png"),
]


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]:
    b = draw.textbbox((0, 0), text, font=fnt, stroke_width=0)
    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="+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:
    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]]]:
    # EdgeTTS WordBoundary often strips punctuation from word events. Re-attach
    # punctuation from the source script by position so captions do not produce
    # ugly cross-sentence chunks like "SPOTS THIRD GIVE".
    source_tokens = SCRIPT.replace("—", " ").split()
    segments: list[list[dict[str, float | str]]] = []
    current: list[dict[str, float | str]] = []
    phrase_breakers = {"for", "you", "instead", "assumptions", "spots", "alternatives", "myself", "judgment", "it", "simple", "answer", "argument"}
    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.18
        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 load_scene_images() -> list[Image.Image]:
    imgs = []
    for _, _, p in SCENES:
        path = Path(p)
        if not path.exists():
            raise FileNotFoundError(path)
        img = Image.open(path).convert("RGB")
        # Cover-crop to exact vertical canvas.
        scale = max(WIDTH / img.width, HEIGHT / img.height)
        new_size = (math.ceil(img.width * scale), math.ceil(img.height * scale))
        img = img.resize(new_size, Image.Resampling.LANCZOS)
        left = (img.width - WIDTH) // 2
        top = (img.height - HEIGHT) // 2
        img = img.crop((left, top, left + WIDTH, top + HEIGHT))
        imgs.append(img)
    return imgs


def scene_index_for_time(t: float) -> int:
    for i, (start, end, _) in enumerate(SCENES):
        if start <= t < end:
            return i
    return len(SCENES) - 1


def scene_local_progress(t: float, idx: int) -> float:
    start, end, _ = SCENES[idx]
    return max(0.0, min(1.0, (t - start) / max(0.001, end - start)))


def ken_burns(img: Image.Image, p: float, idx: int) -> Image.Image:
    # Subtle premium motion; no explanatory overlays.
    zoom = 1.018 + 0.028 * p
    dx_amp = 28
    dy_amp = 22
    dx = int(dx_amp * math.sin((p + idx * 0.17) * math.tau) * 0.55)
    dy = int(dy_amp * math.cos((p + idx * 0.11) * math.tau) * 0.55)
    w = int(WIDTH / zoom)
    h = int(HEIGHT / zoom)
    cx = WIDTH // 2 + dx
    cy = HEIGHT // 2 + dy
    left = max(0, min(WIDTH - w, cx - w // 2))
    top = max(0, min(HEIGHT - h, cy - h // 2))
    return img.crop((left, top, left + w, top + h)).resize((WIDTH, HEIGHT), Image.Resampling.LANCZOS)


def add_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 - 180
    gap = 38
    chosen = font(58, True)
    for size in range(64, 42, -2):
        fnt = font(size, True)
        widths = [text_size(draw, word, fnt)[0] for word in words]
        total = sum(widths) + gap * (len(words) - 1)
        if total <= max_total:
            chosen = fnt
            break
    widths = [text_size(draw, word, chosen)[0] for word in words]
    total = sum(widths) + gap * (len(words) - 1)
    x = (WIDTH - total) // 2
    y = 1518
    # Text only with shadow/stroke; no caption box/bar.
    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=chosen, fill=fill, stroke_width=5, stroke_fill=(0, 0, 0))
        x += ww + gap
    return img


def add_scene_crossfade(base: Image.Image, images: list[Image.Image], t: float, idx: int, p: float) -> Image.Image:
    # 10-frame soft crossfade at scene boundaries, image-to-image only.
    if p < 0.28 and idx > 0:
        start, end, _ = SCENES[idx]
        fade = min(1.0, max(0.0, (t - start) / 0.28))
        prev = ken_burns(images[idx - 1], 1.0, idx - 1)
        return Image.blend(prev, base, fade)
    return base


def make_contact_sheet(images: list[Image.Image]) -> None:
    thumbs = []
    for img in images:
        thumb = img.copy().resize((270, 480), Image.Resampling.LANCZOS)
        thumbs.append(thumb)
    sheet = Image.new("RGB", (810, 960), (8, 12, 24))
    for i, thumb in enumerate(thumbs):
        x = (i % 3) * 270
        y = (i // 3) * 480
        sheet.paste(thumb, (x, y))
    sheet.save(CONTACT, quality=94)


def render_video(words: list[dict[str, float | str]], duration: float) -> None:
    images = load_scene_images()
    make_contact_sheet(images)
    segments = build_segments(words)
    total_duration = max(duration + 0.65, SCENES[-1][1])
    total_frames = math.ceil(total_duration * FPS)
    with tempfile.TemporaryDirectory(prefix="autoshorts_ai004_clean_frames_") as tmp:
        frame_dir = Path(tmp) / "frames"
        frame_dir.mkdir()
        for frame_idx in range(total_frames):
            t = frame_idx / FPS
            idx = scene_index_for_time(t)
            p = scene_local_progress(t, idx)
            frame = ken_burns(images[idx], p, idx)
            frame = add_scene_crossfade(frame, images, t, idx, p)
            # Tiny darkening at bottom to preserve subtitle legibility; photographic, not a label/card overlay.
            grad = Image.new("L", (1, HEIGHT), 0)
            gd = ImageDraw.Draw(grad)
            for y in range(HEIGHT):
                alpha = int(max(0, min(150, (y - 1320) / 600 * 150)))
                gd.point((0, y), fill=alpha)
            alpha = grad.resize((WIDTH, HEIGHT))
            dark = Image.new("RGB", (WIDTH, HEIGHT), (0, 0, 0))
            frame = Image.composite(dark, frame, alpha)
            seg = segment_for_time(t, segments)
            frame = add_subtitles(frame, seg, active_word_index(t, seg) if seg else 0)
            frame.save(frame_dir / f"frame_{frame_idx: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 ts, out in [("6", QA_FRAME_06), ("18", QA_FRAME_18), ("32", QA_FRAME_32)]:
        subprocess.check_call(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-ss", ts, "-i", str(FINAL), "-frames:v", "1", str(out)])


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,bit_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 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())
    if len(words) < 20:
        raise RuntimeError(f"TTS word boundaries too low: {len(words)}")
    audio_duration = ffprobe_duration(AUDIO)
    render_video(words, audio_duration)
    meta = probe_video(FINAL)
    digest = sha256(FINAL)
    package = {
        "schema_version": "autoshorts_final_review.clean_ai_image_cut.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()),
        "contact_sheet_path": str(CONTACT.resolve()),
        "qa_frame_paths": [str(QA_FRAME_06.resolve()), str(QA_FRAME_18.resolve()), str(QA_FRAME_32.resolve())],
        "sha256": digest,
        "video_meta": meta,
        "voice": VOICE,
        "word_boundaries": len(words),
        "visual_policy": "AI images only + active-word subtitles; no renderer cards, arrows, boxes, circles, focus lines, labels, progress bars, or UI overlays.",
        "image_sources": [p for _, _, p in SCENES],
        "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"],
        "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"""## AutoShortsBot Review — ai-004 clean AI image cut

MEDIA:{FINAL.resolve()}

**Titel:** Stop asking AI to think for you
**Hook:** Stop asking AI to think for you. Ask it to argue with you.
**Sprache final:** Englisch
**Zielplattformen:** YouTube Shorts, TikTok, Instagram Reels
**Format:** {meta['width']}x{meta['height']}, SAR {meta['sample_aspect_ratio']}, DAR {meta['display_aspect_ratio']}, {meta['duration']}s
**SHA256:** `{digest}`

**Visual Policy:** Hochwertige detaillierte KI-Bilder + aktive Wort-Untertitel. Keine Render-Cards, Pfeile, Boxen, Kreise, Focus-Lines, Labels oder Progress Bars.
**Contact sheet:** `{CONTACT.resolve()}`
**QA frames:** `{QA_FRAME_06.resolve()}`, `{QA_FRAME_18.resolve()}`, `{QA_FRAME_32.resolve()}`

**Caption:** AI is more useful as a critic than as a guru.
**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()
