"""
JamPulse Minute – Ein Prompt → Skript → Sprache → Promo-Video (60–120 Sek., bis 4K).

  python scripts/ia1minute.py "Website ab 99 Euro" --duration 120 --4k
"""

from __future__ import annotations

import asyncio
import json
import os
import re
import subprocess
import textwrap
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def _output_root() -> Path:
    try:
        from jam.jampulse_desktop import videos_minute_dir

        return videos_minute_dir()
    except ImportError:
        p = Path.home() / "Documents" / "IA-1-Minute"
        p.mkdir(parents=True, exist_ok=True)
        return p


OUTPUT_ROOT = _output_root()
SECRETS = ROOT / "data" / "jami-secrets.local.json"

MIN_DURATION_SEC = 60.0
MAX_DURATION_SEC = 120.0
DEFAULT_DURATION_SEC = 60
WORDS_PER_MINUTE = 185

RESOLUTIONS: dict[str, dict[str, tuple[int, int]]] = {
    "1080p": {
        "9:16": (1080, 1920),
        "16:9": (1920, 1080),
        "1:1": (1080, 1080),
        "3:4": (1080, 1440),
    },
    "4k": {
        "9:16": (2160, 3840),
        "16:9": (3840, 2160),
        "1:1": (2160, 2160),
        "3:4": (2160, 2880),
    },
}


@dataclass
class ExportConfig:
    target_sec: int = DEFAULT_DURATION_SEC
    resolution: str = "1080p"
    aspect_ratio: str = "9:16"

    def normalize(self) -> ExportConfig:
        sec = int(self.target_sec)
        sec = max(int(MIN_DURATION_SEC), min(int(MAX_DURATION_SEC), sec))
        res = "4k" if str(self.resolution).lower() in ("4k", "2160p", "uhd") else "1080p"
        aspect = self.aspect_ratio if self.aspect_ratio in RESOLUTIONS["1080p"] else "9:16"
        return ExportConfig(target_sec=sec, resolution=res, aspect_ratio=aspect)

    @property
    def size(self) -> tuple[int, int]:
        cfg = self.normalize()
        return RESOLUTIONS[cfg.resolution][cfg.aspect_ratio]

    @property
    def is_4k(self) -> bool:
        return self.normalize().resolution == "4k"

    @property
    def word_target(self) -> int:
        cfg = self.normalize()
        return int(WORDS_PER_MINUTE * (cfg.target_sec / 60.0))

    @property
    def min_voice_sec(self) -> float:
        return self.normalize().target_sec * 0.86

    @property
    def scene_count(self) -> int:
        return max(6, min(12, int(self.normalize().target_sec / 10)))


# Legacy-Konstanten (Abwärtskompatibilität)
TARGET_SEC = DEFAULT_DURATION_SEC
HD_WORDS = 175
STD_WORDS = 165
COMMERCIAL_WORDS = 175
MIN_VOICE_SEC = 52.0


@dataclass
class Scene:
    start: float
    duration: float
    overlay: str
    stock_query: str = "business smartphone portrait"


@dataclass
class VideoPlan:
    prompt: str
    title: str
    hook: str
    voiceover: str
    caption: str
    hashtags: str
    stock_query: str
    scenes: list[Scene] = field(default_factory=list)
    brand: str = ""
    cta: str = ""
    dialogue: list[dict[str, str]] = field(default_factory=list)


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 _word_count(text: str) -> int:
    return len(re.findall(r"\w+", text, flags=re.UNICODE))


def _trim_words(text: str, max_words: int) -> str:
    words = re.findall(r"\S+", text)
    if len(words) <= max_words:
        return text.strip()
    return " ".join(words[:max_words]).rstrip(".,;:") + "…"


def _word_target(*, export: ExportConfig, commercial: bool, hd: bool = False) -> int:
    if commercial:
        return export.word_target
    if hd:
        return max(HD_WORDS, export.word_target)
    return max(STD_WORDS, int(export.word_target * 0.9))


def _tts_rate(export: ExportConfig, *, commercial: bool) -> str:
    sec = export.normalize().target_sec
    if not commercial:
        return "-8%"
    if sec >= 110:
        return "-12%"
    if sec >= 80:
        return "-11%"
    return "-10%"


def _commercial_fillers(plan: VideoPlan) -> list[str]:
    cta = plan.cta or "Link in Bio."
    brand = plan.brand or "JamPulse"
    return [
        f"Zusammengefasst: {plan.hook}.",
        "Der wichtigste Punkt ist, dass du heute den ersten Schritt machst.",
        "Nicht perfekt planen – sondern anfangen und unterwegs verbessern.",
        "Wenn du das umsetzt, siehst du schneller Ergebnisse als du denkst.",
        "Viele scheitern nicht am Wissen, sondern daran, dass sie zu lange warten.",
        "Stell dir vor, du schaust dieses Video in vier Wochen nochmal an – was hättest du gern schon umgesetzt?",
        "Kleine Fortschritte täglich schlagen einen perfekten Plan, der nie startet.",
        "Frag dich ehrlich: Was ist der eine Schritt, den du heute noch machen kannst?",
        "Genau dieser Schritt trennt Wunschdenken von echten Ergebnissen.",
        f"Mehr Details, Beispiele und Hilfe findest du bei {brand}.",
        f"Call to Action: {cta}",
        "Speichere das Video, wenn du es später noch brauchst.",
        "Teile es mit jemandem, der genau das gerade sucht.",
        "Kommentiere, wenn du dazu noch Fragen hast – ich lese mit.",
        "Und vergiss nicht: Jede große Veränderung beginnt mit einer kleinen Entscheidung.",
    ]


