"""Dialog Studio – einfache Web-Oberfläche für Dating-Dialog (Sarah & Laura)."""

from __future__ import annotations

import json
import mimetypes
import shutil
import threading
import uuid
import webbrowser
from datetime import datetime
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse

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

try:
    from jam.jampulse_desktop import studio_root

    STUDIO_ROOT = studio_root() / "DialogStudio"
except ImportError:
    STUDIO_ROOT = Path.home() / "Documents" / "DialogStudio"

JOBS_DIR = STUDIO_ROOT / "jobs"
WORK_DIR = STUDIO_ROOT / "jobs_work"
OUTPUT_DIR = STUDIO_ROOT / "output"
PORT = 8793
SITE_URL = f"http://127.0.0.1:{PORT}/"

_lock = threading.Lock()
_active_job: str | None = None


def ensure_dirs() -> None:
    for d in (STUDIO_ROOT, JOBS_DIR, WORK_DIR, OUTPUT_DIR):
        d.mkdir(parents=True, exist_ok=True)
    write_studio_legal_pages()


def legal_site_url() -> str:
    from jam.jampulse_brand import PRODUCT_SITE_URL

    return PRODUCT_SITE_URL.rstrip("/")


def write_studio_legal_pages() -> list[Path]:
    """Impressum, Datenschutz, AGB, Widerruf – Studio + Desktop-Shop."""
    from jam.clipwelle_legal import write_legal_pages

    site = legal_site_url()
    paths = write_legal_pages(STUDIO_ROOT / "legal", site_url=site)
    try:
        from jam.jampulse_desktop import studio_root

        paths.extend(write_legal_pages(studio_root() / "Shop", site_url=site))
    except ImportError:
        pass
    return paths


def _legal_page_bytes(path: str) -> bytes | None:
    from jam.clipwelle_legal import (
        agb_html,
        datenschutz_html,
        impressum_html,
        legal_page,
        widerruf_html,
    )

    site = legal_site_url()
    routes = {
        "/impressum": ("Impressum", impressum_html(site_url=site)),
        "/datenschutz": ("Datenschutzerklärung", datenschutz_html(site_url=site)),
        "/agb": ("AGB", agb_html(site_url=site)),
        "/widerruf": ("Widerruf", widerruf_html(site_url=site)),
    }
    key = path.lower().rstrip("/")
    if key.endswith(".html"):
        key = "/" + key.rsplit("/", 1)[-1].replace(".html", "")
    if key not in routes:
        return None
    title, body = routes[key]
    return legal_page(title, body, site_url=site).encode("utf-8")


def _job_path(job_id: str) -> Path:
    return JOBS_DIR / f"{job_id}.json"


