"""VidPuls Promo-TTS – ElevenLabs (Standard), Fallback nur bei expliziter Wahl."""

from __future__ import annotations

import asyncio
import json
import os
import re
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"

# VidPuls-Standard: immer ElevenLabs (kein stiller Edge-Fallback)
TTS_DEFAULT = "elevenlabs"

# Native deutsche Neural-Stimmen – kein Fremdakzent (Microsoft Edge)
EDGE_FEMALE = "de-DE-KatjaNeural"
EDGE_MALE = "de-DE-ConradNeural"

# OpenAI-Fallback
OPENAI_TTS_MODELS = ("gpt-4o-mini-tts", "tts-1-hd")
OPENAI_FEMALE = "coral"
OPENAI_MALE = "onyx"
OPENAI_NARRATOR_INSTRUCTIONS = (
    "Sprich natives Hochdeutsch ohne Akzent – warm, fließend und menschlich, "
    "wie eine deutsche Person in einem Social-Media-Video. Kein Dialekt, kein "
    "Fremdakzent, keine Roboter-Betonung."
)
OPENAI_DIALOGUE_FEMALE = (
    "Natives Hochdeutsch, weibliche Stimme ohne Akzent – freundlich und locker "
    "wie in einem echten deutschen Gespräch."
)
OPENAI_DIALOGUE_MALE = (
    "Natives Hochdeutsch, männliche Stimme ohne Akzent – entspannt und neugierig "
    "wie in einem echten deutschen Gespräch."
)

_SENT_SPLIT = re.compile(r"(?<=[.!?…])\s+")
_OPENAI_MAX_CHARS = 3800
_EDGE_WHOLE_MAX = 2800

DialogueTurn = tuple[str, str]


@dataclass
class DialogueTrack:
    speaker: str
    path: Path
    duration: float
    pause_after: float = 0.0


def _load_tts_config() -> dict:
    for name in ("vidpuls-config.json", "jampulse-config.json", "clipwelle-config.json"):
        p = ROOT / "data" / name
        if not p.is_file():
            continue
        try:
            return json.loads(p.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def edge_voice_id(style: str) -> str:
    cfg = _load_tts_config()
    if style == "male":
        return str(cfg.get("edge_voice_male") or EDGE_MALE)
    return str(cfg.get("edge_voice_female") or EDGE_FEMALE)


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 data.get("OPENAI_API_KEY") or ""
            ).strip()
        except (json.JSONDecodeError, OSError):
            pass
    return ""


def tts_provider() -> str:
    """
    elevenlabs – Standard für VidPuls (dauerhaft)
    auto       – wie elevenlabs (kein Edge-Fallback mehr)
    edge       – nur Microsoft Edge TTS (Dev/Fallback)
    openai     – OpenAI Speech API
    kokoro     – Kokoro offline (nicht für DE)
    """
    env = os.environ.get("VIDPULS_TTS", "").strip().lower()
    if env in ("elevenlabs", "kokoro", "openai", "edge", "auto"):
        if env == "auto":
            return TTS_DEFAULT
        return env
    cfg = _load_tts_config()
    val = str(cfg.get("tts") or "").lower()
    if val == "auto":
        return TTS_DEFAULT
    if val in ("elevenlabs", "kokoro", "openai", "edge"):
        return val
    return TTS_DEFAULT


def elevenlabs_tts_configured() -> bool:
    from jam.elevenlabs_tts import configured

    return configured()


def elevenlabs_tts_missing_message() -> str:
    from jam.elevenlabs_tts import missing_message, permission_denied

    if permission_denied():
        return (
            "ElevenLabs-Key ohne Text-to-Speech-Berechtigung. "
            "Neuen Key mit Permission „Text to Speech“ erstellen: "
            "https://elevenlabs.io/app/settings/api-keys"
        )
    return missing_message()


def openai_tts_configured() -> bool:
    return bool(_openai_key())


def openai_tts_missing_message() -> str:
    return (
        "OpenAI TTS benötigt openai_api_key in data/jami-secrets.local.json "
        "oder OPENAI_API_KEY."
    )


def kokoro_tts_configured() -> bool:
    from jam.kokoro_tts import configured

    return configured()


def kokoro_tts_missing_message() -> str:
    from jam.kokoro_tts import missing_message

    return missing_message()


def edge_tts_configured() -> bool:
    try:
        import edge_tts  # noqa: F401

        return True
    except ImportError:
        return False