def _extend_voiceover_text(plan: VideoPlan, *, min_words: int) -> str:
    text = plan.voiceover.strip()
    if _word_count(text) >= min_words:
        return text
    parts = [text]
    for block in _commercial_fillers(plan):
        parts.append(block)
        merged = " ".join(parts)
        if _word_count(merged) >= min_words:
            return _trim_words(merged, min_words + 12)
    return _trim_words(" ".join(parts), min_words + 12)


def _fallback_plan(
    prompt: str,
    *,
    export: ExportConfig,
    hd: bool = False,
    commercial: bool = False,
) -> VideoPlan:
    cfg = export.normalize()
    p = prompt.strip()
    hook = _trim_words(p, 8)
    if len(hook) < 10:
        hook = f"{p[:60]} – in {cfg.target_sec} Sekunden erklärt"

    parts = [
        f"Du willst: {p}.",
        "Hier die Kurzversion – ohne Schnickschnack, aber mit Plan.",
        "Schritt eins: Ziel klären. Was soll am Ende passieren?",
        "Schritt zwei: Umsetzen. Einfach starten schlägt perfekt warten.",
        "Schritt drei: Messen und verbessern. Kleine Schritte, großer Effekt.",
        "Fazit: Ein Prompt reicht – der Rest ist Disziplin und Action.",
    ]
    brand = ""
    cta = "Link in Bio."
    pl = p.lower()
    if "jami" in pl or "dating" in pl:
        brand = "Jami-Dating"
        cta = "jami-dating.de – 100 Gratis-Coins."
    elif "ki web" in pl or "website" in pl or "web studio" in pl:
        brand = "KI Web Studio"
        cta = "ki-web-studio.de – ab 99 Euro."

    if hd or commercial:
        parts[1:1] = [
            f"In den nächsten {cfg.target_sec} Sekunden bekommst du alles Wichtige – klar und umsetzbar.",
            "Erst das Warum: Ohne klares Ziel verlierst du Zeit und Nerven.",
            "Dann das Wie: Konkrete Schritte schlagen endlose Planung.",
            "Am Ende zählt nur, ob du heute anfängst – nicht ob alles perfekt ist.",
        ]
    if commercial:
        parts.extend([
            "Das ist kein Theorie-Video – du kannst jeden Schritt direkt nachmachen.",
            "Merke dir: Konstanz schlägt Perfektion, besonders wenn du gerade startest.",
            f"Wenn du Hilfe brauchst: {cta}",
        ])
    if cfg.target_sec >= 90:
        parts.extend([
            "Lass uns etwas tiefer gehen, damit du wirklich alles mitnimmst.",
            "Typischer Fehler Nummer eins: Zu viel auf einmal wollen und dann gar nichts tun.",
            "Besser: Ein klares Mini-Ziel pro Woche – messbar und realistisch.",
            "Typischer Fehler Nummer zwei: Nur konsumieren, nie dokumentieren, was funktioniert.",
            "Schreib dir nach jedem Schritt eine Zeile auf: Was hat geklappt, was nicht?",
            "So baust du dein eigenes Playbook – und wirst unabhängig von random Tipps.",
        ])
    if cfg.target_sec >= 110:
        parts.extend([
            "Und jetzt der Part, den die meisten überspringen: Wiederholung.",
            "Einmal schauen reicht nicht. Schau dir die Kernpunkte in zwei Tagen nochmal an.",
            "Teile das Video oder sende es an dein Team – Alignment spart Wochen.",
            "Wenn du heute nur eine Sache mitnimmst: Starte den kleinsten machbaren Schritt sofort.",
            "Alles andere ist Bonus. Du schaffst das – Schritt für Schritt.",
        ])

    voice = " ".join(parts)
    voice = _trim_words(voice, _word_target(export=cfg, commercial=commercial, hd=hd))

    n_scenes = cfg.scene_count
    overlay_templates = [
        hook[:48],
        "So geht's ↓",
        "Schritt 1 · Ziel",
        "Schritt 2 · Umsetzen",
        "Schritt 3 · Optimieren",
        "Praxis-Tipp",
        "Typische Fehler",
        "Dein Playbook",
        "Wiederholen & teilen",
        "Jetzt starten",
        "Merken & speichern",
        cta[:42],
    ]
    overlays = overlay_templates[:n_scenes]
    step = cfg.target_sec / len(overlays)
    scenes = [
        Scene(start=round(i * step, 1), duration=round(step, 1), overlay=txt, stock_query="social media creator")
        for i, txt in enumerate(overlays)
    ]

    tags = "#shorts #deutsch #ki #content #promo"
    if cfg.target_sec >= 90:
        tags += " #longform"
    if brand == "Jami-Dating":
        tags = "#dating #jami #singles #shorts #deutsch"
    elif brand == "KI Web Studio":
        tags = "#webdesign #website #ki #shorts #deutsch"

    return VideoPlan(
        prompt=p,
        title=_trim_words(p, 6),
        hook=hook,
        voiceover=voice,
        caption=f"{hook}\n\n{p}\n\n{cta}\n\n{tags}",
        hashtags=tags,
        stock_query="content creator smartphone vertical",
        scenes=scenes,
        brand=brand,
        cta=cta,
    )


