"""
Dialog-Szene: zwei Personen stehen sich gegenüber und sprechen (kein generisches B-Roll).

Für verkaufbare Jami-/JamPulse-Dialogvideos: Sarah links, Laura rechts, aktive Sprecherin
wird hervorgehoben – synchron zum ElevenLabs-Dialog.
"""

from __future__ import annotations

import json
import os
import subprocess
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SECRETS = ROOT / "data" / "jami-secrets.local.json"


@dataclass
class CharacterPose:
    """Animierte 3D-Figur: Mund, Mimik, Gestik."""

    speaking: bool = False
    mouth_open: float = 0.05
    mouth_shape: str = "X"
    expression: float = 0.0
    gesture: float = 0.0
    listen_tilt: float = 0.0


@dataclass
class SceneSegment:
    speaker: str
    duration: float


def _find_ffmpeg() -> str | None:
    from jam.tiktok_remix import _find_ffmpeg

    return _find_ffmpeg()


def _probe_duration(path: Path) -> float:
    from jam.ia1minute import _probe_media_duration

    return _probe_media_duration(path)


def _openai_key() -> str:
    key = os.environ.get("OPENAI_API_KEY", "").strip()
    if key:
        return key
    if SECRETS.is_file():
        try:
            data = json.loads(SECRETS.read_text(encoding="utf-8"))
            return str(data.get("openai_api_key") or "").strip()
        except (json.JSONDecodeError, OSError):
            pass
    return ""


def _try_openai_image(prompt: str, out: Path, *, size: str = "1024x1536") -> bool:
    key = _openai_key()
    if not key:
        return False
    out.parent.mkdir(parents=True, exist_ok=True)
    body = {
        "model": "gpt-image-1",
        "prompt": prompt[:4000],
        "n": 1,
        "size": size,
        "quality": "medium",
    }
    req = urllib.request.Request(
        "https://api.openai.com/v1/images/generations",
        data=json.dumps(body).encode("utf-8"),
        headers={
            "Authorization": f"Bearer {key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            data = json.loads(resp.read().decode())
        item = (data.get("data") or [{}])[0]
        b64 = item.get("b64_json")
        if b64:
            import base64

            out.write_bytes(base64.b64decode(b64))
            return out.stat().st_size > 5000
        url = item.get("url")
        if url:
            with urllib.request.urlopen(url, timeout=120) as img:
                out.write_bytes(img.read())
            return out.stat().st_size > 5000
    except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError, KeyError):
        pass
    return False


def _style_key(visual_style: str) -> str:
    vs = (visual_style or "cartoon").strip().lower()
    if vs in ("3d", "trickfilm", "pixar", "animation", "cgi", "film"):
        return "3d"
    if vs in ("cartoon", "comic", "anime", "illustration"):
        return "cartoon"
    return "realistic"


def _is_3d_style(visual_style: str) -> bool:
    return _style_key(visual_style) == "3d"


def _is_cartoon_style(visual_style: str) -> bool:
    return _style_key(visual_style) == "cartoon"


def _lerp_color(a: tuple[int, ...], b: tuple[int, ...], t: float) -> tuple[int, int, int]:
    t = max(0.0, min(1.0, t))
    return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))  # type: ignore[return-value]