def save_job(job: dict) -> None:
    ensure_dirs()
    _job_path(job["id"]).write_text(
        json.dumps(job, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )


def load_job(job_id: str) -> dict | None:
    p = _job_path(job_id)
    if not p.is_file():
        return None
    try:
        return json.loads(p.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None


def _set_progress(job_id: str, status: str, message: str, **extra) -> None:
    job = load_job(job_id)
    if not job:
        return
    job["status"] = status
    job["message"] = message
    job["updated_at"] = datetime.now().isoformat(timespec="seconds")
    job.update(extra)
    save_job(job)


def _resolution_dims(resolution: str) -> tuple[int, int]:
    from jam.ia1minute import RESOLUTIONS

    key = "4k" if str(resolution).lower() in ("4k", "2160p", "uhd") else "1080p"
    w, h = RESOLUTIONS[key]["9:16"]
    return int(w), int(h)


def _example_prompt() -> str:
    from jam.ia1minute import weekend_date_turns

    lines = []
    for sp, txt in weekend_date_turns():
        name = "Sarah" if sp == "sarah" else "Laura"
        lines.append(f"{name}: {txt}")
    return "\n\n".join(lines)


def _run_dialog_job(job_id: str) -> None:
    global _active_job
    from jam.dialog_prompt import parse_dialogue_prompt
    from jam.elevenlabs_tts import set_runtime_voice_overrides
    from jam.promo_tts import collect_dialogue_tracks, synthesize_dialogue_voice

    job = load_job(job_id)
    if not job:
        return

    work = WORK_DIR / job_id
    work.mkdir(parents=True, exist_ok=True)
    voice_path = work / "voice_mixed.mp3"
    video_path = OUTPUT_DIR / f"{job_id}.mp4"
    audio_copy = OUTPUT_DIR / f"{job_id}.mp3"

    try:
        from jam.jampulse_license import can_generate, mark_generation_used, needs_watermark

        ok, lic_msg, lic = can_generate(consume=True)
        if not ok:
            _set_progress(job_id, "error", lic_msg)
            return
        job = load_job(job_id) or job
        job["license_kind"] = str(lic.get("plan") or "free")
        job["watermark"] = needs_watermark()
        save_job(job)

        turns = parse_dialogue_prompt(str(job.get("prompt") or ""))
        job["turn_count"] = len(turns)
        save_job(job)

        _set_progress(job_id, "generating", "Stimmen werden synthetisiert … (ElevenLabs)")

        overrides = {
            "sarah": str(job.get("voice_sarah") or ""),
            "laura": str(job.get("voice_laura") or ""),
            "voice1": str(job.get("voice_sarah") or ""),
            "voice2": str(job.get("voice_laura") or ""),
        }
        set_runtime_voice_overrides({k: v for k, v in overrides.items() if v})
        try:
            ok, msg, engine = synthesize_dialogue_voice(turns, voice_path, commercial=True)
        finally:
            set_runtime_voice_overrides(None)

        if not ok or not voice_path.is_file():
            _set_progress(job_id, "error", msg or "Audio-Generierung fehlgeschlagen")
            return

        shutil.copy2(voice_path, audio_copy)
        job = load_job(job_id) or job
        job["local_audio"] = str(audio_copy)
        job["audio_engine"] = engine
        save_job(job)

        want_video = str(job.get("output") or "both").lower() in ("video", "both", "video_audio")
        if not want_video:
            _set_progress(
                job_id,
                "done",
                f"Audio fertig ({engine})",
                local_audio=str(audio_copy),
            )
            return

        _set_progress(job_id, "generating", "3D-Szene wird auf der GPU gerendert … (Cinema3D + AMF)")

        tracks = collect_dialogue_tracks(voice_path, turns)
        if not tracks:
            _set_progress(job_id, "error", "Keine Dialog-Spuren für Video gefunden")
            return

        width, height = _resolution_dims(str(job.get("resolution") or "1080p"))
        from jam.artist_pipeline import assemble_dialogue_video

        ok_vid, vid_msg = assemble_dialogue_video(
            tracks,
            voice_path,
            video_path,
            width=width,
            height=height,
            visual_style=str(job.get("visual_style") or "3d"),
            work_dir=work,
            turns=turns,
            aspect_ratio="9:16",
        )

        if not ok_vid or not video_path.is_file():
            _set_progress(job_id, "error", vid_msg or "Video-Export fehlgeschlagen")
            return

        from jam.ia1minute import _probe_media_duration

        dur = _probe_media_duration(video_path)
        mark_generation_used(str(job.get("license_kind") or ""))
        _set_progress(
            job_id,
            "done",
            vid_msg,
            local_video=str(video_path),
            local_audio=str(audio_copy),
            duration_sec=round(dur, 1),
            resolution=f"{width}×{height}",
        )
    except Exception as e:
        _set_progress(job_id, "error", str(e)[:500])
    finally:
        with _lock:
            if _active_job == job_id:
                _active_job = None
        _dispatch_next_job()


def resume_job_queue() -> None:
    """Nach Neustart: offene Jobs wieder in die Warteschlange."""
    from jam.dialog_gpu_server import append_server_log, enqueue

    pending: list[tuple[str, str]] = []
    for p in sorted(JOBS_DIR.glob("*.json"), key=lambda x: x.stat().st_mtime):
        job = load_job(p.stem)
        if not job:
            continue
        st = str(job.get("status") or "")
        if st in ("queued", "generating"):
            if st == "generating":
                job["status"] = "queued"
                job["message"] = "Server-Neustart – wieder in Warteschlange …"
                save_job(job)
            pending.append((str(job.get("created_at") or ""), job["id"]))

    pending.sort(key=lambda x: x[0])
    global _active_job
    started = False
    with _lock:
        for _, jid in pending:
            if not _active_job and not started:
                _active_job = jid
                started = True
            else:
                enqueue(jid)
    if started and _active_job:
        append_server_log(STUDIO_ROOT, f"Queue resume: start { _active_job}")
        threading.Thread(target=_run_dialog_job, args=(_active_job,), daemon=True).start()
    elif pending:
        append_server_log(STUDIO_ROOT, f"Queue resume: {len(pending)} wartend")


def _dispatch_next_job() -> None:
    global _active_job
    from jam.dialog_gpu_server import pop_next, append_server_log

    with _lock:
        _active_job = None
    nxt = pop_next()
    if not nxt:
        return
    with _lock:
        _active_job = nxt
    append_server_log(STUDIO_ROOT, f"Queue: start {nxt}")
    threading.Thread(target=_run_dialog_job, args=(nxt,), daemon=True).start()


def start_job(body: dict) -> dict:
    global _active_job

    from jam.dialog_gpu_server import append_server_log, enqueue
    from jam.jampulse_license import can_generate

    ok, lic_msg, lic = can_generate(consume=False)
    if not ok:
        return {"ok": False, "error": lic_msg, "needs_payment": True}

    prompt = str(body.get("prompt") or "").strip()
    voice_sarah = str(body.get("voice_sarah") or body.get("voice1") or "").strip()
    voice_laura = str(body.get("voice_laura") or body.get("voice2") or "").strip()

    from jam.elevenlabs_tts import VOICE_FEMALE_DEFAULT, VOICE_LAURA_DEFAULT, configured

    if not voice_sarah:
        voice_sarah = VOICE_FEMALE_DEFAULT
    if not voice_laura:
        voice_laura = VOICE_LAURA_DEFAULT

    job_id = uuid.uuid4().hex[:12]
    job = {
        "id": job_id,
        "prompt": prompt,
        "voice_sarah": voice_sarah,
        "voice_laura": voice_laura,
        "output": str(body.get("output") or "both"),
        "resolution": str(body.get("resolution") or "1080p"),
        "visual_style": "3d",
        "status": "queued",
        "message": "In Warteschlange …",
        "created_at": datetime.now().isoformat(timespec="seconds"),
        "updated_at": datetime.now().isoformat(timespec="seconds"),
        "elevenlabs": configured(),
    }
    save_job(job)

    with _lock:
        if _active_job:
            pos = enqueue(job_id)
            job["message"] = f"In Warteschlange (Platz {pos + 1}) …"
            save_job(job)
            append_server_log(STUDIO_ROOT, f"Job queued {job_id} pos={pos + 1}")
            return {"ok": True, "jobId": job_id, "queued": True, "queue_position": pos + 1}
        _active_job = job_id

    append_server_log(STUDIO_ROOT, f"Job start {job_id}")
    threading.Thread(target=_run_dialog_job, args=(job_id,), daemon=True).start()
    return {"ok": True, "jobId": job_id, "queued": False, "queue_position": 1}


def studio_html() -> str:
    from jam.dialog_marketing import (
        features_card_html,
        jami_dating_sidebar_html,
        studio_marketing_script,
    )
    from jam.dialog_shop import DIALOG_PASSES, pricing_public
    from jam.dialog_studio_ui import (
        hero_html,
        pricing_cards_html,
        site_header_html,
        steps_html,
        studio_page_css,
    )
    from jam.jami_promo_banner import jami_promo_banner_css

    pr = pricing_public()
    week_price = DIALOG_PASSES["dialog_week"]["price_eur"]
    products_html = pricing_cards_html(pr["products"])
    bank = pr.get("bank_transfer")
    bank_html = ""
    if bank:
        bank_html = (
            f"<p class='hint' style='text-align:center;margin-top:1rem'>"
            f"Überweisung: <strong>{bank.get('holder')}</strong> · IBAN {bank.get('iban')} · "
            f"{bank.get('note', '')}</p>"
        )
    page_css = studio_page_css() + jami_promo_banner_css(animated=True, variant="compact")
    return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta name="description" content="VidPuls Dialog Studio – 3D Dating-Videos mit Lip-Sync für TikTok. Ab {week_price:.2f} €/Woche."/>
<meta name="theme-color" content="#05040c"/>
<title>Dialog Studio – VidPuls · 3D Dating-Videos</title>
<style>{page_css}</style>
</head>
<body>
<div class="page-bg" aria-hidden="true"></div>
<div class="wrap">
  {site_header_html()}
  {hero_html(lan_url=str(pr.get("lan_url") or ""), free_note=str(pr.get("free_note") or ""))}
  {steps_html()}
  <div class="lic" id="licenseBox">Lizenz wird geladen …</div>
  <div class="layout">
    <div class="layout-main">
      <section class="studio-section" id="studio">
        <div class="section-head">
          <h2>🎬 Dein Video-Studio</h2>
          <p>Dialog eingeben · Stimmen wählen · Generieren · Fertig.</p>
        </div>
        <div class="grid">
          <div class="card">
            <label for="prompt">Dialog-Text (Sarah &amp; Laura)</label>
            <textarea id="prompt" placeholder="Sarah: Du glaubst NIEMALS …&#10;&#10;Laura: Was, echt?! …"></textarea>
            <div class="row">
              <button type="button" class="btn-sm" id="loadExample">✨ Beispiel laden</button>
              <select id="resolution" title="Auflösung">
                <option value="1080p">1080p (schneller)</option>
                <option value="4k">4K (beste Qualität)</option>
              </select>
            </div>
            <div class="row" style="margin-top:1rem">
              <div style="flex:1">
                <label for="voice1">Stimme 1 · Sarah</label>
                <select id="voice1"></select>
              </div>
              <div style="flex:1">
                <label for="voice2">Stimme 2 · Laura</label>
                <select id="voice2"></select>
              </div>
            </div>
            <p class="hint">Format: <code>Sarah:</code> oder <code>Laura:</code> am Zeilenanfang.</p>
            <button type="button" class="btn-gen" id="generate">🚀 Video generieren</button>
            <div class="status" id="status">Bereit – leg los!</div>
          </div>
          <div class="card">
            <label>Vorschau &amp; Download</label>
            <div class="player-box" id="playerBox">
              <div class="placeholder" id="placeholder">Dein Video erscheint hier – cinematic, 9:16, mit Lip-Sync ✨</div>
              <video id="video" controls playsinline style="display:none"></video>
              <audio id="audio" controls style="display:none"></audio>
              <div class="dl" id="downloads" style="display:none"></div>
            </div>
          </div>
        </div>
      </section>
      <section class="pricing-section" id="preise">
        <div class="section-head">
          <h2>💜 Zeitpässe</h2>
          <p>{pr.get('free_note','')} · PayPal · Karte · SEPA · Überweisung</p>
        </div>
        <div class="price-grid">{products_html}</div>
        {bank_html}
        <div class="row" style="margin-top:1rem;justify-content:center">
          <button type="button" class="btn-sm" id="payBank" data-product="dialog_month">🏦 Überweisung (Stripe IBAN)</button>
        </div>
      </section>
    </div>
    <aside class="sidebar">
      {features_card_html()}
      {jami_dating_sidebar_html(utm_source="dialog_studio")}
    </aside>
  </div>
  <footer class="studio-footer">
    <a href="/impressum">Impressum</a> ·
    <a href="/datenschutz">Datenschutzerklärung</a> ·
    <a href="/agb">AGB</a> ·
    <a href="/widerruf">Widerruf</a><br/>
    David Prechtel · KI Web Studio · Mühlfeldsiedlung 24 · 86660 Tapfheim ·
    <a href="mailto:kontakt@ki-web-studio.de">kontakt@ki-web-studio.de</a> · USt-IdNr. DE351530475
  </footer>
</div>
<script>
const $ = (id) => document.getElementById(id);
let pollTimer = null;
let currentJob = null;

function setStatus(text, kind) {{
  const el = $("status");
  el.textContent = text;
  el.className = "status" + (kind ? " " + kind : "");
}}

function fillVoices(voices, defaults) {{
  for (const sel of [$("voice1"), $("voice2")]) {{
    sel.innerHTML = "";
    for (const v of voices) {{
      const o = document.createElement("option");
      o.value = v.voice_id;
      o.textContent = v.name + (v.labels ? " · " + v.labels : "");
      sel.appendChild(o);
    }}
  }}
  if (defaults.voice_sarah) $("voice1").value = defaults.voice_sarah;
  if (defaults.voice_laura) $("voice2").value = defaults.voice_laura;
}}

async function api(path, opts) {{
  const r = await fetch(path, opts);
  return r.json();
}}

function showResult(job) {{
  const vid = $("video");
  const aud = $("audio");
  const ph = $("placeholder");
  const dl = $("downloads");
  vid.style.display = "none";
  aud.style.display = "none";
  dl.innerHTML = "";
  dl.style.display = "none";
  ph.style.display = "none";

  if (job.local_video) {{
    vid.src = "/api/media/" + job.id + "/video?t=" + Date.now();
    vid.style.display = "block";
    const a = document.createElement("a");
    a.href = "/api/download/" + job.id + "/video";
    a.textContent = "Video herunterladen (.mp4)";
    a.download = job.id + ".mp4";
    dl.appendChild(a);
    dl.style.display = "flex";
  }}
  if (job.local_audio) {{
    if (!job.local_video) {{
      aud.src = "/api/media/" + job.id + "/audio?t=" + Date.now();
      aud.style.display = "block";
    }}
    const a2 = document.createElement("a");
    a2.href = "/api/download/" + job.id + "/audio";
    a2.textContent = "Audio herunterladen (.mp3)";
    a2.download = job.id + ".mp3";
    dl.appendChild(a2);
    dl.style.display = "flex";
  }}
  if (!job.local_video && !job.local_audio) {{
    ph.style.display = "block";
    ph.textContent = "Noch kein Ergebnis.";
  }}
}}

function pollJob(id) {{
  if (pollTimer) clearInterval(pollTimer);
  pollTimer = setInterval(async () => {{
    const data = await api("/api/job/" + id);
    if (!data.ok) return;
    const job = data.job;
    currentJob = job;
    if (job.status === "queued" || job.status === "generating") {{
      let msg = job.message || "Generiere …";
      if (job.status === "queued") {{
        try {{
          const st = await api("/api/server-status");
          const q = st.queue || {{}};
          const waiting = q.waiting_ids || [];
          const pos = waiting.indexOf(id);
          if (pos >= 0) msg = "Warteschlange – Platz " + (pos + 1) + " von " + ((q.total_in_pipeline) || waiting.length + 1);
          else if (q.active_job && q.active_job !== id) msg = "Warteschlange – GPU rendert gerade …";
        }} catch (e) {{}}
      }}
      setStatus(msg, "run");
      $("generate").disabled = true;
    }} else if (job.status === "done") {{
      clearInterval(pollTimer);
      pollTimer = null;
      $("generate").disabled = false;
      setStatus(job.message || "Fertig!", "ok");
      showResult(job);
    }} else if (job.status === "error") {{
      clearInterval(pollTimer);
      pollTimer = null;
      $("generate").disabled = false;
      setStatus(job.message || job.error || "Fehler", "err");
    }}
  }}, 1200);
}}

$("loadExample").onclick = async () => {{
  const data = await api("/api/template");
  if (data.ok) $("prompt").value = data.text;
}};

$("generate").onclick = async () => {{
  $("generate").disabled = true;
  setStatus("Starte …", "run");
  const body = {{
    prompt: $("prompt").value,
    voice_sarah: $("voice1").value,
    voice_laura: $("voice2").value,
    resolution: $("resolution").value,
    output: "both"
  }};
  const data = await api("/api/generate", {{
    method: "POST",
    headers: {{ "Content-Type": "application/json" }},
    body: JSON.stringify(body)
  }});
  if (!data.ok) {{
    if (data.needs_payment) {{
      setStatus((data.error || "Bezahlung nötig") + " – siehe Preise unten.", "err");
      document.getElementById("preise")?.scrollIntoView({{ behavior: "smooth" }});
    }} else {{
      setStatus(data.error || "Fehler", "err");
    }}
    $("generate").disabled = false;
    return;
  }}
  if (data.queued && data.queue_position > 1) {{
    setStatus("In Warteschlange (Platz " + data.queue_position + ") – GPU rendert gerade ein anderes Video …", "run");
  }} else {{
    setStatus("Generierung gestartet …", "run");
  }}
  pollJob(data.jobId);
}};

async function loadLicense() {{
  const p = await api("/api/pricing");
  if (!p.ok) return;
  const lic = p.license || {{}};
  const box = $("licenseBox");
  box.textContent = lic.message || "Bereit";
  box.className = "lic" + (lic.ok ? " ok" : " err");
  if (!lic.ok) {{
    box.onclick = () => document.getElementById("preise")?.scrollIntoView({{ behavior: "smooth" }});
    box.title = "Klicken → Preise";
  }} else {{
    box.onclick = null;
    box.title = "";
  }}
}}

$("copyLanBtn")?.addEventListener("click", async () => {{
  const url = $("lanLink")?.href || "";
  if (!url) return;
  try {{
    await navigator.clipboard.writeText(url);
    setStatus("WLAN-Link kopiert – an Freunde senden!", "ok");
  }} catch (e) {{
    setStatus("Link: " + url, "ok");
  }}
}});

async function startCheckout(productId, payWith) {{
  setStatus("Öffne Bezahlung …", "run");
  const r = await api("/api/checkout", {{
    method: "POST",
    headers: {{ "Content-Type": "application/json" }},
    body: JSON.stringify({{ product_id: productId, pay_with: payWith || "all" }})
  }});
  if (r.url) {{
    window.location.href = r.url;
    return;
  }}
  setStatus(r.error || r.message || "Checkout fehlgeschlagen", "err");
}}

document.querySelectorAll(".btn-buy").forEach((btn) => {{
  btn.onclick = () => startCheckout(btn.dataset.product, btn.dataset.pay || "all");
}});
document.querySelectorAll(".btn-pp").forEach((btn) => {{
  btn.onclick = async () => {{
    const p = await api("/api/pricing");
    const prod = (p.products || []).find((x) => x.id === btn.dataset.product);
    if (prod && prod.paypal_url) window.location.href = prod.paypal_url;
  }};
}});
$("payBank").onclick = () => startCheckout("dialog_month", "bank");

async function pollGpuStatus() {{
  try {{
    const s = await api("/api/server-status");
    if (!s.ok) return;
    const badge = $("gpuBadge");
    const text = $("gpuBadgeText");
    const hint = $("gpuQueueHint");
    if (badge && text) {{
      badge.hidden = false;
      const gpu = s.gpu_label || (s.gpu && s.gpu.name) || "GPU";
      const q = s.queue || {{}};
      const waiting = q.waiting_count || 0;
      const active = q.active_job ? 1 : 0;
      text.textContent = gpu + (active || waiting ? " · Render läuft" : " · bereit");
    }}
    if (hint) {{
      const enc = s.gpu_encoder || "";
      const q = s.queue || {{}};
      const n = (q.waiting_count || 0) + (q.active_job ? 1 : 0);
      if (n > 0) {{
        hint.hidden = false;
        hint.textContent = "🎬 " + n + " Video(s) in der Pipeline – Encoding über " + (enc || "GPU") + ".";
      }} else if (enc) {{
        hint.hidden = false;
        hint.textContent = "⚡ Kunden-Videos werden über " + enc + " auf diesem PC gerendert.";
      }} else {{
        hint.hidden = true;
      }}
    }}
  }} catch (e) {{}}
}}

(async () => {{
  const params = new URLSearchParams(location.search);
  if (params.get("paid") === "stripe" && params.get("session_id")) {{
    setStatus("Zahlung wird bestätigt …", "run");
    const v = await api("/api/verify-payment?session_id=" + encodeURIComponent(params.get("session_id")));
    if (v.ok) {{
      setStatus("Bezahlt – du kannst jetzt generieren!", "ok");
      history.replaceState(null, "", location.pathname);
      await loadLicense();
    }} else {{
      setStatus(v.error || "Zahlung noch nicht bestätigt", "err");
    }}
  }} else if (params.get("paid") === "paypal" && params.get("product")) {{
    setStatus("PayPal-Zahlung wird bestätigt …", "run");
    let q = "/api/verify-payment?provider=paypal&product=" + encodeURIComponent(params.get("product"));
    if (params.get("tx")) q += "&tx=" + encodeURIComponent(params.get("tx"));
    const v = await api(q);
    if (v.ok) {{
      setStatus("PayPal OK – du kannst jetzt generieren!", "ok");
      history.replaceState(null, "", location.pathname);
      await loadLicense();
    }} else {{
      setStatus(v.error || "PayPal-Freischaltung fehlgeschlagen", "err");
    }}
  }}
  const [voices, tmpl] = await Promise.all([
    api("/api/voices"),
    api("/api/template")
  ]);
  if (voices.ok) fillVoices(voices.voices, voices.defaults || {{}});
  if (tmpl.ok && !$("prompt").value.trim()) $("prompt").value = tmpl.text;
  await loadLicense();
  const health = await api("/api/health");
  if (health.ok && !health.elevenlabs) {{
    setStatus("Hinweis: ElevenLabs-Key fehlt – Fallback-Stimmen (Edge) möglich.", "run");
  }}
  await pollGpuStatus();
  setInterval(pollGpuStatus, 30000);
}})();
{studio_marketing_script()}
</script>
</body>
</html>"""


def build_site() -> Path:
    ensure_dirs()
    (STUDIO_ROOT / "index.html").write_text(studio_html(), encoding="utf-8")
    return STUDIO_ROOT


def server_running(port: int | None = None) -> bool:
    import socket

    p = port if port is not None else PORT
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(0.4)
        return s.connect_ex(("127.0.0.1", p)) == 0


class DialogStudioHandler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=str(STUDIO_ROOT), **kwargs)

    def log_message(self, fmt, *args):
        pass

    def _json(self, data, code=200):
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _read_json(self) -> dict:
        n = int(self.headers.get("Content-Length") or 0)
        if n <= 0:
            return {}
        return json.loads(self.rfile.read(n).decode("utf-8"))

    def _send_file(self, path: Path, download_name: str | None = None) -> None:
        if not path.is_file():
            self.send_error(404)
            return
        mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
        data = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", mime)
        self.send_header("Content-Length", str(len(data)))
        if download_name:
            self.send_header("Content-Disposition", f'attachment; filename="{download_name}"')
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        path = urlparse(self.path).path
        if path == "/api/pricing":
            from jam.dialog_shop import pricing_public

            return self._json({"ok": True, **pricing_public()})
        if path == "/api/verify-payment":
            from urllib.parse import parse_qs

            qs = parse_qs(urlparse(self.path).query)
            provider = (qs.get("provider") or [""])[0].strip().lower()
            if provider == "paypal":
                from jam.dialog_shop import verify_paypal_return

                product = (qs.get("product") or [""])[0]
                tx = (qs.get("tx") or [""])[0]
                return self._json(verify_paypal_return(product, tx=tx))
            sid = (qs.get("session_id") or [""])[0]
            from jam.dialog_shop import verify_stripe_return

            return self._json(verify_stripe_return(sid))
        if path == "/api/health":
            from jam.elevenlabs_tts import configured

            return self._json({"ok": True, "elevenlabs": configured()})
        if path == "/api/server-status":
            from jam.dialog_gpu_server import server_status_public

            return self._json(
                server_status_public(
                    active_job=_active_job,
                    jobs_dir=JOBS_DIR,
                    port=PORT,
                )
            )
        if path == "/api/voices":
            from jam.elevenlabs_tts import (
                VOICE_FEMALE_DEFAULT,
                VOICE_LAURA_DEFAULT,
                list_voices,
            )

            return self._json(
                {
                    "ok": True,
                    "voices": list_voices(),
                    "defaults": {
                        "voice_sarah": VOICE_FEMALE_DEFAULT,
                        "voice_laura": VOICE_LAURA_DEFAULT,
                    },
                }
            )
        if path == "/api/template":
            return self._json({"ok": True, "text": _example_prompt()})
        if path.startswith("/api/job/"):
            jid = path.split("/")[-1]
            job = load_job(jid)
            if not job:
                return self._json({"ok": False, "error": "Job nicht gefunden"}, 404)
            return self._json({"ok": True, "job": job})
        if path.startswith("/api/media/"):
            parts = path.strip("/").split("/")
            if len(parts) >= 4:
                jid, kind = parts[2], parts[3]
                job = load_job(jid)
                if job:
                    if kind.startswith("video") and job.get("local_video"):
                        return self._send_file(Path(job["local_video"]))
                    if kind.startswith("audio") and job.get("local_audio"):
                        return self._send_file(Path(job["local_audio"]))
            return self._json({"ok": False}, 404)
        if path.startswith("/api/download/"):
            parts = path.strip("/").split("/")
            if len(parts) >= 4:
                jid, kind = parts[2], parts[3]
                job = load_job(jid)
                if job:
                    if kind == "video" and job.get("local_video"):
                        return self._send_file(
                            Path(job["local_video"]), download_name=f"{jid}.mp4"
                        )
                    if kind == "audio" and job.get("local_audio"):
                        return self._send_file(
                            Path(job["local_audio"]), download_name=f"{jid}.mp3"
                        )
            return self._json({"ok": False}, 404)
        legal = _legal_page_bytes(path)
        if legal is not None:
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(legal)))
            self.end_headers()
            self.wfile.write(legal)
            return
        if path in ("/", "/index.html"):
            self.send_response(200)
            body = studio_html().encode("utf-8")
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return
        return super().do_GET()

    def do_POST(self):
        path = urlparse(self.path).path
        if path == "/api/checkout":
            body = self._read_json()
            from jam.dialog_shop import checkout

            return self._json(
                checkout(
                    str(body.get("product_id") or "dialog_month"),
                    email=str(body.get("email") or ""),
                    pay_with=str(body.get("pay_with") or "all"),
                )
            )
        if path == "/api/generate":
            return self._json(start_job(self._read_json()))
        return self._json({"ok": False, "error": "Unbekannt"}, 404)


def run_server(*, open_browser: bool = True, daemon: bool = False, host: str = "0.0.0.0") -> None:
    from jam.dialog_gpu_server import mark_server_started

    mark_server_started()
    if server_running() and host in ("127.0.0.1", "0.0.0.0"):
        if not daemon:
            from jam.dialog_shop import pricing_public

            pr = pricing_public()
            print(f"Dialog Studio laeuft schon -> {pr['local_url']}")
            print(f"WLAN/Freunde: {pr['lan_url']}")
        return

    build_site()
    resume_job_queue()
    httpd = ThreadingHTTPServer((host, PORT), DialogStudioHandler)
    if not daemon:
        from jam.dialog_shop import pricing_public

        pr = pricing_public()
        print(f"Dialog Studio -> {pr['local_url']}")
        if host == "0.0.0.0":
            print(f"WLAN/Freunde: {pr['lan_url']}")
        print(f"Ordner: {STUDIO_ROOT}")
    if open_browser and not daemon:
        webbrowser.open(SITE_URL, new=1)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        if not daemon:
            print("\nBeendet.")