def plan_from_prompt(
    prompt: str,
    *,
    export: ExportConfig,
    hd: bool = False,
    commercial: bool = False,
) -> VideoPlan:
    cfg = export.normalize()
    key = _openai_key()
    if not key:
        return _fallback_plan(prompt, export=cfg, hd=hd, commercial=commercial)

    max_words = _word_target(export=cfg, commercial=commercial, hd=hd)
    scene_n = cfg.scene_count
    if commercial:
        quality_hint = (
            f"Voiceover MUSS mindestens {int(cfg.min_voice_sec)} Sekunden Sprechzeit füllen "
            f"(~{max_words} Wörter für {cfg.target_sec} Sek.), professionell, ohne Wiederholungen."
        )
    elif hd:
        quality_hint = f"Voiceover ~{max_words} Wörter für {cfg.target_sec} Sekunden."
    else:
        quality_hint = f"Voiceover mindestens {max_words} Wörter."
    system = (
        f"Du bist Video-Regisseur für deutsche Social-Videos ({cfg.aspect_ratio}, "
        f"exakt {cfg.target_sec} Sekunden Laufzeit). "
        "Antworte NUR mit validem JSON ohne Markdown. Felder: title, hook (max 8 Wörter), "
        f"voiceover (deutsch, {quality_hint}), caption, hashtags, stock_query (english), "
        f"brand, cta, scenes (Liste mit overlay max 42 Zeichen, {scene_n} Einträge)."
    )
    max_tokens = min(2500, 800 + cfg.target_sec * 12)
    payload = {
        "model": "gpt-4o-mini",
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": f"Prompt/Thema:\n{prompt.strip()}"},
        ],
        "max_tokens": max_tokens,
        "temperature": 0.7,
    }
    try:
        req = urllib.request.Request(
            "https://api.openai.com/v1/chat/completions",
            data=json.dumps(payload).encode(),
            headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=90) as resp:
            raw = json.loads(resp.read().decode())["choices"][0]["message"]["content"].strip()
        raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.I)
        data = json.loads(raw)
        voice = _trim_words(str(data.get("voiceover") or ""), max_words)
        if not voice:
            return _fallback_plan(prompt, export=cfg, hd=hd, commercial=commercial)

        scenes_raw = data.get("scenes") or []
        overlays: list[str] = []
        for s in scenes_raw[: scene_n + 2]:
            if isinstance(s, dict):
                overlays.append(str(s.get("overlay") or "")[:42])
            else:
                overlays.append(str(s)[:42])
        if not overlays:
            overlays = [str(data.get("hook") or "")[:42], str(data.get("cta") or "Jetzt starten")[:42]]
        overlays = overlays[:scene_n]
        step = cfg.target_sec / max(len(overlays), 1)
        stock = str(data.get("stock_query") or "social media portrait").strip()
        scenes = [
            Scene(
                start=round(i * step, 1),
                duration=round(step, 1),
                overlay=(txt or f"Szene {i + 1}").strip(),
                stock_query=stock,
            )
            for i, txt in enumerate(overlays)
        ]
        return VideoPlan(
            prompt=prompt.strip(),
            title=str(data.get("title") or prompt[:40])[:60],
            hook=str(data.get("hook") or "")[:80],
            voiceover=voice,
            caption=str(data.get("caption") or voice)[:2000],
            hashtags=str(data.get("hashtags") or "#shorts #deutsch")[:200],
            stock_query=stock,
            scenes=scenes,
            brand=str(data.get("brand") or "")[:40],
            cta=str(data.get("cta") or "")[:80],
        )
    except (urllib.error.URLError, json.JSONDecodeError, KeyError, OSError):
        return _fallback_plan(prompt, export=cfg, hd=hd, commercial=commercial)