def edge_tts_missing_message() -> str:
    return "Edge TTS fehlt: pip install edge-tts (native deutsche Stimmen)"


def _try_elevenlabs_narrator(spoken: str, out: Path, *, style: str) -> tuple[bool, str, str]:
    from jam.elevenlabs_tts import synthesize_long

    ok, model = synthesize_long(
        spoken, out, style=style, dialogue=False, concat_fn=_concat_with_pauses
    )
    if ok:
        _master_audio(out)
        return True, str(out), f"elevenlabs-{model}"
    return False, model, ""


def _try_elevenlabs_dialogue(
    turns: list[DialogueTurn], out: Path, *, pauses: list[float],
) -> tuple[bool, str]:
    from jam.elevenlabs_tts import synthesize_dialogue_turns

    return synthesize_dialogue_turns(
        turns, out, concat_fn=_concat_dialogue, pauses=pauses
    )


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

        return _find_ffmpeg()
    except ImportError:
        return None


def _humanize_for_speech(text: str) -> str:
    """Geschriebenes Deutsch → gesprochenes Deutsch."""
    t = text.strip()
    t = re.sub(r"\s*–\s*", ", ", t)
    t = re.sub(r"\s*—\s*", ", ", t)
    t = re.sub(r"\s+-\s+", ", ", t)
    t = re.sub(r"\.{2,}", ".", t)
    t = re.sub(r";\s*", ", ", t)
    t = re.sub(r"\bJami-Dating\b", "Jami Dating", t)
    t = re.sub(r"\bjami-dating\.de\b", "jami dating punkt de", t, flags=re.I)
    t = re.sub(r"\bDu\b", "du", t)
    t = re.sub(r"\bDu zahlst\b", "du zahlst", t)
    t = re.sub(r"\bNein!\b", "Nö,", t)
    t = re.sub(r"\bNein\b", "Nee", t)
    t = re.sub(r"\bJa\b", "Jo", t)
    t = re.sub(r"\bokay\b", "okay", t, flags=re.I)
    t = re.sub(r"\s+", " ", t)
    t = re.sub(r",\s*,", ",", t)
    return t.strip()


def _humanize_dialogue_line(text: str, *, speaker: str, short: bool) -> str:
    """Dialogzeile – lockerer, mit natürlichen Pausen."""
    t = _humanize_for_speech(text)
    if short and not t.endswith(("?", "!", ".")):
        t += "."
    # Leichte Betonung bei kurzen Reaktionen
    if short and len(t) < 28:
        t = re.sub(r"^(Jo|Nee|Nö|Hmm|Ah|Oh)\b", r"\1,", t, flags=re.I)
    return t


def _dialogue_pause_after(text: str, *, short: bool) -> float:
    """Kurze Übergänge – fließendes Gespräch ohne lange Pausen."""
    if short:
        return 0.05
    if text.rstrip().endswith("?"):
        return 0.08
    if text.rstrip().endswith("!"):
        return 0.07
    return 0.06


def pick_voice_profile(prompt: str) -> dict[str, str]:
    pl = (prompt or "").lower()
    female_keys = (
        "dating", "jami", "liebe", "single", "beauty", "kosmetik",
        "mode", "wellness", "yoga", "lifestyle", "influencer",
    )
    male_keys = (
        "handwerk", "auto", "tech", "software", "business", "finanz",
        "immobil", "bau", "werkstatt", "anwalt", "versicher",
    )
    if any(k in pl for k in female_keys):
        style = "female"
    elif any(k in pl for k in male_keys):
        style = "male"
    else:
        style = "female"
    return {
        "style": style,
        "edge": edge_voice_id(style),
        "openai": OPENAI_FEMALE if style == "female" else OPENAI_MALE,
    }


def _edge_dialogue_prosody(style: str) -> tuple[str, str, str]:
    """Native DE-Stimmen – natürliches Gesprächstempo."""
    voice = edge_voice_id(style)
    if style == "female":
        return voice, "+3%", "+0Hz"
    return voice, "+1%", "-2Hz"


def _sentences(text: str) -> list[str]:
    parts = [p.strip() for p in _SENT_SPLIT.split(text) if p.strip()]
    return parts or [text]