def _paste_shaded_sphere(
    layer: "Image.Image",
    cx: int,
    cy: int,
    radius: int,
    base: tuple[int, int, int],
    *,
    light_dir: tuple[float, float] = (-0.35, -0.55),
) -> None:
    from PIL import ImageDraw

    d = ImageDraw.Draw(layer)
    lx = cx + int(light_dir[0] * radius)
    ly = cy + int(light_dir[1] * radius)
    for i in range(radius, 0, -1):
        t = i / max(radius, 1)
        col = _lerp_color(base, (255, 248, 240), (1.0 - t) * 0.38)
        shade = _lerp_color(col, (max(0, base[0] - 80), max(0, base[1] - 80), max(0, base[2] - 70)), t * 0.45)
        d.ellipse([cx - i, cy - i, cx + i, cy + i], fill=shade + (255,))
    highlight_r = max(4, radius // 4)
    d.ellipse(
        [lx - highlight_r, ly - highlight_r, lx + highlight_r, ly + highlight_r],
        fill=(255, 255, 255, 90),
    )


def _paste_shaded_body(
    layer: "Image.Image",
    box: tuple[int, int, int, int],
    base: tuple[int, int, int],
    *,
    radius: int = 36,
) -> None:
    from PIL import ImageDraw

    d = ImageDraw.Draw(layer)
    x0, y0, x1, y1 = box
    dark = _lerp_color(base, (30, 20, 35), 0.35)
    light = _lerp_color(base, (255, 255, 255), 0.22)
    d.rounded_rectangle(box, radius=radius, fill=dark + (255,))
    inset = (x0 + 8, y0 + 6, x1 - 10, y1 - 8)
    d.rounded_rectangle(inset, radius=max(8, radius - 8), fill=base + (255,))
    d.rounded_rectangle(
        (inset[0], inset[1], inset[0] + (inset[2] - inset[0]) // 2 + 6, inset[3]),
        radius=12,
        fill=light + (70,),
    )


def _draw_3d_mouth(
    md: "ImageDraw.ImageDraw",
    *,
    cx: int,
    mouth_y: int,
    s: float,
    shape: str,
    open_amount: float,
    facing: str,
) -> None:
    """Rhubarb-Mundformen auf der 3D-Figur."""
    open_amount = max(0.0, min(1.0, open_amount))
    shape = (shape or "X").upper()
    lip = (220, 95, 115)
    inner = (190, 70, 90, 200)
    w = int(22 * s + open_amount * 14 * s)
    h = int(6 * s + open_amount * 22 * s)

    if shape == "X" or open_amount < 0.08:
        md.arc(
            [cx - int(18 * s), mouth_y - int(2 * s), cx + int(18 * s), mouth_y + int(10 * s)],
            195,
            345,
            fill=(185, 100, 110),
            width=max(2, int(3 * s)),
        )
        return

    if shape in ("A", "E"):
        md.ellipse([cx - w, mouth_y - h // 2, cx + w, mouth_y + h], fill=inner)
        md.arc([cx - w, mouth_y - h, cx + w, mouth_y + h // 2], 10, 170, fill=lip, width=max(3, int(4 * s)))
    elif shape in ("B", "H"):
        md.ellipse([cx - w, mouth_y, cx + w, mouth_y + h], fill=inner)
        md.line([cx - w, mouth_y + int(2 * s), cx + w, mouth_y + int(2 * s)], fill=lip, width=max(2, int(3 * s)))
    elif shape in ("C", "G"):
        md.chord([cx - w, mouth_y - h // 3, cx + w, mouth_y + h], 20, 160, fill=inner)
        md.arc([cx - w, mouth_y - h // 2, cx + w, mouth_y + h], 15, 165, fill=lip, width=max(3, int(3 * s)))
    elif shape == "D":
        md.rectangle([cx - w // 2, mouth_y, cx + w // 2, mouth_y + h // 2], fill=inner)
    elif shape == "F":
        md.line([cx - w // 2, mouth_y + int(4 * s), cx + w // 2, mouth_y + int(4 * s)], fill=lip, width=max(2, int(3 * s)))
        md.line([cx - w // 3, mouth_y, cx + w // 3, mouth_y + int(8 * s)], fill=(240, 240, 235, 180), width=max(1, int(2 * s)))
    else:
        md.ellipse([cx - w, mouth_y, cx + w, mouth_y + h], fill=inner)


def _draw_3d_arms(
    layer: "Image.Image",
    *,
    cx: int,
    cy: int,
    s: float,
    outfit: tuple[int, int, int],
    skin: tuple[int, int, int],
    facing: str,
    speaking: bool,
    gesture: float,
) -> None:
    """Arme / Gestik – hebt sich beim Sprechen leicht."""
    import math

    from PIL import ImageDraw

    d = ImageDraw.Draw(layer)
    body_top = cy + int(40 * s)
    swing = math.sin(gesture) * (0.55 if speaking else 0.12)
    lift = (0.35 + max(0.0, swing)) if speaking else 0.08

    if facing == "right":
        shoulder = (cx + int(55 * s), body_top + int(18 * s))
        elbow_y = body_top + int(70 * s - 40 * s * lift)
        hand_x = shoulder[0] + int(35 * s + 20 * s * swing)
        hand_y = elbow_y + int(35 * s)
        d.line([shoulder, (hand_x, elbow_y), (hand_x + int(8 * s), hand_y)], fill=outfit + (255,), width=max(8, int(14 * s)))
        _paste_shaded_sphere(layer, hand_x + int(8 * s), hand_y, int(14 * s), skin)
        d.line(
            [(cx - int(55 * s), body_top + int(22 * s)), (cx - int(75 * s), body_top + int(85 * s))],
            fill=outfit + (255,),
            width=max(7, int(12 * s)),
        )
    else:
        shoulder = (cx - int(55 * s), body_top + int(18 * s))
        elbow_y = body_top + int(70 * s - 40 * s * lift)
        hand_x = shoulder[0] - int(35 * s + 20 * s * swing)
        hand_y = elbow_y + int(35 * s)
        d.line([shoulder, (hand_x, elbow_y), (hand_x - int(8 * s), hand_y)], fill=outfit + (255,), width=max(8, int(14 * s)))
        _paste_shaded_sphere(layer, hand_x - int(8 * s), hand_y, int(14 * s), skin)
        d.line(
            [(cx + int(55 * s), body_top + int(22 * s)), (cx + int(75 * s), body_top + int(85 * s))],
            fill=outfit + (255,),
            width=max(7, int(12 * s)),
        )


def _draw_3d_character(
    layer: "Image.Image",
    *,
    cx: int,
    cy: int,
    scale: float,
    facing: str,
    hair: tuple[int, int, int],
    outfit: tuple[int, int, int],
    skin: tuple[int, int, int],
    active: bool,
    mouth_open: float = 0.05,
    mouth_shape: str = "X",
    expression: float = 0.0,
    gesture: float = 0.0,
    listen_tilt: float = 0.0,
) -> None:
    from PIL import Image, ImageDraw

    s = scale * (1.08 if active else 1.0)
    cy = cy + int(listen_tilt * 8 * s)
    foot_y = cy + int(200 * s)
    shadow = Image.new("RGBA", layer.size, (0, 0, 0, 0))
    sd = ImageDraw.Draw(shadow)
    sw = int(130 * s)
    sd.ellipse([cx - sw, foot_y - 16, cx + sw, foot_y + 28], fill=(20, 12, 30, 110))
    layer.alpha_composite(shadow)

    body_top = cy + int(40 * s)
    _paste_shaded_body(
        layer,
        (cx - int(78 * s), body_top, cx + int(78 * s), cy + int(195 * s)),
        outfit,
        radius=int(34 * s),
    )
    _draw_3d_arms(
        layer,
        cx=cx,
        cy=cy,
        s=s,
        outfit=outfit,
        skin=skin,
        facing=facing,
        speaking=active and mouth_open > 0.12,
        gesture=gesture,
    )

    neck_y = body_top - int(6 * s)
    _paste_shaded_sphere(layer, cx, neck_y, int(22 * s), skin)

    head_y = cy - int(55 * s)
    head_r = int(78 * s)
    _paste_shaded_sphere(layer, cx, head_y, head_r, skin)

    hair_layer = Image.new("RGBA", layer.size, (0, 0, 0, 0))
    hd = ImageDraw.Draw(hair_layer)
    if facing == "right":
        hd.pieslice(
            [cx - head_r - 6, head_y - head_r - 10, cx + head_r // 2, head_y + head_r // 3],
            200,
            340,
            fill=hair + (255,),
        )
        hd.rectangle([cx - head_r, head_y - head_r, cx + head_r // 3, head_y], fill=hair + (255,))
    else:
        hd.pieslice(
            [cx - head_r // 2, head_y - head_r - 10, cx + head_r + 6, head_y + head_r // 3],
            200,
            340,
            fill=hair + (255,),
        )
        hd.rectangle([cx - head_r // 3, head_y - head_r, cx + head_r, head_y], fill=hair + (255,))
    layer.alpha_composite(hair_layer)

    eye_y = head_y - int(6 * s) - int(expression * 4 * s)
    brow_y = eye_y - int(14 * s)
    brow_lift = int(expression * 6 * s)
    if facing == "right":
        eyes = (cx - int(28 * s), cx + int(10 * s))
        pupil_dx = int(5 * s)
        brows = (cx - int(28 * s), cx + int(10 * s))
    else:
        eyes = (cx - int(10 * s), cx + int(28 * s))
        pupil_dx = -int(5 * s)
        brows = (cx - int(10 * s), cx + int(28 * s))
    from PIL import ImageDraw

    ed = ImageDraw.Draw(layer)
    for bx in brows:
        ed.line(
            [bx - int(12 * s), brow_y - brow_lift, bx + int(12 * s), brow_y - brow_lift + int(2 * s)],
            fill=(55, 35, 45, 255),
            width=max(2, int(3 * s)),
        )
    for ex in eyes:
        _paste_shaded_sphere(layer, ex, eye_y, int(14 * s), (255, 255, 255))
        ed.ellipse(
            [ex - 6 + pupil_dx, eye_y - 7, ex + 6 + pupil_dx, eye_y + 5],
            fill=(35, 28, 48, 255),
        )
        ed.ellipse([ex - 2 + pupil_dx, eye_y - 5, ex + 2 + pupil_dx, eye_y - 1], fill=(255, 255, 255, 200))

    from PIL import ImageDraw

    md = ImageDraw.Draw(layer)
    mouth_y = head_y + int(32 * s)
    if active and mouth_open > 0.08:
        _draw_3d_mouth(
            md,
            cx=cx,
            mouth_y=mouth_y,
            s=s,
            shape=mouth_shape,
            open_amount=mouth_open,
            facing=facing,
        )
    else:
        md.arc([cx - int(18 * s), mouth_y - int(2 * s), cx + int(18 * s), mouth_y + int(12 * s)], 195, 345, fill=(185, 100, 110), width=max(2, int(3 * s)))

    if active or expression > 0.15:
        glow = Image.new("RGBA", layer.size, (0, 0, 0, 0))
        gd = ImageDraw.Draw(glow)
        gd.ellipse(
            [cx - head_r - 30, head_y - head_r - 30, cx + head_r + 30, head_y + head_r + 40],
            fill=(255, 230, 180, 45),
        )
        layer.alpha_composite(glow)


def _render_3d_room_base(w: int, h: int) -> "Image.Image":
    from PIL import Image, ImageDraw

    img = Image.new("RGB", (w, h))
    d = ImageDraw.Draw(img)
    for y in range(h):
        t = y / max(h - 1, 1)
        col = _lerp_color((48, 42, 72), (24, 18, 38), t * 0.85)
        d.line([(0, y), (w, y)], fill=col)

    # Boden mit Perspektive
    floor_y = int(h * 0.72)
    d.polygon(
        [(0, floor_y), (w, floor_y), (w, h), (0, h)],
        fill=(62, 48, 44),
    )
    for i in range(8):
        t = i / 7
        x = int(w * (0.15 + t * 0.7))
        d.line([(w // 2, floor_y), (x, h)], fill=(72, 56, 50), width=2)

    # Fenster + volumetrisches Licht
    win_w, win_h = int(w * 0.42), int(h * 0.24)
    wx, wy = (w - win_w) // 2, int(h * 0.05)
    d.rounded_rectangle([wx - 8, wy - 8, wx + win_w + 8, wy + win_h + 8], radius=22, fill=(240, 230, 210))
    d.rounded_rectangle([wx, wy, wx + win_w, wy + win_h], radius=16, fill=(190, 220, 255))
    for i in range(6):
        alpha = 40 - i * 6
        d.polygon(
            [(wx + win_w // 2, wy + win_h), (wx - 40 + i * 30, h), (wx + win_w + 40 - i * 30, h)],
            fill=(255, 248, 220),
        )

    # Couch (3D-Kissen)
    couch_y = int(h * 0.68)
    d.rounded_rectangle([int(w * 0.08), couch_y, int(w * 0.92), int(h * 0.93)], radius=48, fill=(150, 88, 78))
    d.rounded_rectangle([int(w * 0.1), couch_y + 12, int(w * 0.9), int(h * 0.9)], radius=40, fill=(195, 115, 98))
    d.rounded_rectangle([int(w * 0.26), couch_y - 18, int(w * 0.74), couch_y + 28], radius=20, fill=(120, 78, 58))
    return img


def _openai_3d_scene_prompt(active: str | None) -> str:
    if active in ("sarah", "female", "lisa"):
        mood = "Sarah on the left is speaking happily with open mouth, Laura on the right listens surprised"
    elif active == "laura":
        mood = "Laura on the right is speaking excitedly, Sarah on the left listens smiling"
    else:
        mood = "both women face each other in a friendly conversation pause"
    return (
        "3D CGI animated movie still, Pixar-style trickfilm, vertical 9:16 portrait. "
        "Modern cozy apartment in Germany, warm cinematic lighting through window. "
        "Two young adult women stand facing each other across a coffee table. "
        "LEFT woman Sarah: brown wavy hair, coral pink sweater. "
        "RIGHT woman Laura: blonde hair, teal blouse. "
        f"{mood}. "
        "Soft shadows, subsurface skin, detailed 3D render, depth of field, "
        "no text, no watermark, no logos"
    )


def _ensure_3d_openai_frames(assets: Path, *, size: tuple[int, int]) -> bool:
    """Volle 3D-Trickfilm-Szene per KI – wenn OpenAI-Key vorhanden."""
    w, h = size
    oai_size = "1024x1536" if h >= w else "1536x1024"
    ok_all = True
    for active in (None, "sarah", "laura"):
        key = active or "neutral"
        dest = assets / f"frame_{key}.png"
        if dest.is_file() and dest.stat().st_size > 25000:
            continue
        if not _try_openai_image(_openai_3d_scene_prompt(active), dest, size=oai_size):
            ok_all = False
    return ok_all


def _draw_cartoon_woman(
    draw,
    *,
    cx: int,
    cy: int,
    scale: float,
    facing: str,
    hair: tuple[int, int, int],
    top: tuple[int, int, int],
    skin: tuple[int, int, int],
    active: bool,
) -> None:
    from PIL import ImageDraw

    s = scale * (1.06 if active else 1.0)
    w = int(210 * s)
    h = int(320 * s)
    x0 = cx - w // 2
    y0 = cy - int(140 * s)

    shadow = (40, 28, 52, 90)
    draw.ellipse([x0 + 20, y0 + h - 30, x0 + w - 20, y0 + h + 10], fill=shadow)

    # Körper / Schultern
    body_top = y0 + int(150 * s)
    draw.rounded_rectangle(
        [x0 + 18, body_top, x0 + w - 18, y0 + h - 10],
        radius=int(36 * s),
        fill=top,
    )
    neck = [cx - int(18 * s), body_top - int(8 * s), cx + int(18 * s), body_top + int(12 * s)]
    draw.rounded_rectangle(neck, radius=8, fill=skin)

    # Kopf
    head_r = int(72 * s)
    hx, hy = cx, y0 + int(88 * s)
    draw.ellipse([hx - head_r, hy - head_r, hx + head_r, hy + head_r], fill=skin)

    # Haare
    hair_y = hy - head_r - int(8 * s)
    if facing == "right":
        draw.pieslice(
            [hx - head_r - 8, hair_y, hx + head_r + 18, hy + head_r // 2],
            200,
            340,
            fill=hair,
        )
        draw.rectangle([hx - head_r, hair_y + 10, hx + head_r // 2, hy - 10], fill=hair)
    else:
        draw.pieslice(
            [hx - head_r - 18, hair_y, hx + head_r + 8, hy + head_r // 2],
            200,
            340,
            fill=hair,
        )
        draw.rectangle([hx - head_r // 2, hair_y + 10, hx + head_r, hy - 10], fill=hair)

    eye_y = hy - int(8 * s)
    if facing == "right":
        ex1, ex2 = hx - int(26 * s), hx + int(8 * s)
        pupil_dx = int(4 * s)
    else:
        ex1, ex2 = hx - int(8 * s), hx + int(26 * s)
        pupil_dx = -int(4 * s)
    for ex in (ex1, ex2):
        draw.ellipse([ex - 10, eye_y - 12, ex + 10, eye_y + 8], fill=(255, 255, 255))
        draw.ellipse(
            [ex - 5 + pupil_dx, eye_y - 6, ex + 5 + pupil_dx, eye_y + 2],
            fill=(45, 35, 55),
        )
    brow_y = eye_y - int(18 * s)
    draw.arc([ex1 - 12, brow_y, ex1 + 12, brow_y + 16], 200, 340, fill=(60, 40, 45), width=3)
    draw.arc([ex2 - 12, brow_y, ex2 + 12, brow_y + 16], 200, 340, fill=(60, 40, 45), width=3)

    mouth_y = hy + int(28 * s)
    if active:
        draw.arc([hx - 22, mouth_y - 10, hx + 22, mouth_y + 18], 10, 170, fill=(190, 90, 110), width=4)
    else:
        draw.arc([hx - 18, mouth_y - 6, hx + 18, mouth_y + 10], 190, 350, fill=(180, 100, 110), width=3)

    # Sprech-Hinweis (ohne Text) – leichte Wärme vor dem aktiven Gesicht
    if active:
        glow = (255, 220, 180, 55)
        draw.ellipse([hx - head_r - 24, hy - head_r - 24, hx + head_r + 24, hy + head_r + 36], fill=glow)
        talk = (255, 255, 255, 40)
        tx = hx + int(36 * s) if facing == "right" else hx - int(36 * s)
        draw.ellipse([tx - 18, hy - 8, tx + 18, hy + 24], fill=talk)


def _render_3d_frame(
    size: tuple[int, int],
    *,
    active: str | None,
) -> "Image.Image":
    from PIL import Image, ImageDraw, ImageEnhance, ImageFilter

    w, h = size
    img = _render_3d_room_base(w, h).convert("RGBA")
    chars = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    sarah_active = active in ("sarah", "female", "lisa")
    laura_active = active in ("laura",)

    _draw_3d_character(
        chars,
        cx=int(w * 0.30),
        cy=int(h * 0.36),
        scale=1.08,
        facing="right",
        hair=(88, 52, 36),
        outfit=(235, 98, 118),
        skin=(255, 218, 192),
        active=sarah_active,
    )
    _draw_3d_character(
        chars,
        cx=int(w * 0.70),
        cy=int(h * 0.36),
        scale=1.08,
        facing="left",
        hair=(245, 205, 110),
        outfit=(72, 168, 195),
        skin=(255, 212, 186),
        active=laura_active,
    )
    img.alpha_composite(chars)

    overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    od = ImageDraw.Draw(overlay)
    if sarah_active:
        od.rectangle([int(w * 0.52), 0, w, h], fill=(0, 0, 0, 70))
    elif laura_active:
        od.rectangle([0, 0, int(w * 0.48), h], fill=(0, 0, 0, 70))
    img = Image.alpha_composite(img, overlay)

    out = ImageEnhance.Color(img.convert("RGB")).enhance(1.2)
    out = ImageEnhance.Contrast(out).enhance(1.08)
    out = ImageEnhance.Brightness(out).enhance(1.02)
    return out.filter(ImageFilter.GaussianBlur(radius=0.35))


def _render_3d_frame_animated(
    size: tuple[int, int],
    *,
    sarah: CharacterPose,
    laura: CharacterPose,
) -> "Image.Image":
    """Volle 3D-Szene mit Lip-Sync, Mimik und Gestik pro Frame."""
    from PIL import Image, ImageDraw, ImageEnhance, ImageFilter

    w, h = size
    img = _render_3d_room_base(w, h).convert("RGBA")
    chars = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    _draw_3d_character(
        chars,
        cx=int(w * 0.30),
        cy=int(h * 0.36),
        scale=1.08,
        facing="right",
        hair=(88, 52, 36),
        outfit=(235, 98, 118),
        skin=(255, 218, 192),
        active=sarah.speaking,
        mouth_open=sarah.mouth_open,
        mouth_shape=sarah.mouth_shape,
        expression=sarah.expression,
        gesture=sarah.gesture,
        listen_tilt=sarah.listen_tilt,
    )
    _draw_3d_character(
        chars,
        cx=int(w * 0.70),
        cy=int(h * 0.36),
        scale=1.08,
        facing="left",
        hair=(245, 205, 110),
        outfit=(72, 168, 195),
        skin=(255, 212, 186),
        active=laura.speaking,
        mouth_open=laura.mouth_open,
        mouth_shape=laura.mouth_shape,
        expression=laura.expression,
        gesture=laura.gesture,
        listen_tilt=laura.listen_tilt,
    )
    img.alpha_composite(chars)

    overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    od = ImageDraw.Draw(overlay)
    if sarah.speaking and not laura.speaking:
        od.rectangle([int(w * 0.52), 0, w, h], fill=(0, 0, 0, 70))
    elif laura.speaking and not sarah.speaking:
        od.rectangle([0, 0, int(w * 0.48), h], fill=(0, 0, 0, 70))
    img = Image.alpha_composite(img, overlay)

    out = ImageEnhance.Color(img.convert("RGB")).enhance(1.2)
    out = ImageEnhance.Contrast(out).enhance(1.08)
    out = ImageEnhance.Brightness(out).enhance(1.02)
    return out.filter(ImageFilter.GaussianBlur(radius=0.35))


def _render_frame(
    size: tuple[int, int],
    *,
    active: str | None,
    visual_style: str,
) -> "Image.Image":
    if _is_3d_style(visual_style):
        return _render_3d_frame(size, active=active)

    from PIL import Image, ImageDraw, ImageFilter

    w, h = size
    img = Image.new("RGB", (w, h))
    draw = ImageDraw.Draw(img, "RGBA")

    # Wohnzimmer-Hintergrund
    for y in range(h):
        t = y / max(h - 1, 1)
        r = int(245 - 35 * t)
        g = int(232 - 40 * t)
        b = int(218 - 30 * t)
        draw.line([(0, y), (w, y)], fill=(r, g, b))

    # Fenster / Licht
    win_w, win_h = int(w * 0.36), int(h * 0.22)
    wx, wy = (w - win_w) // 2, int(h * 0.06)
    draw.rounded_rectangle([wx, wy, wx + win_w, wy + win_h], radius=18, fill=(255, 248, 230))
    draw.rounded_rectangle(
        [wx + 12, wy + 12, wx + win_w - 12, wy + win_h - 12],
        radius=12,
        fill=(210, 230, 255),
    )

    # Couch / Tisch zwischen ihnen
    table_y = int(h * 0.62)
    draw.rounded_rectangle(
        [int(w * 0.28), table_y, int(w * 0.72), table_y + int(h * 0.05)],
        radius=20,
        fill=(120, 85, 65),
    )
    draw.rounded_rectangle(
        [int(w * 0.12), int(h * 0.68), int(w * 0.88), int(h * 0.92)],
        radius=40,
        fill=(180, 120, 110),
    )

    cartoon = _is_cartoon_style(visual_style)
    sarah_active = active in ("sarah", "female", "lisa")
    laura_active = active in ("laura",)

    if cartoon:
        sarah_skin = (255, 220, 195)
        laura_skin = (255, 215, 188)
        _draw_cartoon_woman(
            draw,
            cx=int(w * 0.28),
            cy=int(h * 0.42),
            scale=1.05,
            facing="right",
            hair=(95, 55, 38),
            top=(240, 120, 130),
            skin=sarah_skin,
            active=sarah_active,
        )
        _draw_cartoon_woman(
            draw,
            cx=int(w * 0.72),
            cy=int(h * 0.42),
            scale=1.05,
            facing="left",
            hair=(240, 210, 120),
            top=(90, 170, 190),
            skin=laura_skin,
            active=laura_active,
        )
    else:
        # Realistischer Look: weiche Silhouetten + Licht
        for side, sp in (("left", "sarah"), ("right", "laura")):
            cx = int(w * (0.28 if side == "left" else 0.72))
            cy = int(h * 0.40)
            is_on = (sp == "sarah" and sarah_active) or (sp == "laura" and laura_active)
            tone = (255, 245, 238) if is_on else (210, 200, 195)
            rw, rh = int(w * 0.22), int(h * 0.34)
            draw.rounded_rectangle(
                [cx - rw // 2, cy - rh // 2, cx + rw // 2, cy + rh // 2],
                radius=30,
                fill=tone,
            )
            draw.ellipse(
                [cx - rw // 3, cy - rh // 2 - 20, cx + rw // 3, cy - rh // 4],
                fill=(220, 180, 160),
            )

    # Inaktive Seite leicht abdunkeln
    overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
    od = ImageDraw.Draw(overlay)
    if active == "sarah" or active in ("female", "lisa"):
        od.rectangle([int(w * 0.55), 0, w, h], fill=(0, 0, 0, 55))
    elif active == "laura":
        od.rectangle([0, 0, int(w * 0.45), h], fill=(0, 0, 0, 55))
    img = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
    return img.filter(ImageFilter.GaussianBlur(radius=0.3))


def _ensure_character_portraits(
    assets: Path,
    *,
    visual_style: str,
) -> None:
    """Optionale KI-Portraits (OpenAI) – Fallback bleibt die gezeichnete Szene."""
    if visual_style not in ("cartoon", "comic", "anime", "illustration"):
        return
    sarah_png = assets / "sarah_portrait.png"
    laura_png = assets / "laura_portrait.png"
    if not sarah_png.is_file():
        _try_openai_image(
            "Cartoon illustration, young German woman Sarah, half-body portrait facing right "
            "toward another person, excited friendly smile, pink sweater, brown wavy hair, "
            "soft apartment background, flat modern animation style, no text, no watermark",
            sarah_png,
        )
    if not laura_png.is_file():
        _try_openai_image(
            "Cartoon illustration, young German woman Laura, half-body portrait facing left "
            "toward another person, surprised happy expression, teal top, blonde hair, "
            "soft apartment background, flat modern animation style, no text, no watermark",
            laura_png,
        )


def _frame_path(assets: Path, active: str | None) -> Path:
    key = active or "neutral"
    p = assets / f"frame_{key}.png"
    if p.is_file():
        return p
    return assets / "frame_neutral.png"


def prepare_scene_assets(
    work_dir: Path,
    *,
    size: tuple[int, int],
    visual_style: str = "cartoon",
) -> Path:
    sk = _style_key(visual_style)
    assets = work_dir / f"dialogue_scene_{sk}"
    assets.mkdir(parents=True, exist_ok=True)
    if sk == "3d":
        _ensure_3d_openai_frames(assets, size=size)
    else:
        _ensure_character_portraits(assets, visual_style=visual_style)
    for active in (None, "sarah", "laura", "female", "lisa"):
        p = assets / f"frame_{active or 'neutral'}.png"
        if p.is_file() and sk == "3d" and p.stat().st_size > 25000:
            continue
        if not p.is_file() or (sk == "3d" and p.stat().st_size <= 25000):
            _render_frame(size, active=active, visual_style=visual_style).save(p, "PNG")
    return assets


def _segment_video_filter(
    *,
    compose_w: int,
    compose_h: int,
    dur: float,
    fps: int,
    active: str | None,
    speaker: str,
    visual_style: str,
) -> str:
    n = max(int(dur * fps), 1)
    base = f"scale={compose_w}:{compose_h}:flags=lanczos"
    if _is_3d_style(visual_style):
        grade = "eq=saturation=1.14:contrast=1.06:brightness=0.02"
        if active and speaker != "neutral":
            pan = "0.44" if active in ("sarah", "female", "lisa") else "0.54"
            return (
                f"{base},"
                f"zoompan=z='1.08+0.03*sin(2*PI*on/{n})':"
                f"x='(iw-iw/zoom)*{pan}+14*sin(on/18)':"
                f"y='(ih-ih/zoom)*0.36+8*sin(on/22)':"
                f"d={n}:s={compose_w}x{compose_h},"
                f"{grade},format=yuv420p"
            )
        return f"{base},{grade},format=yuv420p"
    if active and speaker != "neutral":
        return (
            f"{base},"
            f"zoompan=z='1+0.02*sin(2*PI*on/{n})':"
            f"x='0':y='0':d={n}:s={compose_w}x{compose_h},"
            f"format=yuv420p"
        )
    return f"{base},format=yuv420p"


def segments_from_tracks(tracks: list) -> list[SceneSegment]:
    segs: list[SceneSegment] = []
    for tr in tracks:
        dur = max(0.35, float(tr.duration))
        segs.append(SceneSegment(speaker=str(tr.speaker).lower(), duration=dur))
        pause = float(getattr(tr, "pause_after", 0.0) or 0.0)
        if pause > 0.04:
            segs.append(SceneSegment(speaker="neutral", duration=pause))
    return segs


def _speaker_frame_key(speaker: str) -> str | None:
    sp = (speaker or "").lower()
    if sp in ("sarah", "female", "lisa"):
        return "sarah"
    if sp == "laura":
        return "laura"
    if sp == "male":
        return "male"
    return None


def assemble_two_shot_dialogue(
    tracks: list,
    audio: Path,
    out: Path,
    *,
    width: int,
    height: int,
    visual_style: str = "cartoon",
    work_dir: Path | None = None,
) -> tuple[bool, str]:
    ff = _find_ffmpeg()
    if not ff:
        return False, "ffmpeg fehlt"
    if not audio.is_file():
        return False, "Audio fehlt"
    if not tracks:
        return False, "Keine Dialog-Spuren"

    work = work_dir or audio.parent
    assets = prepare_scene_assets(work, size=(width, height), visual_style=visual_style)
    segs = segments_from_tracks(tracks)
    if not segs:
        return False, "Keine Szenen-Segmente"

    audio_dur = _probe_duration(audio)
    vis_dur = sum(s.duration for s in segs)
    if audio_dur > 1.0 and vis_dur > 0.5 and abs(audio_dur - vis_dur) > 0.35:
        ratio = audio_dur / vis_dur
        segs = [SceneSegment(s.speaker, s.duration * ratio) for s in segs]

    seg_dir = work / "dialogue_segments"
    seg_dir.mkdir(parents=True, exist_ok=True)
    seg_files: list[Path] = []
    fps = 24

    compose_w = min(width, 1080)
    compose_h = int(height * (compose_w / width))
    if compose_h % 2:
        compose_h += 1

    for i, seg in enumerate(segs):
        active = _speaker_frame_key(seg.speaker)
        frame = _frame_path(assets, active)
        seg_out = seg_dir / f"seg_{i:03d}.mp4"
        dur = max(0.25, seg.duration)
        vf = _segment_video_filter(
            compose_w=compose_w,
            compose_h=compose_h,
            dur=dur,
            fps=fps,
            active=active,
            speaker=seg.speaker,
            visual_style=visual_style,
        )
        cmd = [
            ff,
            "-y",
            "-loop",
            "1",
            "-i",
            str(frame),
            "-t",
            f"{dur:.3f}",
            "-vf",
            vf,
            "-r",
            str(fps),
            "-c:v",
            "libx264",
            "-preset",
            "fast",
            "-crf",
            "20",
            "-pix_fmt",
            "yuv420p",
            str(seg_out),
        ]
        try:
            subprocess.run(cmd, capture_output=True, timeout=120, check=True)
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
            err = ""
            if isinstance(e, subprocess.CalledProcessError):
                err = (e.stderr or b"").decode(errors="replace")[-200:]
            return False, f"Segment {i}: {err or e}"[:160]
        if seg_out.is_file() and seg_out.stat().st_size > 500:
            seg_files.append(seg_out)

    if not seg_files:
        return False, "Keine Video-Segmente erzeugt"

    list_file = work / "dialogue_scene_concat.txt"
    list_file.write_text(
        "\n".join(f"file '{p.resolve().as_posix()}'" for p in seg_files),
        encoding="utf-8",
    )
    silent = work / "dialogue_scene_silent.mp4"
    try:
        subprocess.run(
            [
                ff,
                "-y",
                "-f",
                "concat",
                "-safe",
                "0",
                "-i",
                str(list_file),
                "-c",
                "copy",
                str(silent),
            ],
            capture_output=True,
            timeout=300,
            check=True,
        )
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
        return False, f"Concat: {e}"[:120]

    out.parent.mkdir(parents=True, exist_ok=True)
    scale_vf = ""
    if width != compose_w or height != compose_h:
        scale_vf = f"scale={width}:{height}:flags=lanczos,"
    grade_vf = ""
    if _is_3d_style(visual_style):
        grade_vf = "eq=saturation=1.1:contrast=1.04,"

    cmd = [
        ff,
        "-y",
        "-i",
        str(silent),
        "-i",
        str(audio),
        "-vf",
        f"{scale_vf}{grade_vf}format=yuv420p",
        "-map",
        "0:v",
        "-map",
        "1:a",
        "-c:v",
        "libx264",
        "-preset",
        "medium",
        "-crf",
        "17" if width >= 2160 else "19",
        "-c:a",
        "aac",
        "-b:a",
        "192k",
        "-t",
        f"{audio_dur:.3f}",
        "-movflags",
        "+faststart",
        str(out),
    ]
    try:
        subprocess.run(cmd, capture_output=True, timeout=600, check=True)
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
        err = ""
        if isinstance(e, subprocess.CalledProcessError):
            err = (e.stderr or b"").decode(errors="replace")[-200:]
        return False, f"Mux: {err or e}"[:160]

    from jam.ia1minute import _finalize_mp4

    _finalize_mp4(ff, out)
    if out.is_file() and out.stat().st_size > 5000:
        total = sum(s.duration for s in segs)
        label = "3D-Trickfilm" if _is_3d_style(visual_style) else "Dialog-Szene"
        return True, f"{label} · 2 Frauen gegenüber · {total:.0f}s · {width}×{height}"
    return False, "Export leer"