def weekend_date_turns() -> list[tuple[str, str]]:
    """Sarah & Laura – Das Wochenend-Date, ~60 Sekunden."""
    return [
        (
            "sarah",
            "Du glaubst NIEMALS, was mir passiert ist! Ich habe es endlich getan. "
            "Ich habe mich gestern Abend bei Jami angemeldet!",
        ),
        (
            "laura",
            "Was, echt?! Bei der neuen Dating-Seite, von der alle reden? "
            "Und? Wie ist es? Erzähl!",
        ),
        (
            "sarah",
            "Total verrückt. Kein ewiges Hin- und Herschreiben wie bei den anderen Apps. "
            "Die Leute da meinen es echt ernst. Und das Beste: Ich habe direkt jemanden "
            "gematcht, der absolut mein Typ ist.",
        ),
        (
            "laura",
            "Nein, wie schnell ging das denn? Habt ihr schon Nummern getauscht oder was?",
        ),
        (
            "sarah",
            "Besser! Wir haben für dieses Wochenende schon ein Date ausgemacht. "
            "Wir gehen am Samstagabend zusammen in diese neue Rooftop-Bar!",
        ),
        (
            "laura",
            "Oh mein Gott, wie geil! Okay, wir müssen SOFORT dein Outfit planen. "
            "Jami funktioniert ja scheinbar echt!",
        ),
    ]


def jami_dialogue_turns() -> list[tuple[str, str]]:
    """Lisa & Tom – lockeres Café-Gespräch über Jami, ~60 Sekunden."""
    return [
        ("female", "Tom, du bist doch noch single, oder?"),
        ("male", "Jo, wieso — was gibt's denn?"),
        ("female", "Kennst du schon Jami? Die Dating-App aus Deutschland?"),
        ("male", "Nee, ehrlich gesagt nicht. Ist da wieder so ein Abo, nur um zu schreiben?"),
        ("female", "Nö, genau das nicht. Du zahlst halt nur Coins, wenn du wirklich ne Nachricht schickst."),
        ("male", "Ah okay. Und was kostet so ne Nachricht?"),
        ("female", "Zehn Coins. Und am Anfang kriegst du hundert Stück geschenkt — zum Reinschnuppern."),
        ("male", "Hmm, klingt fair. Sind da auch echte Leute drauf?"),
        ("female", "Klar, alles aus Deutschland. Swipen und Matchen ist komplett gratis."),
        ("male", "Was ist denn besser als bei Tinder?"),
        ("female", "Du entscheidest selbst, wann du zahlst. Kein monatlicher Druck."),
        ("male", "Hört sich echt gut an. Wie heißt die Seite nochmal?"),
        ("female", "Jami Dating — jami-dating.de. Probier's einfach mal aus."),
    ]


def _dialogue_to_voiceover(turns: list[tuple[str, str]]) -> str:
    labels = {
        "female": "Lisa", "male": "Tom", "sarah": "Sarah", "laura": "Laura",
    }
    lines = []
    for sp, text in turns:
        name = labels.get(sp, sp.capitalize() if sp else "?")
        lines.append(f"{name}: {text}")
    return "\n".join(lines)


def plan_dialogue(
    prompt: str,
    *,
    export: ExportConfig,
    turns: list[tuple[str, str]] | None = None,
    title: str = "",
    stock_query: str = "",
) -> VideoPlan:
    cfg = export.normalize()
    dialogue_turns = turns or jami_dialogue_turns()
    dialogue = [{"speaker": sp, "text": txt} for sp, txt in dialogue_turns]
    voiceover = _dialogue_to_voiceover(dialogue_turns)
    n = cfg.scene_count
    stock = stock_query or "friends talking cafe smartphone vertical"
    scenes = [
        Scene(
            start=round(i * (cfg.target_sec / n), 1),
            duration=round(cfg.target_sec / n, 1),
            overlay="",
            stock_query=stock,
        )
        for i in range(n)
    ]
    vid_title = title or "Jami-Dating Gespräch"
    hook = title or "Zwei Freunde über Jami-Dating"
    cta = "jami-dating.de – 100 Gratis-Coins"
    return VideoPlan(
        prompt=prompt.strip(),
        title=vid_title,
        hook=hook,
        voiceover=voiceover,
        caption=f"{hook}\n\nEchte Singles, faire Coins, kein Abo.\n\n{cta}",
        hashtags="#dating #jami #singles #gespräch #deutsch #jampulse",
        stock_query=stock,
        scenes=scenes,
        brand="Jami-Dating",
        cta=cta,
        dialogue=dialogue,
    )


def synthesize_dialogue(
    turns: list[tuple[str, str]],
    out: Path,
    *,
    commercial: bool = True,
) -> tuple[bool, str]:
    from jam.promo_tts import synthesize_dialogue_voice

    ok, msg, _engine = synthesize_dialogue_voice(turns, out, commercial=commercial)
    return ok, msg