def _paragraph_chunks(text: str, max_chars: int = _EDGE_WHOLE_MAX) -> list[str]:
    """Große Blöcke – fließend, nicht Satz für Satz."""
    if len(text) <= max_chars:
        return [text]
    sents = _sentences(text)
    blocks: list[str] = []
    cur: list[str] = []
    cur_len = 0
    for s in sents:
        add = len(s) + (1 if cur else 0)
        if cur and cur_len + add > max_chars:
            blocks.append(" ".join(cur))
            cur = [s]
            cur_len = len(s)
        else:
            cur.append(s)
            cur_len += add
    if cur:
        blocks.append(" ".join(cur))
    return blocks or [text]


def _chunks(text: str, max_chars: int = _OPENAI_MAX_CHARS) -> list[str]:
    return _paragraph_chunks(text, max_chars)


def _natural_rate(*, conversational: bool = False) -> str:
    """Normales Sprechtempo – nicht künstlich beschleunigt."""
    return "+1%" if conversational else "+0%"


def _concat_crossfade(parts: list[Path], out: Path, *, fade_sec: float = 0.07) -> bool:
    """Weiche Übergänge – kein hartes Aufeinanderkleben."""
    if not parts:
        return False
    if len(parts) == 1:
        out.write_bytes(parts[0].read_bytes())
        return out.stat().st_size > 200

    ff = _ffmpeg()
    if not ff:
        return _concat_with_pauses(parts, out, pause_sec=0.10)

    work = out.parent
    current = parts[0]
    for i, nxt in enumerate(parts[1:], 1):
        tmp = work / f"_xf_{i:03d}.mp3"
        try:
            subprocess.run(
                [
                    ff, "-y", "-i", str(current), "-i", str(nxt),
                    "-filter_complex", f"[0:a][1:a]acrossfade=d={fade_sec}:c1=tri:c2=tri",
                    "-c:a", "libmp3lame", "-q:a", "2", str(tmp),
                ],
                capture_output=True, timeout=120, check=True,
            )
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
            return _concat_with_pauses(parts, out, pause_sec=0.10)
        if not tmp.is_file() or tmp.stat().st_size < 200:
            return _concat_with_pauses(parts, out, pause_sec=0.10)
        current = tmp

    out.write_bytes(current.read_bytes())
    return out.stat().st_size > 500


def _concat_with_pauses(parts: list[Path], out: Path, *, pause_sec: float = 0.10) -> bool:
    if not parts:
        return False
    if len(parts) == 1:
        out.write_bytes(parts[0].read_bytes())
        return out.stat().st_size > 200

    ff = _ffmpeg()
    if not ff:
        out.write_bytes(b"".join(p.read_bytes() for p in parts))
        return out.stat().st_size > 200

    work = out.parent
    silence = work / "_tts_pause.mp3"
    try:
        subprocess.run(
            [
                ff, "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
                "-t", f"{pause_sec:.3f}", "-c:a", "libmp3lame", "-q:a", "4", str(silence),
            ],
            capture_output=True, timeout=30, check=True,
        )
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        silence = None

    list_file = work / "_tts_concat.txt"
    lines: list[str] = []
    for i, p in enumerate(parts):
        lines.append(f"file '{p.resolve().as_posix()}'")
        if silence and silence.is_file() and i < len(parts) - 1:
            lines.append(f"file '{silence.resolve().as_posix()}'")
    list_file.write_text("\n".join(lines), encoding="utf-8")

    try:
        subprocess.run(
            [ff, "-y", "-f", "concat", "-safe", "0", "-i", str(list_file), "-c", "copy", str(out)],
            capture_output=True, timeout=120, check=True,
        )
        return out.is_file() and out.stat().st_size > 500
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        return False


def _silence_clip(work: Path, sec: float, tag: str) -> Path | None:
    ff = _ffmpeg()
    if not ff or sec <= 0.01:
        return None
    path = work / f"_dlg_pause_{tag}_{int(sec * 1000)}.mp3"
    if path.is_file() and path.stat().st_size > 50:
        return path
    try:
        subprocess.run(
            [
                ff, "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono",
                "-t", f"{sec:.3f}", "-c:a", "libmp3lame", "-q:a", "4", str(path),
            ],
            capture_output=True, timeout=30, check=True,
        )
        return path if path.is_file() else None
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        return None