def fit_dialogue_to_target(
    plan: VideoPlan,
    voice_path: Path,
    *,
    export: ExportConfig,
    commercial: bool = True,
) -> tuple[bool, str, float]:
    cfg = export.normalize()
    min_voice = cfg.min_voice_sec
    turns = [(d["speaker"], d["text"]) for d in plan.dialogue]
    if not turns:
        turns = jami_dialogue_turns()
        plan.dialogue = [{"speaker": sp, "text": txt} for sp, txt in turns]
        plan.voiceover = _dialogue_to_voiceover(turns)

    extra_turns = [
        ("laura", "Erzähl mir noch mehr! Das klingt ja mega."),
        ("sarah", "Jami ist echt anders. Probier's einfach mal aus."),
    ]

    best_dur = 0.0
    for attempt in range(5):
        ok, msg = synthesize_dialogue(turns, voice_path, commercial=commercial)
        if not ok:
            return False, msg, 0.0
        best_dur = _probe_media_duration(voice_path)
        if best_dur >= min_voice:
            if best_dur <= cfg.target_sec * 1.08:
                return True, msg, best_dur
            # Dialog zu lang – kürzen
            if len(turns) > 6:
                turns = turns[:-1]
                plan.dialogue = [{"speaker": sp, "text": txt} for sp, txt in turns]
                plan.voiceover = _dialogue_to_voiceover(turns)
                continue
            return True, msg, best_dur
        if attempt < len(extra_turns):
            sp, txt = extra_turns[attempt]
            turns = turns + [(sp, txt)]
            plan.dialogue.append({"speaker": sp, "text": txt})
            plan.voiceover = _dialogue_to_voiceover(turns)

    if best_dur >= min_voice * 0.85:
        return True, str(voice_path), best_dur
    return False, f"Dialog zu kurz ({best_dur:.1f}s, min. {min_voice:.0f}s)", best_dur


async def _tts_async(text: str, out: Path, *, export: ExportConfig, commercial: bool = False) -> None:
    """Legacy-Fallback – wird von promo_tts ersetzt."""
    import edge_tts

    rate = _tts_rate(export, commercial=commercial)
    comm = edge_tts.Communicate(
        text,
        "de-DE-SeraphinaMultilingualNeural",
        rate=rate,
        pitch="-1Hz",
        volume="+6%",
    )
    await comm.save(str(out))


def synthesize_voice(
    text: str,
    out: Path,
    *,
    export: ExportConfig | None = None,
    hd: bool = False,
    commercial: bool = False,
    prompt: str = "",
) -> tuple[bool, str]:
    from jam.promo_tts import synthesize_promo_voice

    cfg = (export or ExportConfig()).normalize()
    ok, msg, _engine = synthesize_promo_voice(
        text,
        out,
        target_sec=cfg.target_sec,
        commercial=commercial or hd,
        prompt=prompt,
    )
    return ok, msg


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

    return _find_ffmpeg()


def _ffprobe_path() -> str | None:
    ff = _find_ffmpeg()
    if not ff:
        return None
    ffprobe = ff.replace("ffmpeg.exe", "ffprobe.exe").replace("ffmpeg", "ffprobe")
    if not Path(ffprobe).is_file() and shutil_which("ffprobe"):
        ffprobe = shutil_which("ffprobe") or ffprobe
    return ffprobe if Path(ffprobe).is_file() or shutil_which("ffprobe") else None


def _probe_media_duration(path: Path) -> float:
    ffprobe = _ffprobe_path()
    if not ffprobe:
        return 0.0
    try:
        r = subprocess.run(
            [ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path)],
            capture_output=True,
            text=True,
            timeout=30,
        )
        return max(0.0, float(r.stdout.strip()))
    except (ValueError, subprocess.SubprocessError, OSError):
        return 0.0


def _audio_duration(path: Path, *, max_sec: float | None = None) -> float:
    dur = _probe_media_duration(path)
    if dur <= 0:
        return float(max_sec or DEFAULT_DURATION_SEC)
    cap = max_sec or MAX_DURATION_SEC
    return min(cap, max(5.0, dur))


def fit_voiceover_to_target(
    plan: VideoPlan,
    voice_path: Path,
    *,
    export: ExportConfig,
    hd: bool = False,
    commercial: bool = False,
) -> tuple[bool, str, float]:
    """TTS wiederholen/verlängern bis mindestens min_voice_sec Sprechzeit."""
    cfg = export.normalize()
    min_words = _word_target(export=cfg, commercial=commercial, hd=hd)
    min_voice = cfg.min_voice_sec if commercial else min_words / 2.5
    text = _extend_voiceover_text(plan, min_words=min_words) if commercial else plan.voiceover
    plan.voiceover = text

    best_dur = 0.0
    attempts = 6 if cfg.target_sec >= 90 else 4
    for attempt in range(attempts):
        ok, msg = synthesize_voice(
            text, voice_path, export=cfg, hd=hd, commercial=commercial, prompt=plan.prompt
        )
        if not ok:
            return False, msg, 0.0
        best_dur = _probe_media_duration(voice_path)
        if not commercial or best_dur >= min_voice:
            return True, msg, best_dur
        extra = _commercial_fillers(plan)[attempt % len(_commercial_fillers(plan))]
        text = _trim_words(f"{text} {extra}", min_words + 30)
        plan.voiceover = text

    if best_dur >= min_voice * 0.85:
        return True, str(voice_path), best_dur
    return False, f"Voiceover zu kurz ({best_dur:.1f}s, min. {min_voice:.0f}s)", best_dur


def shutil_which(name: str) -> str | None:
    import shutil

    return shutil.which(name)


def _fontfile() -> str:
    candidates = [
        Path(r"C:\Windows\Fonts\arial.ttf"),
        Path(r"C:\Windows\Fonts\segoeui.ttf"),
        Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
    ]
    for p in candidates:
        if p.is_file():
            s = str(p).replace("\\", "/").replace(":", "\\:")
            return s
    return ""


def _escape_drawtext(s: str) -> str:
    s = s.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'").replace("%", "\\%")
    return re.sub(r"[^a-zA-Z0-9 @#._äöüÄÖÜß-]", "", s)[:48]


def _style_color_filters(visual_style: str, *, hd: bool, is_4k: bool) -> list[str]:
    style = (visual_style or "realistic").strip().lower()
    if style in ("cartoon", "comic", "anime"):
        # Stabil & abspielbar – kein edgedetect (kann MP4 beschädigen)
        return [
            "eq=saturation=1.65:contrast=1.15:brightness=0.05",
            "hue=s=1.4",
            "curves=all='0/0 0.45/0.55 1/1'",
        ]
    eq = "eq=brightness=0.03:saturation=1.08:contrast=1.04" if (hd or is_4k) else "eq=brightness=0.02:saturation=1.06"
    out = [eq]
    if hd or is_4k:
        out.append("unsharp=5:5:0.6:5:5:0.0")
    return out


def _build_drawtext_filter(
    scenes: list[Scene],
    duration: float,
    *,
    export: ExportConfig,
    hd: bool = False,
    visual_style: str = "realistic",
) -> str:
    cfg = export.normalize()
    w, h = cfg.size
    if not scenes:
        base = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
        return ",".join([base, *_style_color_filters(visual_style, hd=hd, is_4k=cfg.is_4k)])
    font = _fontfile()
    font_part = f":fontfile='{font}'" if font else ""
    base_fs = 46
    if cfg.is_4k:
        fontsize = int(base_fs * (w / 1080))
    elif hd:
        fontsize = 52
    else:
        fontsize = base_fs
    parts = [
        f"scale={w}:{h}:force_original_aspect_ratio=increase",
        f"crop={w}:{h}",
        *_style_color_filters(visual_style, hd=hd, is_4k=cfg.is_4k),
    ]
    borderw = max(3, int(fontsize / 16))
    for sc in scenes:
        if not sc.overlay.strip():
            continue
        txt = _escape_drawtext(sc.overlay)
        start = max(0.0, sc.start)
        end = min(duration, sc.start + sc.duration)
        parts.append(
            f"drawtext=text='{txt}':fontsize={fontsize}{font_part}:fontcolor=white:borderw={borderw}:bordercolor=black:"
            f"x=(w-text_w)/2:y=h*0.78:enable='between(t,{start:.2f},{end:.2f})'"
        )
    return ",".join(parts)


def _finalize_mp4(ff: str, path: Path) -> bool:
    """Moov-Atom nach vorne – verhindert „beschädigte“ MP4 in Browser/Player."""
    if not path.is_file() or path.stat().st_size < 5000:
        return False
    tmp = path.with_suffix(".fix.mp4")
    try:
        subprocess.run(
            [
                ff, "-y", "-i", str(path),
                "-c", "copy", "-movflags", "+faststart", str(tmp),
            ],
            capture_output=True, timeout=180, check=True,
        )
        if tmp.is_file() and tmp.stat().st_size > 5000:
            tmp.replace(path)
            return True
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        if tmp.is_file():
            tmp.unlink(missing_ok=True)
    return path.is_file()


def assemble_video(
    stock: Path,
    audio: Path,
    plan: VideoPlan,
    out: Path,
    *,
    export: ExportConfig,
    duration: float | None = None,
    hd: bool = False,
    commercial: bool = False,
    visual_style: str = "realistic",
) -> tuple[bool, str]:
    ff = _find_ffmpeg()
    if not ff:
        return False, "ffmpeg fehlt – Video nur als Audio+Plan gespeichert."
    if not stock.is_file():
        return False, "Stock-Video fehlt."
    if not audio.is_file():
        return False, "Audio fehlt."

    cfg = export.normalize()
    out_sec = float(duration or cfg.target_sec)
    out_sec = max(MIN_DURATION_SEC, min(MAX_DURATION_SEC, out_sec))
    out.parent.mkdir(parents=True, exist_ok=True)
    vf = _build_drawtext_filter(
        plan.scenes, out_sec, export=cfg, hd=hd, visual_style=visual_style
    )
    high_q = hd or commercial or cfg.is_4k
    preset = "slow" if cfg.is_4k else ("medium" if high_q else "fast")
    crf = "15" if cfg.is_4k else ("17" if high_q else "22")
    abit = "256k" if cfg.is_4k else ("192k" if high_q else "128k")
    af = f"apad=whole_dur={out_sec:.2f},atrim=0:{out_sec:.2f}"
    timeout = 600 if cfg.is_4k else 300

    cmd = [
        ff,
        "-y",
        "-stream_loop",
        "-1",
        "-i",
        str(stock),
        "-i",
        str(audio),
        "-t",
        f"{out_sec:.2f}",
        "-vf",
        vf,
        "-af",
        af,
        "-map",
        "0:v",
        "-map",
        "1:a",
        "-c:v",
        "libx264",
        "-preset",
        preset,
        "-crf",
        crf,
        "-pix_fmt",
        "yuv420p",
        "-c:a",
        "aac",
        "-b:a",
        abit,
        "-movflags",
        "+faststart",
        str(out),
    ]
    try:
        subprocess.run(cmd, capture_output=True, timeout=timeout, check=True)
        _finalize_mp4(ff, out)
        if out.is_file() and out.stat().st_size > 5000:
            w, h = cfg.size
            label = f"{w}×{h}" if cfg.is_4k else ("1080p HD" if hd else "HD")
            return True, f"Video {out_sec:.0f}s · {label}"
    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"ffmpeg: {err or e}"[:160]
    return False, "Video-Export leer"