def _concat_dialogue(parts: list[Path], out: Path, *, pauses: list[float]) -> bool:
    """Zwei Stimmen mit echten Gesprächspausen – nicht überblenden."""
    if not parts:
        return False
    if len(parts) == 1:
        out.write_bytes(parts[0].read_bytes())
        return out.stat().st_size > 200

    ff = _ffmpeg()
    if not ff:
        return _concat_with_pauses(parts, out, pause_sec=0.18)

    work = out.parent
    list_file = work / "_dialogue_concat.txt"
    lines: list[str] = []
    for i, p in enumerate(parts):
        lines.append(f"file '{p.resolve().as_posix()}'")
        if i < len(parts) - 1:
            pause = pauses[i] if i < len(pauses) else 0.22
            sil = _silence_clip(work, pause, f"{i:03d}")
            if sil:
                lines.append(f"file '{sil.resolve().as_posix()}'")
    list_file.write_text("\n".join(lines), encoding="utf-8")
    try:
        subprocess.run(
            [ff, "-y", "-f", "concat", "-safe", "0", "-i", str(list_file), "-c", "copy", str(out)],
            capture_output=True, timeout=180, check=True,
        )
        return out.is_file() and out.stat().st_size > 500
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        return _concat_with_pauses(parts, out, pause_sec=0.18)


def _master_audio(path: Path) -> None:
    """Minimal – nur leichte Wärme, keine starke Kompression."""
    ff = _ffmpeg()
    if not ff or not path.is_file():
        return
    tmp = path.with_suffix(".master.mp3")
    af = "highpass=f=70,lowpass=f=14000"
    try:
        subprocess.run(
            [ff, "-y", "-i", str(path), "-af", af, "-c:a", "libmp3lame", "-q:a", "2", str(tmp)],
            capture_output=True, timeout=180, check=True,
        )
        if tmp.is_file() and tmp.stat().st_size > 500:
            tmp.replace(path)
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        if tmp.is_file():
            tmp.unlink(missing_ok=True)


def _master_dialogue_audio(path: Path) -> None:
    """Leichte Wärme für Gespräche – zwei Stimmen klar trennbar."""
    ff = _ffmpeg()
    if not ff or not path.is_file():
        return
    tmp = path.with_suffix(".dlg.mp3")
    af = "highpass=f=75,lowpass=f=13000,acompressor=threshold=-20dB:ratio=2:attack=8:release=80"
    try:
        subprocess.run(
            [ff, "-y", "-i", str(path), "-af", af, "-c:a", "libmp3lame", "-q:a", "2", str(tmp)],
            capture_output=True, timeout=180, check=True,
        )
        if tmp.is_file() and tmp.stat().st_size > 500:
            tmp.replace(path)
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        if tmp.is_file():
            tmp.unlink(missing_ok=True)


def _prepare_dialogue_turns(turns: list[DialogueTurn]) -> tuple[list[DialogueTurn], list[float]]:
    """Bereinigen + Pausen pro Zeile."""
    from jam.german_tts import prepare_german_text

    cleaned: list[DialogueTurn] = []
    pauses: list[float] = []
    for speaker, text in turns:
        raw = prepare_german_text(text, max_chars=None)
        short = len(raw.strip()) < 32
        line = _humanize_dialogue_line(raw, speaker=_normalize_speaker(speaker), short=short)
        if line:
            cleaned.append((_normalize_speaker(speaker), line))
            pauses.append(_dialogue_pause_after(line, short=short))
    return cleaned, pauses


def _kokoro_turn_speed(text: str) -> float | None:
    """Kurze Reaktionen etwas flotter – wie im echten Gespräch."""
    if len(text) < 28:
        return 1.03
    if len(text) > 85:
        return 0.93
    return None


async def _edge_save(text: str, voice: str, rate: str, pitch: str, out: Path) -> None:
    import edge_tts

    comm = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch, volume="+2%")
    await comm.save(str(out))


async def _synthesize_edge_fluent(
    text: str, out: Path, *, voice: str, rate: str, pitch: str,
) -> bool:
    """Ganzer Text in wenigen Blöcken – fließend, ohne Satz-Stakkato."""
    blocks = _paragraph_chunks(text)
    work = out.parent / "_tts_parts"
    work.mkdir(parents=True, exist_ok=True)

    if len(blocks) == 1:
        await _edge_save(blocks[0], voice, rate, pitch, out)
        return out.is_file() and out.stat().st_size > 500

    parts: list[Path] = []
    for i, block in enumerate(blocks):
        part = work / f"blk_{i:03d}.mp3"
        await _edge_save(block, voice, rate, pitch, part)
        if part.is_file() and part.stat().st_size > 200:
            parts.append(part)

    if not parts:
        return False
    ok = _concat_with_pauses(parts, out, pause_sec=0.14)
    return ok and out.is_file() and out.stat().st_size > 500


def _openai_speech(
    text: str,
    out: Path,
    *,
    voice: str,
    speed: float = 1.0,
    instructions: str = "",
) -> tuple[bool, str]:
    """OpenAI /v1/audio/speech – menschliche Stimmen."""
    key = _openai_key()
    if not key:
        return False, openai_tts_missing_message()

    last_err = ""
    for model in OPENAI_TTS_MODELS:
        body: dict = {
            "model": model,
            "input": text,
            "voice": voice,
            "response_format": "mp3",
            "speed": max(0.92, min(1.05, speed)),
        }
        if instructions and model.startswith("gpt-4o"):
            body["instructions"] = instructions
        payload = json.dumps(body).encode("utf-8")
        req = urllib.request.Request(
            "https://api.openai.com/v1/audio/speech",
            data=payload,
            headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=180) as resp:
                data = resp.read()
            if len(data) < 500:
                last_err = f"{model}: leere Antwort"
                continue
            out.write_bytes(data)
            return True, model
        except urllib.error.HTTPError as e:
            try:
                err_body = json.loads(e.read().decode())
                last_err = str(err_body.get("error", {}).get("message", e))[:160]
            except (json.JSONDecodeError, OSError, UnicodeDecodeError):
                last_err = str(e)[:160]
        except (urllib.error.URLError, OSError, TimeoutError) as e:
            last_err = str(e)[:160]
    return False, last_err or "OpenAI TTS fehlgeschlagen"


def _synthesize_openai(
    text: str,
    out: Path,
    *,
    voice: str,
    speed: float = 1.0,
    instructions: str = OPENAI_NARRATOR_INSTRUCTIONS,
) -> tuple[bool, str]:
    blocks = _chunks(text)
    work = out.parent / "_tts_openai"
    work.mkdir(parents=True, exist_ok=True)

    model_used = ""
    parts: list[Path] = []
    for i, block in enumerate(blocks):
        part = work / f"blk_{i:03d}.mp3"
        ok, info = _openai_speech(
            block, part, voice=voice, speed=speed, instructions=instructions
        )
        if not ok:
            return False, info
        model_used = info
        parts.append(part)

    if len(parts) == 1:
        out.write_bytes(parts[0].read_bytes())
        return out.stat().st_size > 500, model_used
    ok = _concat_with_pauses(parts, out, pause_sec=0.10)
    return ok, model_used


def _try_kokoro_narrator(spoken: str, out: Path, *, style: str) -> tuple[bool, str, str]:
    from jam.kokoro_tts import synthesize_long

    ok, model = synthesize_long(
        spoken, out, style=style, concat_fn=_concat_with_pauses
    )
    if ok:
        _master_audio(out)
        return True, str(out), model
    return False, model, ""


def _try_kokoro_dialogue(
    turns: list[DialogueTurn], out: Path, *, pauses: list[float],
) -> tuple[bool, str]:
    from jam.kokoro_tts import synthesize_text

    work = out.parent / "_dialogue_kokoro"
    work.mkdir(parents=True, exist_ok=True)
    parts: list[Path] = []
    model_used = ""
    for i, (speaker, text) in enumerate(turns):
        if not text.strip():
            continue
        sp = _normalize_speaker(speaker)
        part = work / f"turn_{i:03d}.mp3"
        spd = _kokoro_turn_speed(text)
        ok, info = synthesize_text(text, part, style=sp, speed=spd)
        if not ok:
            return False, info
        model_used = info
        parts.append(part)
    if not parts:
        return False, "Leerer Dialog"
    if len(parts) == 1:
        out.write_bytes(parts[0].read_bytes())
        return True, model_used
    dlg_pauses = pauses[: max(len(parts) - 1, 0)]
    return _concat_dialogue(parts, out, pauses=dlg_pauses), model_used


def _try_openai_narrator(spoken: str, out: Path, *, voice: str) -> tuple[bool, str, str]:
    ok, model = _synthesize_openai(
        spoken, out, voice=voice, speed=1.0, instructions=OPENAI_NARRATOR_INSTRUCTIONS
    )
    if ok:
        _master_audio(out)
        return True, str(out), f"openai-{model}"
    return False, model, ""