def ensure_min_video_duration(path: Path, *, min_sec: float = MIN_DURATION_SEC) -> tuple[bool, float, str]:
    """Stellt sicher, dass die MP4 mindestens min_sec lang ist (verkaufbar)."""
    dur = _probe_media_duration(path)
    if dur >= min_sec - 0.05:
        return True, dur, f"{dur:.1f}s"

    ff = _find_ffmpeg()
    if not ff or not path.is_file():
        return False, dur, "Video fehlt"

    pad = max(0.0, min_sec - dur)
    tmp = path.with_suffix(".pad.mp4")
    cmd = [
        ff,
        "-y",
        "-i",
        str(path),
        "-vf",
        f"tpad=stop_mode=clone:stop_duration={pad:.3f}",
        "-af",
        f"apad=pad_dur={pad:.3f}",
        "-t",
        f"{min_sec:.2f}",
        "-c:v",
        "libx264",
        "-preset",
        "fast",
        "-crf",
        "18",
        "-c:a",
        "aac",
        str(tmp),
    ]
    try:
        subprocess.run(cmd, capture_output=True, timeout=180, check=True)
        if tmp.is_file() and tmp.stat().st_size > 5000:
            tmp.replace(path)
            new_dur = _probe_media_duration(path)
            if new_dur >= min_sec - 0.05:
                return True, new_dur, f"auf {new_dur:.1f}s verlängert"
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        if tmp.is_file():
            tmp.unlink(missing_ok=True)
    return False, dur, f"zu kurz ({dur:.1f}s, min. {min_sec:.0f}s)"


def write_project_files(work: Path, plan: VideoPlan, *, video: Path | None = None) -> None:
    work.mkdir(parents=True, exist_ok=True)
    (work / "plan.json").write_text(
        json.dumps(
            {
                **asdict(plan),
                "scenes": [asdict(s) for s in plan.scenes],
                "generated_at": datetime.now().isoformat(timespec="seconds"),
            },
            ensure_ascii=False,
            indent=2,
        )
        + "\n",
        encoding="utf-8",
    )
    (work / "caption.txt").write_text(
        f"{plan.caption}\n\n{plan.hashtags}\n".strip() + "\n",
        encoding="utf-8",
    )
    (work / "voiceover.txt").write_text(plan.voiceover + "\n", encoding="utf-8")
    readme = textwrap.dedent(
        f"""\
        JamPulse Minute – {plan.title}
        ================================

        Prompt: {plan.prompt}

        Dateien:
          video.mp4      – Fertiges Promo-Video (60–120 Sek.)
          voice.mp3      – Sprachspur
          caption.txt    – TikTok/Reels Text (kopieren)
          plan.json      – KI-Skript

        Angebot: JamPulse Minute – Promo-Video (DE), bis 2 Min. / 4K.
        """
    )
    (work / "README.txt").write_text(readme, encoding="utf-8")