def _try_edge_narrator(
    spoken: str, out: Path, *, voice: str, rate: str, pitch: str,
) -> tuple[bool, str, str]:
    try:
        ok = asyncio.run(
            _synthesize_edge_fluent(spoken, out, voice=voice, rate=rate, pitch=pitch)
        )
        if ok:
            _master_audio(out)
            short = voice.replace("de-DE-", "").replace("Neural", "")
            return True, str(out), f"edge-de-{short}"
    except Exception as e:
        return False, str(e)[:120], ""
    return False, "Edge TTS fehlgeschlagen", ""


def synthesize_promo_voice(
    text: str,
    out: Path,
    *,
    target_sec: int = 60,
    commercial: bool = False,
    prompt: str = "",
) -> tuple[bool, str, str]:
    """Sprachausgabe – natives Hochdeutsch (Katja/Conrad oder ElevenLabs)."""
    from jam.german_tts import prepare_german_text

    spoken = _humanize_for_speech(prepare_german_text(text, max_chars=None))
    if not spoken:
        return False, "Leerer Text", ""

    out.parent.mkdir(parents=True, exist_ok=True)
    profile = pick_voice_profile(prompt or spoken)
    provider = tts_provider()

    if provider == "elevenlabs":
        if elevenlabs_tts_configured():
            ok, msg, engine = _try_elevenlabs_narrator(spoken, out, style=profile["style"])
            if ok:
                return True, msg, engine
            return False, msg, ""
        return False, elevenlabs_tts_missing_message(), ""

    if provider in ("edge",):
        if edge_tts_configured():
            rate = _natural_rate(conversational=False)
            ok, msg, engine = _try_edge_narrator(
                spoken, out, voice=profile["edge"], rate=rate, pitch="+0Hz"
            )
            if ok:
                return True, msg, engine
            if provider == "edge":
                return False, msg, ""
        elif provider == "edge":
            return False, edge_tts_missing_message(), ""

    if provider == "openai":
        if openai_tts_configured():
            ok, msg, engine = _try_openai_narrator(spoken, out, voice=profile["openai"])
            if ok:
                return True, msg, engine
            return False, msg, ""

    if provider == "kokoro":
        if kokoro_tts_configured():
            ok, msg, engine = _try_kokoro_narrator(spoken, out, style=profile["style"])
            if ok:
                return True, msg, engine
            return False, msg, ""
        return False, kokoro_tts_missing_message(), ""

    return False, "TTS fehlgeschlagen", ""


# --- Dialog: zwei Personen (native DE-Stimmen) ---

_SPEAKER_OPENAI = {"female": OPENAI_FEMALE, "male": OPENAI_MALE}


def _normalize_speaker(name: str) -> str:
    n = (name or "").strip().lower()
    if n in ("sarah", "laura", "lisa", "tom", "female", "male"):
        return n
    if n in ("f", "frau", "woman", "sie", "anna"):
        return "female"
    if n in ("m", "mann", "man", "er"):
        return "male"
    return "female"


def _edge_dialogue_prosody_for(speaker: str) -> tuple[str, str, str]:
    sp = _normalize_speaker(speaker)
    if sp == "sarah":
        return edge_voice_id("female"), "+4%", "+2Hz"
    if sp == "laura":
        return "de-DE-AmalaNeural", "+2%", "+0Hz"
    return _edge_dialogue_prosody("male" if sp == "male" else "female")


async def _synthesize_dialogue_edge(
    turns: list[DialogueTurn], out: Path, *, pauses: list[float],
) -> bool:
    work = out.parent / "_dialogue_parts"
    work.mkdir(parents=True, exist_ok=True)
    parts: list[Path] = []

    for i, (speaker, text) in enumerate(turns):
        if not text.strip():
            continue
        voice, rate, pitch = _edge_dialogue_prosody_for(speaker)
        part = work / f"turn_{i:03d}.mp3"
        await _edge_save(text, voice, rate, pitch, part)
        if part.is_file() and part.stat().st_size > 200:
            parts.append(part)

    if not parts:
        return False
    dlg_pauses = pauses[: max(len(parts) - 1, 0)]
    ok = _concat_dialogue(parts, out, pauses=dlg_pauses)
    return ok and out.is_file() and out.stat().st_size > 500


def _synthesize_dialogue_openai(
    turns: list[DialogueTurn], out: Path, *, pauses: list[float],
) -> tuple[bool, str]:
    work = out.parent / "_dialogue_openai"
    work.mkdir(parents=True, exist_ok=True)
    parts: list[Path] = []
    model_used = ""
    for i, (speaker, text) in enumerate(turns):
        if not text.strip():
            continue
        sp = _normalize_speaker(speaker)
        instr = OPENAI_DIALOGUE_FEMALE if sp == "female" else OPENAI_DIALOGUE_MALE
        part = work / f"turn_{i:03d}.mp3"
        ok, info = _openai_speech(
            text, part, voice=_SPEAKER_OPENAI[sp], speed=1.0, instructions=instr
        )
        if not ok:
            return False, info
        model_used = info
        parts.append(part)
    if not parts:
        return False, "Leerer Dialog"
    if len(parts) == 1:
        out.write_bytes(parts[0].read_bytes())
        return out.stat().st_size > 500, model_used
    dlg_pauses = pauses[: max(len(parts) - 1, 0)]
    return _concat_dialogue(parts, out, pauses=dlg_pauses), model_used


def synthesize_dialogue_voice(
    turns: list[DialogueTurn],
    out: Path,
    *,
    commercial: bool = True,
) -> tuple[bool, str, str]:
    """Dialog – natürliches Gespräch mit echten Sprecherpausen."""
    cleaned, pauses = _prepare_dialogue_turns(turns)
    if not cleaned:
        return False, "Leerer Dialog", ""

    out.parent.mkdir(parents=True, exist_ok=True)
    provider = tts_provider()

    if provider == "elevenlabs":
        if elevenlabs_tts_configured():
            ok, info = _try_elevenlabs_dialogue(cleaned, out, pauses=pauses)
            if ok:
                _master_dialogue_audio(out)
                return True, str(out), f"elevenlabs-{info}-dialogue"
            return False, info, ""
        return False, elevenlabs_tts_missing_message(), ""

    if provider == "edge":
        if edge_tts_configured():
            try:
                ok = asyncio.run(_synthesize_dialogue_edge(cleaned, out, pauses=pauses))
                if ok:
                    _master_dialogue_audio(out)
                    v = edge_voice_id("female").replace("de-DE-", "").replace("Neural", "")
                    return True, str(out), f"edge-de-{v}-dialogue"
                return False, "Edge Dialog-TTS fehlgeschlagen", ""
            except Exception as e:
                return False, str(e)[:120], ""
        return False, edge_tts_missing_message(), ""

    if provider == "openai":
        if openai_tts_configured():
            ok, info = _synthesize_dialogue_openai(cleaned, out, pauses=pauses)
            if ok:
                _master_dialogue_audio(out)
                return True, str(out), f"openai-{info}-dialogue"
            return False, info, ""

    if provider == "kokoro":
        if kokoro_tts_configured():
            ok, info = _try_kokoro_dialogue(cleaned, out, pauses=pauses)
            if ok:
                _master_dialogue_audio(out)
                return True, str(out), f"{info}-dialogue"
            return False, info, ""
        return False, kokoro_tts_missing_message(), ""

    return False, "Dialog-TTS fehlgeschlagen", ""


def _dialogue_parts_dir(voice_out: Path) -> Path | None:
    parent = voice_out.parent
    for name in (
        "_dialogue_elevenlabs",
        "_dialogue_parts",
        "_dialogue_openai",
        "_dialogue_kokoro",
    ):
        d = parent / name
        if d.is_dir() and any(d.glob("turn_*.mp3")):
            return d
    return None


def collect_dialogue_tracks(
    voice_out: Path,
    turns: list[DialogueTurn],
) -> list[DialogueTrack]:
    """Einzelne Dialog-Spuren inkl. Pausen – für Zwei-Personen-Video."""
    from jam.ia1minute import _probe_media_duration

    cleaned, pauses = _prepare_dialogue_turns(turns)
    parts_dir = _dialogue_parts_dir(voice_out)
    if not parts_dir:
        return []

    tracks: list[DialogueTrack] = []
    for i, (speaker, _text) in enumerate(cleaned):
        part = parts_dir / f"turn_{i:03d}.mp3"
        if not part.is_file():
            continue
        dur = _probe_media_duration(part)
        pause_after = pauses[i] if i < len(cleaned) - 1 else 0.0
        tracks.append(
            DialogueTrack(
                speaker=speaker,
                path=part,
                duration=dur,
                pause_after=pause_after,
            )
        )
    return tracks