def run_ia1minute(
    prompt: str,
    *,
    work_dir: Path | None = None,
    open_folder: bool = False,
    hd: bool = False,
    commercial: bool = True,
    target_sec: int = DEFAULT_DURATION_SEC,
    resolution: str = "1080p",
    aspect_ratio: str = "9:16",
    dialogue: bool = False,
    dialogue_turns: list[tuple[str, str]] | None = None,
    dialogue_title: str = "",
    stock_query: str = "",
    visual_style: str = "realistic",
) -> dict:
    """Volle Pipeline: Prompt → Plan → TTS → Stock → Video (60–120 Sek., bis 4K)."""
    export = ExportConfig(
        target_sec=target_sec, resolution=resolution, aspect_ratio=aspect_ratio
    ).normalize()
    if dialogue and visual_style in ("cartoon", "realistic", ""):
        visual_style = "3d"
    if export.is_4k or export.target_sec >= 90:
        hd = True

    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    slug = re.sub(r"[^a-z0-9]+", "-", prompt.lower())[:30].strip("-") or "video"
    work = work_dir or (OUTPUT_ROOT / f"{stamp}-{slug}")

    t0 = time.time()
    if dialogue:
        plan = plan_dialogue(
            prompt,
            export=export,
            turns=dialogue_turns,
            title=dialogue_title,
            stock_query=stock_query or (
                "cartoon animation two women friends colorful"
                if visual_style in ("cartoon", "comic", "anime")
                else ""
            ),
        )
    else:
        plan = plan_from_prompt(prompt, export=export, hd=hd, commercial=commercial)
    work.mkdir(parents=True, exist_ok=True)

    voice_path = work / "voice.mp3"
    if dialogue:
        ok_tts, tts_msg, voice_sec = fit_dialogue_to_target(
            plan, voice_path, export=export, commercial=commercial
        )
    elif commercial:
        ok_tts, tts_msg, voice_sec = fit_voiceover_to_target(
            plan, voice_path, export=export, hd=hd, commercial=True
        )
    else:
        ok_tts, tts_msg = synthesize_voice(
            plan.voiceover, voice_path, export=export, hd=hd, commercial=False, prompt=plan.prompt
        )
        voice_sec = _probe_media_duration(voice_path)
    if not ok_tts:
        write_project_files(work, plan)
        return {
            "ok": False,
            "error": tts_msg,
            "work_dir": str(work),
            "plan": asdict(plan),
        }

    audio_path = voice_path
    if dialogue:
        mixed = work / "voice_mixed.mp3"
        clip_preview = min(MAX_DURATION_SEC, max(MIN_DURATION_SEC, voice_sec))
        from jam.promo_bgm import mix_voice_with_bgm

        if mix_voice_with_bgm(
            voice_path, mixed, duration_sec=clip_preview, music_volume=0.13
        ):
            audio_path = mixed

    video_path = work / "video.mp4"
    stock_note = ""

    if dialogue:
        from jam.artist_pipeline import assemble_dialogue_video
        from jam.promo_tts import collect_dialogue_tracks

        turns = [(d["speaker"], d["text"]) for d in plan.dialogue]
        tracks = collect_dialogue_tracks(voice_path, turns)
        if not tracks:
            write_project_files(work, plan)
            return {
                "ok": False,
                "error": "Dialog-Spuren fehlen – TTS erneut ausführen",
                "work_dir": str(work),
                "voice": str(voice_path),
            }
        w, h = export.size
        ok_vid, vid_msg = assemble_dialogue_video(
            tracks,
            audio_path,
            video_path,
            width=w,
            height=h,
            visual_style=visual_style,
            work_dir=work,
            turns=turns,
            aspect_ratio=export.aspect_ratio,
        )
        stock_note = "Artist Dialog (KI-Clips + Stimme)"
    else:
        from jam.tiktok_remix import download_stock_clip

        stock_path, stock_note = download_stock_clip(
            plan.stock_query,
            prefer_unusual=visual_style not in ("cartoon", "comic", "anime"),
            hd=hd or commercial or export.is_4k,
        )
        if not stock_path:
            write_project_files(work, plan)
            return {
                "ok": False,
                "error": f"Stock-Video: {stock_note}",
                "work_dir": str(work),
                "voice": str(voice_path),
            }

        import shutil

        stock_local = work / "stock.mp4"
        shutil.copy2(stock_path, stock_local)
        clip_dur = float(export.target_sec)
        ok_vid, vid_msg = assemble_video(
            stock_local,
            audio_path,
            plan,
            video_path,
            export=export,
            duration=clip_dur,
            hd=hd,
            commercial=commercial,
            visual_style=visual_style,
        )
    final_sec = 0.0
    if ok_vid and commercial and not dialogue:
        ok_len, final_sec, len_msg = ensure_min_video_duration(
            video_path, min_sec=float(export.target_sec)
        )
        if not ok_len:
            ok_vid = False
            vid_msg = len_msg
        else:
            vid_msg = f"{vid_msg} · {len_msg}"
    elif ok_vid:
        final_sec = _probe_media_duration(video_path)

    write_project_files(work, plan, video=video_path if ok_vid else None)

    if open_folder and work.is_dir():
        try:
            os.startfile(str(work))  # type: ignore[attr-defined]
        except OSError:
            pass

    elapsed = round(time.time() - t0, 1)
    return {
        "ok": ok_vid,
        "prompt": prompt,
        "title": plan.title,
        "hook": plan.hook,
        "duration_sec": round(final_sec or export.target_sec, 1) if ok_vid else round(dur, 1),
        "target_sec": export.target_sec,
        "resolution": export.resolution,
        "aspect_ratio": export.aspect_ratio,
        "word_count": _word_count(plan.voiceover),
        "voice_sec": round(voice_sec, 1),
        "min_duration_guaranteed": commercial,
        "video": str(video_path) if ok_vid else None,
        "voice": str(voice_path),
        "caption_file": str(work / "caption.txt"),
        "work_dir": str(work),
        "stock": stock_note,
        "message": vid_msg if ok_vid else vid_msg or tts_msg,
        "ai": bool(_openai_key()),
        "hd": hd,
        "commercial": commercial,
        "elapsed_sec": elapsed,
        "caption_preview": plan.caption[:200],
    }
