#!/usr/bin/env python3
"""Minimal Cockpit Server — statische Dateien + Wohnung-Daten API. Port 8089 (HTTP) + 8090 (HTTPS)."""
import hashlib
import json
import math
import os
import re
import subprocess
import sys
import threading
import uuid
from http.server import HTTPServer, SimpleHTTPRequestHandler
from socketserver import ThreadingMixIn
from pathlib import Path
from urllib.parse import urlparse, parse_qs, urlencode
import urllib.request as _urlrequest
import urllib.error as _urlerror

# Song-Erkennungs-Modul aus demselben Verzeichnis laden
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent / 'chatgpt_bridge'))
try:
    import song_erkennung as _song_mod
    _SONG_MOD_OK = True
except Exception as _e:
    _SONG_MOD_OK = False
    print(f'[cockpit] song_erkennung nicht geladen: {_e}', flush=True)

# ── Cherry Inbox Watcher + Live Listener ──────────────────────────────
try:
    import cherry_inbox as _cherry_inbox
    import notify as _cherry_notify
    import cherry_listener as _cherry_listener
    import watson_reactor as _watson_reactor
    _cherry_inbox.start_watcher()
    # _cherry_listener.start_listener()  # manuell starten
    _watson_reactor.start_reactor()
    print('[cockpit] Cherry-Inbox-Watcher + Watson-Reaktor gestartet', flush=True)
    _CHERRY_OK = True
except Exception as _ce:
    _CHERRY_OK = False
    print(f'[cockpit] Cherry-Inbox nicht geladen: {_ce}', flush=True)

ZOTIFY_JOBS = {}  # job_id -> {'status': ..., 'log': [...]}
WATSON_VOICES_DIR_FOR_ZOTIFY = Path('/Users/victorholland/Vibe Coding/Voice Output/watson_voices') / 'raw_sources' / 'spotify'
FLANEUR_TOKEN_FILE = Path('/Users/victorholland/Vibe Coding/dispatcher/flaneur/flaneur_public_tokens.json')
FLANEUR_LOCATION_FILE = Path('/Users/victorholland/Vibe Coding/dispatcher/cockpit/flaneur_latest_location.json')
FLANEUR_ROUTE_LOG_FILE = Path('/Users/victorholland/Vibe Coding/dispatcher/cockpit/flaneur_route_log.jsonl')

_TILE_KEY_CACHE = {}

def _flaneur_keychain(service: str, account: str = '') -> str:
    cache_key = (service, account)
    if cache_key in _TILE_KEY_CACHE:
        return _TILE_KEY_CACHE[cache_key]
    try:
        cmd = ['security', 'find-generic-password', '-s', service]
        if account:
            cmd += ['-a', account]
        cmd += ['-w']
        val = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip()
    except Exception:
        val = ''
    _TILE_KEY_CACHE[cache_key] = val
    return val

_FLANEUR_TILE_PROVIDERS = {
    'mapbox': {
        'kind': 'mapbox',
        'service': 'mapbox-token',
        'account': 'beachorchestra',
        'styles': {
            'streets': 'mapbox/streets-v12',
            'outdoors': 'mapbox/outdoors-v12',
            'light': 'mapbox/light-v11',
            'dark': 'mapbox/dark-v11',
            'satellite': 'mapbox/satellite-streets-v12',
            'navigation-night': 'mapbox/navigation-night-v1',
        },
    },
    'thunderforest': {
        'kind': 'thunderforest',
        'service': 'thunderforest-api',
        'account': 'beachorchestra',
        'styles': {
            'cycle': 'cycle',
            'atlas': 'atlas',
            'landscape': 'landscape',
            'outdoors': 'outdoors',
            'transport': 'transport',
            'transport-dark': 'transport-dark',
            'spinal': 'spinal-map',
            'spinal-map': 'spinal-map',
            'mobile-atlas': 'mobile-atlas',
            'pioneer': 'pioneer',
            'neighbourhood': 'neighbourhood',
        },
    },
    'maptiler': {
        'kind': 'maptiler',
        'service': 'maptiler-api',
        'account': 'beachorchestra',
        'styles': {
            'aquarelle': 'aquarelle-v4',
            'backdrop': 'backdrop-v4',
            'base': 'base-v4',
            'dataviz': 'dataviz-v4',
            'landscape': 'landscape-v4',
            'outdoor': 'outdoor-v4',
            'satellite': 'satellite-v4',
            'satellite-hybrid': 'hybrid-v4',
            'streets': 'streets-v4',
            'toner': 'toner-v2',
            'topo': 'topo-v4',
            'winter': 'winter-v4',
        },
    },
    'stadia': {
        'kind': 'stadia',
        'service': 'stadia-maps-api',
        'account': 'beachorchestra',
        'styles': {
            'watercolor': ('stamen_watercolor', 'jpg'),
            'toner': ('stamen_toner', 'png'),
            'toner-lite': ('stamen_toner_lite', 'png'),
            'terrain': ('stamen_terrain', 'png'),
            'outdoors': ('outdoors', 'png'),
            'alidade-smooth': ('alidade_smooth', 'png'),
            'alidade-smooth-dark': ('alidade_smooth_dark', 'png'),
            'osm-bright': ('osm_bright', 'png'),
        },
    },
    'berlin': {
        'kind': 'wms3857',
        'styles': {
            'luftbild-1928': {
                'url': 'https://gdi.berlin.de/services/wms/luftbild_1928_04',
                'layers': 'c_luftbilder_1928_04_raster',
                'format': 'image/png',
                'transparent': 'true',
            },
        },
    },
    'arcgis': {
        'kind': 'arcgis-tile',
        'styles': {
            'imagery': 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
            'topo': 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
            'natgeo': 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}',
            'shaded-relief': 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}',
        },
    },
    'jawg': {
        'kind': 'jawg',
        'service': 'jawg-api',
        'account': 'beachorchestra',
        'styles': {
            'streets': 'jawg-streets',
            'terrain': 'jawg-terrain',
            'sunny': 'jawg-sunny',
            'dark': 'jawg-dark',
            'light': 'jawg-light',
        },
    },
    'geoapify': {
        'kind': 'geoapify',
        'service': 'geoapify-api',
        'account': 'beachorchestra',
        'styles': {
            'osm-bright': 'osm-bright',
            'osm-carto': 'osm-carto',
            'klokantech-basic': 'klokantech-basic',
            'positron': 'positron',
            'dark-matter': 'dark-matter',
        },
    },
    'openhistoricalmap': {
        'kind': 'template',
        'styles': {
            'standard': 'https://www.openhistoricalmap.org/ohm_tiles/{z}/{x}/{y}.png',
        },
    },
}

def _flaneur_xyz_bbox_3857(z: int, x: int, y: int) -> tuple[float, float, float, float]:
    n = 2 ** z
    lon_w = x / n * 360.0 - 180.0
    lon_e = (x + 1) / n * 360.0 - 180.0
    lat_n = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
    lat_s = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
    radius = 6378137.0

    def merc(lon: float, lat: float) -> tuple[float, float]:
        mx = radius * math.radians(lon)
        my = radius * math.log(math.tan(math.pi / 4 + math.radians(lat) / 2))
        return mx, my

    minx, miny = merc(lon_w, lat_s)
    maxx, maxy = merc(lon_e, lat_n)
    return minx, miny, maxx, maxy

# ── Reisebericht Jobs ──────────────────────────────────────────────────
RB_JOBS: dict = {}   # job_id -> {status, step, detail, name, address, filename, error}
RB_LOCK = threading.Lock()
RB_OUTPUT_DIR = Path(__file__).parent / 'reiseberichte'  # kein iCloud-Sync, kein SF_DATALESS
RB_SCRIPT = Path(__file__).parent.parent / 'reisebericht' / 'reisebericht_v2.py'
RB_QUEUE: list = []   # [job_id, ...]  — wartende Jobs
RB_QUEUE_LOCK = threading.Lock()
RB_RUNNING = threading.Event()  # gesetzt wenn ein Job aktiv läuft

def _rb_run(job_id: str, name: str, lat: float, lng: float, address: str) -> None:
    """Läuft in eigenem Thread. Ruft reisebericht_v2.py auf und trackt Fortschritt."""
    import re as _re, time as _time
    steps = ['geocode', 'pois', 'wetter', 'text', 'tts', 'mix']
    step_patterns = {
        'geocode': r'Position:|Hole Geo',
        'pois':    r'Overpass|POI',
        'wetter':  r'Wetter|Open-Meteo',
        'text':    r'Generiere Text|Text:',
        'tts':     r'Erzeuge Sprach|Stimme gespeichert',
        'mix':     r'Hintergrundmusik|Mix|fertig',
    }

    def _set(step=None, detail='', status='running', filename=None, error=None):
        with RB_LOCK:
            RB_JOBS[job_id].update({
                'status': status,
                'step': step or RB_JOBS[job_id].get('step', 'geocode'),
                'detail': detail,
            })
            if filename: RB_JOBS[job_id]['filename'] = filename
            if error:    RB_JOBS[job_id]['error']    = error

    _set(step='geocode', detail='Starte…')

    try:
        import sys as _sys, subprocess as _sp
        env = {**__import__('os').environ}
        env['PYTHONUNBUFFERED'] = '1'  # real-time stdout from subprocess
        env['PATH'] = '/opt/homebrew/bin:/opt/homebrew/sbin:' + env.get('PATH', '/usr/bin:/bin:/usr/sbin:/sbin')
        # Anthropic key from keychain (credentials.env contains 1Password ref, unusable)
        try:
            ak = _sp.check_output(['security', 'find-generic-password', '-s', 'anthropic', '-w'],
                                  text=True, stderr=_sp.DEVNULL).strip()
            if ak:
                env['ANTHROPIC_API_KEY'] = ak
        except Exception:
            pass
        # ElevenLabs key from keychain — pass as env var so subprocess doesn't need keychain
        try:
            ek = _sp.check_output(['security', 'find-generic-password', '-s', '11labs', '-w'],
                                  text=True, stderr=_sp.DEVNULL).strip()
            if ek:
                env['ELEVENLABS_API_KEY'] = ek
        except Exception:
            pass

        cmd = [
            _sys.executable, str(RB_SCRIPT),
            '--lat',  str(lat),
            '--lng',  str(lng),
            '--mode', 'static',
        ]
        if name:
            cmd += ['--name', name]
        proc = _sp.Popen(cmd, stdout=_sp.PIPE, stderr=_sp.STDOUT,
                         text=True, bufsize=1, env=env,
                         cwd=str(RB_SCRIPT.parent))

        current_step = 'geocode'
        last_file = None

        for line in proc.stdout:
            line = line.rstrip()
            if not line: continue
            # Capture all output for debug
            with RB_LOCK:
                RB_JOBS[job_id].setdefault('debug_log', []).append(line)
            # Detect step from output
            for step, pat in step_patterns.items():
                if _re.search(pat, line, _re.IGNORECASE):
                    current_step = step
                    break
            _set(step=current_step, detail=line[:120])
            # Detect output file
            m = _re.search(r'Gespeichert: (.+\.mp3)', line)
            if m:
                last_file = Path(m.group(1)).name

        proc.wait()
        if proc.returncode != 0:
            _set(status='error', error='Script mit Fehler beendet', step=current_step)
            return

        # Find the latest mp3 in output dir
        if not last_file:
            RB_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
            mp3s = sorted(RB_OUTPUT_DIR.glob('*.mp3'), key=lambda p: p.stat().st_mtime, reverse=True)
            if mp3s: last_file = mp3s[0].name

        if last_file:
            _set(status='done', step='mix', detail='Fertig!', filename=last_file)
        else:
            _set(status='error', error='Keine MP3-Datei erzeugt', step='mix')

    except Exception as exc:
        _set(status='error', error=str(exc))

def _rb_queue_worker():
    """Verarbeitet die Reisebericht-Queue sequenziell. Läuft als Daemon-Thread."""
    import time as _time
    while True:
        with RB_QUEUE_LOCK:
            if not RB_QUEUE:
                RB_RUNNING.clear()
                _time.sleep(0.5)
                continue
            job_id = RB_QUEUE.pop(0)

        with RB_LOCK:
            job = RB_JOBS.get(job_id)
        if not job:
            continue

        RB_RUNNING.set()
        _rb_run(job_id, job['name'], job['lat'], job['lng'], job['address'])

_rb_worker_thread = threading.Thread(target=_rb_queue_worker, daemon=True)
_rb_worker_thread.start()

# ── Real-Debrid ────────────────────────────────────────────────────────
RD_JOBS: dict = {}   # job_id -> {status, magnet, folder, files, progress, error}
RD_LOCK = threading.Lock()
RD_DOWNLOADS_FILE = Path(__file__).parent / 'realdebrid_downloads.json'
RD_API_BASE = 'https://api.real-debrid.com/rest/1.0'

# ── Jackett ────────────────────────────────────────────────────────────
JACKETT_URL  = 'http://127.0.0.1:9117'
JACKETT_KEY  = '5ilxo38md8ddd7mab6fm0cfo27fdtnah'
_JACKETT_SESSION: dict = {'cookie': None}  # lazy session cookie cache
_JACKETT_LOCK = threading.Lock()

# ── SABnzbd (Usenet) ─────────────────────────────────────────────────
SABNZBD_URL  = 'http://127.0.0.1:8080'
SABNZBD_KEY  = '75431768c0314f748ea42f7a25849361'

# ── Stimmen-Finder Paths ──────────────────────────────────────────────
STIMMEN_DIR      = Path('/Users/victorholland/Vibe Coding/dispatcher/stimmen')
STIMMEN_AUDIBLE  = STIMMEN_DIR / 'audible'
STIMMEN_YTDLP    = STIMMEN_DIR / 'ytdlp'
STIMMEN_AUDIO    = STIMMEN_DIR / 'audio'
AUDIBLE_CLI      = Path('/Users/victorholland/Vibe Coding/dispatcher/tools/audible_venv/bin/audible')
NZBGEEK_CFG      = Path('/Users/victorholland/Vibe Coding/dispatcher/tools/nzbgeek_config.json')
_YTDLP_JOBS: dict = {}
_YTDLP_LOCK = threading.Lock()

# ── Anthropic Cost Report ──────────────────────────────────────────────
_ANTHROPIC_COSTS_CACHE: dict = {}  # {'ts': float, 'data': list}
_ANTHROPIC_COSTS_LOCK = threading.Lock()
_ANTHROPIC_CACHE_TTL = 3600  # 1 Stunde

# ── Sommerurlaub Build Pipeline ────────────────────────────────────────
_SU_BUILD: dict = {'status': 'idle', 'step': '', 'progress': 0, 'error': ''}
_SU_BUILD_LOCK = threading.Lock()
_SU_DEEPER: dict = {'status': 'idle', 'step': '', 'progress': 0, 'error': '', 'option': ''}
_SU_DEEPER_LOCK = threading.Lock()

_TAG_LABELS = {
    'ferienhaus':'Ferienhaus','hotel':'Hotel','apartment':'Apartment','offen':'Offen',
    'felsen':'Felsküste','sandstrand':'Sandstrand','lebendig':'Lebendig & bunt','umziehen':'Mehrere Orte',
    'kroatien':'Kroatien','slowenien':'Slowenien','montenegro':'Montenegro','italien':'Italien',
    'griechenland':'Griechenland','tuerkei':'Türkei',
    'schwimmen':'Schwimmen','schnorcheln':'Schnorcheln','fotografieren':'Fotografieren',
    'andere_kids':'Andere Kids','gaming':'Gaming','fahrrad':'Fahrrad','entspannen':'Entspannen',
    'spielplatz':'Spielplatz','sandburg':'Sandburg','eis':'Eisessen','boote':'Boote',
    'lesen':'Lesen','kultur':'Kultur','essen':'Gut essen','wein':'Wein','nichtstun':'Nichtstun',
    'abend':'Abendpromenaden','sport':'Sport','shoppen':'Shoppen',
    'selbst':'Selbst kochen','supermarkt':'Supermarkt in der Nähe','kinder':'Kinderfreundliche Restaurants',
    'klima':'Klimaanlage','waschmaschine':'Waschmaschine','parkplatz':'Parkplatz','spüli':'Spülmaschine',
    'pool':'Pool','balkon':'Balkon','meerblick':'Meerblick',
    'zug':'Zug','zug_ja':'Zug (gerne)','zug_preis':'Zug (wenn günstiger)','flug':'Flugzeug','flug_recherche':'Flüge recherchieren','auto_eigen':'Eigenes Auto','egal':'Egal wie','preis':'Was günstiger ist',
    'ja_adria':'Adria (offen)','ja_offen':'Alles offen','nein':'Festgelegtes Ziel',
    'mieten':'Mietauto vor Ort',
}

def _su_extract_json(raw: str) -> dict:
    import re as _re_j
    raw = raw.strip()

    def _grab_first_object(s):
        """Walk string respecting quoted strings to find the first complete {...} block."""
        depth = 0
        in_str = False
        esc = False
        start = None
        for i, c in enumerate(s):
            if esc:
                esc = False
                continue
            if in_str:
                if c == '\\':
                    esc = True
                elif c == '"':
                    in_str = False
                continue
            if c == '"':
                in_str = True
            elif c == '{':
                if depth == 0:
                    start = i
                depth += 1
            elif c == '}':
                depth -= 1
                if depth == 0 and start is not None:
                    return s[start:i + 1]
        return s  # fallback

    def _repair(s):
        s = _re_j.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', s)
        s = _re_j.sub(r',\s*([\}\]])', r'\1', s)
        return s

    # Strip all markdown code fences anywhere in the string
    stripped = _re_j.sub(r'```[a-zA-Z]*\n?', '', raw).strip()

    candidate = _grab_first_object(stripped) if '{' in stripped else stripped

    for attempt in (candidate, _repair(candidate)):
        try:
            return json.loads(attempt)
        except json.JSONDecodeError:
            pass

    # Final fallback: try on the completely raw string too
    candidate2 = _grab_first_object(raw)
    repaired2 = _repair(candidate2)
    try:
        return json.loads(repaired2)
    except json.JSONDecodeError as e:
        raise json.JSONDecodeError(f'JSON-Repair fehlgeschlagen: {e.msg}', e.doc, e.pos)

def _su_tag(v):
    if isinstance(v, list):
        return ', '.join(_TAG_LABELS.get(x, x) for x in v)
    return _TAG_LABELS.get(str(v), str(v))

def _su_week_label(kw: int) -> str:
    import datetime
    mon = datetime.date.fromisocalendar(2026, kw, 1)
    sun = mon + datetime.timedelta(days=6)
    return f'KW {kw} ({mon.day}.{mon.strftime("%b")}–{sun.day}.{sun.strftime("%b")})'

def _su_anthropic_key() -> str:
    try:
        import subprocess as _sp
        return _sp.check_output(
            ['security', 'find-generic-password', '-s', 'anthropic', '-w'],
            text=True, stderr=_sp.DEVNULL
        ).strip()
    except Exception:
        return ''

def _su_fetch_unsplash(query: str) -> str:
    """Holt ein Destination-Foto von Unsplash. Gibt URL zurück oder ''."""
    try:
        key = subprocess.check_output(
            ['security', 'find-generic-password', '-s', 'unsplash-api', '-w'],
            text=True, stderr=subprocess.DEVNULL).strip()
        if not key: return ''
        import urllib.request as _ur, urllib.parse as _up
        q = _up.quote(f'{query} travel landscape destination')
        url = f'https://api.unsplash.com/search/photos?query={q}&per_page=3&orientation=landscape'
        req = _ur.Request(url, headers={'Authorization': f'Client-ID {key}', 'Accept-Version': 'v1'})
        with _ur.urlopen(req, timeout=8) as resp:
            data = json.loads(resp.read())
        results = data.get('results', [])
        if results:
            return results[0].get('urls', {}).get('regular', '')
    except Exception as e:
        print(f'[unsplash] {e}', flush=True)
    return ''

def _su_fetch_gallery(option_name: str, d: dict) -> list:
    """Fetches 10+ gallery images from Unsplash tailored to destination + family prefs."""
    try:
        key = subprocess.check_output(
            ['security', 'find-generic-password', '-s', 'unsplash-api', '-w'],
            text=True, stderr=subprocess.DEVNULL).strip()
        if not key:
            return []
    except Exception:
        return []

    import urllib.request as _ur2, urllib.parse as _up2

    # Detect country/region for accurate search terms
    dl = option_name.lower()
    if any(x in dl for x in ['kroatien','dalmatien','split','dubrovnik','istrien','rovinj','makarska','brela','hvar','korčula']):
        place = 'Dalmatia Croatia'; loc = 'Kroatien · Adria'
    elif any(x in dl for x in ['griechenland','korfu','zakynthos','kephalonia','peloponnes','kreta','ionisch']):
        place = 'Greek islands Greece'; loc = 'Griechenland'
    elif any(x in dl for x in ['montenegro','kotor','perast','budva']):
        place = 'Montenegro Kotor Bay'; loc = 'Montenegro'
    elif any(x in dl for x in ['albanien','saranda','ksamil']):
        place = 'Albania Riviera'; loc = 'Albanien'
    elif any(x in dl for x in ['slowenien','piran','portorož']):
        place = 'Slovenia Adriatic coast'; loc = 'Slowenien'
    elif any(x in dl for x in ['portugal','algarve','lagos','albufeira']):
        place = 'Algarve Portugal'; loc = 'Portugal · Algarve'
    elif any(x in dl for x in ['spanien','mallorca','costa brava','valencia','katalonien']):
        place = 'Spain Mediterranean coast'; loc = 'Spanien'
    elif any(x in dl for x in ['frankreich','côte','nizza','antibes','provence']):
        place = 'French Riviera'; loc = 'Südfrankreich'
    elif any(x in dl for x in ['italien','sizilien','toskana','apulien','amalfi','sardinien']):
        place = 'Italy Mediterranean'; loc = 'Italien'
    elif any(x in dl for x in ['türkei','bodrum','antalya','fethiye','marmaris']):
        place = 'Turkey Aegean coast'; loc = 'Türkei'
    elif any(x in dl for x in ['malta']):
        place = 'Malta island'; loc = 'Malta'
    else:
        place = option_name.split(':')[0].split('(')[0].strip(); loc = place

    janno = d.get('janno', [])
    thede = d.get('thede', [])
    pina  = d.get('pina', [])
    math  = d.get('mathias', [])
    heid  = d.get('heidrun', [])

    # Base queries always present
    qc = [
        (f'{place} beach turquoise water',        f'Strand · {loc}'),
        (f'{place} old town historic village',     f'Altstadt · historisches Zentrum'),
        (f'{place} sea swimming crystal clear',    f'Kristallklares Wasser · {loc}'),
        (f'{place} harbor port boats',             f'Hafen · Bootsausflüge & Inseltransfers'),
        (f'{place} local food seafood restaurant', f'Lokale Küche · frische Meeresfrüchte'),
        (f'{place} nature landscape scenic',       f'Landschaft & Natur · {loc}'),
        (f'{place} sunset evening coastal',        f'Abendstimmung · Sonnenuntergang'),
        (f'{place} village street local life',     f'Lokales Leben · Dorf & Gassen'),
    ]
    # Family-tailored additions
    if 'schnorcheln' in janno:
        qc.append((f'{place} snorkeling underwater fish',   'Schnorcheln für Janno (14) · Unterwasserwelt'))
    elif 'tauchen' in janno:
        qc.append((f'{place} scuba diving underwater',      'Tauchen für Janno (14)'))
    else:
        qc.append((f'{place} beach kids playing',           'Strand · Spaß für Janno (14)'))

    if 'rad' in thede:
        qc.append((f'{place} cycling bike path coastal',    'Radfahren für Thede (11) · Küstenweg'))
    elif 'wandern' in thede:
        qc.append((f'{place} hiking trail scenic',          'Wandern für Thede (11) · Aussicht'))
    else:
        qc.append((f'{place} water sports kayak paddle',    'Wassersport für Thede (11)'))

    if 'pool' in pina:
        qc.append((f'outdoor pool Mediterranean summer',    'Pool für Pina (7) · Plantschen & Entspannen'))
    else:
        qc.append((f'{place} children family fun beach',    'Strand für Pina (7) · Sandburgen & Wellen'))

    if 'wein' in math:
        qc.append((f'{place} wine local winery',            'Weinkultur für Mathias · regionale Tropfen'))
    if 'essen' in math or 'essen' in heid:
        qc.append((f'{place} gourmet dinner outdoor terrace','Abendessen auf der Terrasse · Mathias & Heidrun'))

    # Fill up to min 10
    extras = [
        (f'{place} family vacation summer',       f'Familienurlaub · {loc}'),
        (f'{place} aerial coast drone view',      f'Küste aus der Vogelperspektive · {loc}'),
        (f'{place} market local produce',         'Lokaler Markt · frische Zutaten'),
    ]
    while len(qc) < 10:
        if extras: qc.append(extras.pop(0))
        else: break

    results: list = [None] * len(qc)

    def _one(i, query, caption):
        try:
            q = _up2.quote(query)
            url = f'https://api.unsplash.com/search/photos?query={q}&per_page=3&orientation=landscape&content_filter=high'
            req = _ur2.Request(url, headers={'Authorization': f'Client-ID {key}', 'Accept-Version': 'v1'})
            with _ur2.urlopen(req, timeout=10) as resp:
                data = json.loads(resp.read())
            photos = data.get('results', [])
            if photos:
                p = photos[0]
                pid = p['id']
                utm = 'utm_source=watson_reiseplaner&utm_medium=referral'
                results[i] = {
                    'url':   p['urls']['regular'],
                    'thumb': p['urls']['small'],
                    'alt':   (p.get('alt_description') or caption)[:120],
                    'caption': caption,
                    'credit_name': p['user']['name'],
                    'credit_url': f'{p["user"]["links"]["html"]}?{utm}',
                    'photo_url':  f'https://unsplash.com/photos/{pid}?{utm}',
                }
        except Exception as ex:
            print(f'[gallery] "{query}": {ex}', flush=True)

    ts = [threading.Thread(target=_one, args=(i, q, c), daemon=True) for i, (q, c) in enumerate(qc)]
    for t in ts: t.start()
    for t in ts: t.join(timeout=15)
    return [r for r in results if r]


def _su_call_claude(api_key: str, prompt: str, max_tokens: int = 4096, model: str = 'claude-haiku-4-5-20251001', prefill: str = '') -> str:
    import urllib.request as _ur
    messages = [{'role': 'user', 'content': prompt}]
    if prefill:
        messages.append({'role': 'assistant', 'content': prefill})
    payload = json.dumps({
        'model': model,
        'max_tokens': max_tokens,
        'messages': messages,
    }).encode('utf-8')
    req = _ur.Request(
        'https://api.anthropic.com/v1/messages',
        data=payload,
        headers={
            'x-api-key': api_key,
            'anthropic-version': '2023-06-01',
            'Content-Type': 'application/json',
        },
        method='POST',
    )
    with _ur.urlopen(req, timeout=90) as resp:
        data = json.loads(resp.read().decode('utf-8'))
    content = data.get('content', [])
    return content[0].get('text', '').strip() if content else ''

def _su_api_key(service: str, account: str = 'rat-der-weisen') -> str:
    try:
        import subprocess as _sp2
        return _sp2.check_output(
            ['security', 'find-generic-password', '-s', service, '-a', account, '-w'],
            text=True, stderr=_sp2.DEVNULL
        ).strip()
    except Exception:
        return ''

def _su_call_perplexity(key: str, prompt: str, max_tokens: int = 1000) -> str:
    import urllib.request as _ur2
    payload = json.dumps({
        'model': 'sonar',
        'messages': [{'role': 'user', 'content': prompt}],
        'max_tokens': max_tokens,
    }).encode('utf-8')
    req = _ur2.Request(
        'https://api.perplexity.ai/chat/completions',
        data=payload,
        headers={'Authorization': f'Bearer {key}', 'Content-Type': 'application/json'},
        method='POST',
    )
    with _ur2.urlopen(req, timeout=60) as resp:
        data = json.loads(resp.read().decode('utf-8'))
    return data['choices'][0]['message']['content'].strip()

def _su_call_openai(key: str, prompt: str, max_tokens: int = 800) -> str:
    import urllib.request as _ur3
    payload = json.dumps({
        'model': 'gpt-4o-mini',
        'messages': [{'role': 'user', 'content': prompt}],
        'max_tokens': max_tokens,
    }).encode('utf-8')
    req = _ur3.Request(
        'https://api.openai.com/v1/chat/completions',
        data=payload,
        headers={'Authorization': f'Bearer {key}', 'Content-Type': 'application/json'},
        method='POST',
    )
    with _ur3.urlopen(req, timeout=60) as resp:
        data = json.loads(resp.read().decode('utf-8'))
    return data['choices'][0]['message']['content'].strip()

def _su_call_gemini_verify(key: str, prompt: str, max_tokens: int = 800) -> str:
    import urllib.request as _ur4
    payload = json.dumps({
        'contents': [{'parts': [{'text': prompt}]}],
        'generationConfig': {'maxOutputTokens': max_tokens, 'temperature': 0.1},
    }).encode('utf-8')
    url = f'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key={key}'
    req = _ur4.Request(url, data=payload, headers={'Content-Type': 'application/json'}, method='POST')
    with _ur4.urlopen(req, timeout=60) as resp:
        data = json.loads(resp.read().decode('utf-8'))
    parts = data.get('candidates', [{}])[0].get('content', {}).get('parts', [{}])
    return parts[0].get('text', '').strip()

def _su_verify_prompt(facts_text: str, option: str, budget: int) -> str:
    return f"""Du bist ein unabhängiger Reise-Faktenchecker. Prüfe folgende Angaben für eine Familienreise (5 Personen, Berlin → {option}, 2 Wochen, ~{budget:,} € Gesamtbudget) auf Plausibilität (Stand Sommer 2026).

ZU PRÜFENDE ANGABEN:
{facts_text}

Antworte AUSSCHLIESSLICH mit diesem JSON (kein Markdown, kein Text davor/danach):
{{"facts":[{{"claim":"Originaltext","verdict":"confirmed|plausible|uncertain|wrong","note":"Max 1 Satz Einschätzung oder Korrektur"}}],"overall":"reliable|mostly_reliable|uncertain|unreliable","summary":"Ein Satz Gesamteinschätzung"}}"""

def _su_extract_verifiable_facts(recs: dict) -> list[str]:
    facts = []
    for f in recs.get('flights', []):
        if f.get('price_estimate'):
            facts.append(f"Flug {f.get('route','Berlin→Ziel')}: {f['price_estimate']} pro Person")
    for a in recs.get('accommodations', []):
        if a.get('price_range'):
            facts.append(f"Unterkunft '{a.get('name',a.get('type',''))}': {a['price_range']} pro Nacht")
    bd = recs.get('budget_summary', {})
    if bd.get('rental_car'):
        facts.append(f"Mietauto 14 Tage: {bd['rental_car']}")
    if bd.get('total'):
        facts.append(f"Gesamtbudget geschätzt: {bd['total']}")
    if bd.get('accommodation_total'):
        facts.append(f"Unterkunft gesamt 14 Nächte: {bd['accommodation_total']}")
    for act in recs.get('activities', [])[:2]:
        if act.get('price'):
            facts.append(f"Aktivität '{act.get('name','')}': {act['price']}")
    return facts if facts else ['Preisangaben aus der Tiefenrecherche']

def _su_run_verification(recs: dict, option: str, budget: int) -> dict:
    facts_list = _su_extract_verifiable_facts(recs)
    facts_text = '\n'.join(f'- {f}' for f in facts_list)
    prompt = _su_verify_prompt(facts_text, option, budget)

    results = {}
    errors = {}

    def call_one(name, fn, *args):
        try:
            raw = fn(*args, prompt)
            parsed = _su_extract_json(raw)
            results[name] = parsed
        except Exception as ex:
            errors[name] = str(ex)
            results[name] = {'facts': [], 'overall': 'uncertain', 'summary': f'Fehler: {ex}'}

    threads = [
        threading.Thread(target=call_one, args=('Perplexity', _su_call_perplexity, _su_api_key('perplexity')), daemon=True),
        threading.Thread(target=call_one, args=('ChatGPT', _su_call_openai, _su_api_key('openai')), daemon=True),
        threading.Thread(target=call_one, args=('Gemini', _su_call_gemini_verify, _su_api_key('gemini')), daemon=True),
        threading.Thread(target=call_one, args=('Claude', _su_call_claude, _su_anthropic_key()), daemon=True),
    ]
    for t in threads:
        t.start()
    for t in threads:
        t.join(timeout=90)

    return {'facts_checked': facts_list, 'sources': results, 'errors': errors}

def _su_verification_html(verify: dict) -> str:
    if not verify or not verify.get('sources'):
        return ''
    sources = verify['sources']

    VERDICT_MAP = {
        'confirmed': ('✓', '#4caf82', 'Bestätigt'),
        'plausible': ('~', '#d4a843', 'Plausibel'),
        'uncertain': ('?', '#888', 'Unsicher'),
        'wrong': ('✗', '#e05252', 'Abweichung'),
    }
    OVERALL_MAP = {
        'reliable': ('#4caf82', 'Zuverlässig'),
        'mostly_reliable': ('#d4a843', 'Überwiegend zuverlässig'),
        'uncertain': ('#888', 'Unsicher'),
        'unreliable': ('#e05252', 'Nicht zuverlässig'),
    }

    # Count divergences across all sources
    divergence_count = 0
    all_verdicts = []
    for src_data in sources.values():
        for fact in src_data.get('facts', []):
            v = fact.get('verdict', 'uncertain')
            all_verdicts.append(v)
            if v in ('uncertain', 'wrong'):
                divergence_count += 1

    all_overall = [s.get('overall', 'uncertain') for s in sources.values()]
    any_unreliable = any(o in ('uncertain', 'unreliable') for o in all_overall)

    # No divergences and all reliable → small badge only, no full panel
    if divergence_count == 0 and not any_unreliable:
        n = len(sources)
        return f'<div class="verify-ok fade">✓ Alle {n} KIs bestätigen die Angaben als plausibel</div>'

    # Divergences present → full panel
    source_blocks = ''
    for src_name, src_data in sources.items():
        overall = src_data.get('overall', 'uncertain')
        color, overall_label = OVERALL_MAP.get(overall, ('#888', overall))
        summary = src_data.get('summary', '')
        fact_rows = ''
        for fact in src_data.get('facts', []):
            v = fact.get('verdict', 'uncertain')
            icon, fcolor, _ = VERDICT_MAP.get(v, ('?', '#888', v))
            note = fact.get('note', '')
            claim = fact.get('claim', '')
            note_html = f'<div class="vf-note">{note}</div>' if note else ''
            fact_rows += f'<div class="vf-row"><span class="vf-icon" style="color:{fcolor}">{icon}</span><div><div class="vf-claim">{claim}</div>{note_html}</div></div>'
        summary_html = f'<div class="vs-summary">{summary}</div>' if summary else ''
        source_blocks += f'''
<div class="vs-block">
  <div class="vs-head">
    <div class="vs-name">{src_name}</div>
    <div class="vs-overall" style="color:{color}">{overall_label}</div>
  </div>
  {summary_html}
  {fact_rows}
</div>'''

    return f'''
<div class="verify-section fade">
  <div class="s-label">⚠ Abweichungen gefunden — {divergence_count} Angabe(n) prüfen</div>
  <div class="verify-note">Perplexity (Live-Web), ChatGPT, Gemini und Claude haben die Angaben unabhängig geprüft. Bitte markierte Punkte vor der Buchung selbst verifizieren.</div>
  <div class="vs-grid">{source_blocks}</div>
</div>'''

def _su_perplexity_gather(destination: str, d: dict) -> str:
    key = _su_api_key('perplexity')
    if not key:
        return f'Keine Perplexity-Daten für {destination} (kein API-Key).'
    wochen = d.get('wochen', [])
    period = f'KW {min(wochen)}–{max(wochen)} 2026' if wochen else 'Juli/August 2026'
    queries = [
        f'Ferienwohnung Ferienhaus Familie 5 Personen {destination} {period} Preise pro Nacht konkrete Angebote buchbar booking.com airbnb',
        f'Flüge Berlin BER {destination} {period} Rückflug Preise Direktflug Angebote aktuell 2026 konkrete Preise pro Person',
        f'Nachtzug ÖBB Nightjet EuroNight Berlin {destination} {period} Preise Buchung Familie konkrete Fahrplaninfos',
        f'Mietwagen {destination} {period} 7-Sitzer Familienvan konkrete Preise 14 Tage Anbieter Check24 Sixt Hertz',
        f'Kinderfreundliche Aktivitäten Ausflüge {destination} 2026 konkrete Eintrittspreise Tipps Familie',
        f'Restaurants Essen {destination} günstig Familie lokal 2026 Empfehlungen Preise',
        f'{destination} Strand Meer Tipps 2026 Familie Kinder beste Strände Wasserqualität',
        f'{destination} Fähre Fährverbindung lokale Inseln Fahrtzeiten Preise 2026 Familie Tagesausflug',
        f'{destination} aktuelles Preisniveau 2026 Kosten Lebenshaltung Tourist Supermarkt Restaurant',
        f'{destination} Reiseinfos 2026 Einreise Sicherheit Tipps aktuell Gesundheit',
    ]
    results = [''] * len(queries)
    def _q(i, text):
        try:
            results[i] = _su_call_perplexity(key, text, max_tokens=1000)
        except Exception as e:
            results[i] = f'[Fehler: {e}]'
    ts = [threading.Thread(target=_q, args=(i, t), daemon=True) for i, t in enumerate(queries)]
    for t in ts: t.start()
    for t in ts: t.join(timeout=90)
    labels = ['UNTERKUNFT (Live)', 'FLÜGE (Live)', 'ZUGVERBINDUNGEN (Live)', 'MIETWAGEN (Live)', 'AKTIVITÄTEN & AUSFLÜGE', 'RESTAURANTS & ESSEN', 'STRÄNDE & MEER', 'FÄHREN & INSELN', 'PREISNIVEAU & KOSTEN', 'REISEINFOS & SICHERHEIT']
    return '\n\n'.join(f'=== {l} ===\n{r}' for l, r in zip(labels, results) if r and '[Fehler' not in r) or f'Keine Perplexity-Daten für {destination}.'

def _su_claude_structure_prompt(research_brief: str, d: dict, option: str, compact: bool = False) -> str:
    budget = d.get('budget_max', d.get('budget', 7500))
    budget_min = d.get('budget_min', 0)
    budget_str = f'{budget_min:,.0f}–{budget:,.0f} €' if budget_min else f'{budget:,.0f} €'
    wochen = d.get('wochen', [])
    period = ', '.join(_su_week_label(w) for w in sorted(wochen)) or 'flexibel'
    kurzfristig_note = 'KURZFRISTIGE BUCHUNG — nur aktuell verfügbare Optionen zeigen.' if d.get('kurzfristig') else ''
    prompt_full = f"""Du bist ein Reiseplaner. Nutze AUSSCHLIESSLICH die folgenden Live-Recherche-Daten von Perplexity — kein Trainingswissen. Nutze die Daten vollständig aus. Wenn eine konkrete Zahl fehlt, schätze auf Basis der Daten und markiere mit "(Richtwert)". Kein Feld darf leer bleiben oder "nicht recherchiert" enthalten. WICHTIG: data_gaps muss immer ein leeres Array [] bleiben — schätze statt Lücken zu melden.

REISEZIEL: {option} | Budget: {budget_str} | Zeitraum: {period} | Familie: 5P (Janno 14, Thede 11, Pina 7 + 2 Erw.) | Abfahrt: Berlin
{kurzfristig_note}

PERPLEXITY LIVE-DATEN:
{research_brief}

Erstelle einen strukturierten Reiseplan. Antworte AUSSCHLIESSLICH mit JSON:
{{
  "destination": "{option}",
  "city": "Hauptstadt/Hauptort für Buchungssuche (z.B. Dubrovnik, Lagos, Ljubljana)",
  "iata": "Nächster Flughafen IATA-Code (z.B. DBV, FAO, LJU) oder leer wenn kein Direktflug",
  "data_quality": "gut|mittel|lückenhaft",
  "intro": "2 Sätze Einleitung über das Reiseziel aus den Recherche-Daten",
  "best_week": "Empfohlene Woche aus den Daten oder 'nicht aus Daten ableitbar'",
  "one_concern": "Ein echter Vorbehalt oder leer",
  "accommodations": [
    {{"name": "Name oder Typ", "area": "Lage", "bedrooms": 2, "price_range": "XXX–XXX €/N", "description": "Kurz aus Daten", "booking_url": "URL falls in Daten vorhanden", "humble": "Hinweis falls Preis nicht live bestätigt"}}
  ],
  "flights": [
    {{"airline": "Name", "route": "BER→Ziel", "price_estimate": "~XXX €/P", "price_5p_return": "~X.XXX € gesamt", "duration": "Xh", "humble": "Hinweis"}}
  ],
  "trains": [
    {{"type": "Nachtzug/IC", "route": "Berlin→Ziel", "price_estimate": "~XXX €/P", "price_5p_return": "~X.XXX € gesamt", "duration": "Xh", "humble": "Hinweis"}}
  ],
  "activities": [
    {{"title": "Name", "for": "Janno/alle/Kinder", "description": "Kurz", "approx_cost": "~XX €/P", "kid_friendly": true}}
  ],
  "budget_detail": {{
    "unterkunft_14n": "~X.XXX €",
    "anreise_5p": "~XXX €",
    "mietauto_14d": "~XXX €",
    "ausflüge": "~XXX €",
    "essen_täglich": "~XXX € (Ø/Tag × 14)",
    "gesamt": "~X.XXX € PFLICHTFELD — addiere alle Posten, immer ausfüllen",
    "puffer": "~XXX € (verbleibend bei Budget {budget:,} €)",
    "humble": "Alle Preise Richtwerte aus Perplexity-Recherche. Vor Buchung prüfen."
  }},
  "data_gaps": []
}}"""
    if not compact:
        return prompt_full
    # Compact fallback — minimal fields, short descriptions only
    return f"""Reiseplaner. Nur Perplexity-Daten nutzen. ZIEL: {option} | Budget: {budget_str} | Zeitraum: {period}

DATEN:
{research_brief}

Antworte NUR mit kompaktem JSON (kurze Texte, max 3 Unterkünfte, max 2 Flüge, max 4 Aktivitäten):
{{
  "destination": "{option}",
  "data_quality": "gut|mittel|lückenhaft",
  "intro": "1 Satz",
  "best_week": "KW X oder unbekannt",
  "one_concern": "Kurz oder leer",
  "accommodations": [{{"name":"","area":"","price_range":"","description":"1 Satz"}}],
  "flights": [{{"airline":"","route":"","price_5p_return":"","duration":""}}],
  "trains": [{{"type":"","route":"","price_5p_return":"","duration":""}}],
  "activities": [{{"title":"","for":"","description":"1 Satz","approx_cost":""}}],
  "budget_detail": {{"unterkunft_14n":"","anreise_5p":"","gesamt":"","humble":"Richtwerte"}},
  "data_gaps": []
}}"""

def _su_chatgpt_red_team_prompt(plan_str: str, research_brief: str, option: str) -> str:
    return f"""Du bist ein kritischer Reise-Gutachter. Dein einziger Auftrag: Finde alles was an diesem Reiseplan falsch, unrealistisch, übertrieben oder gefährlich ist.

REISEPLAN FÜR {option}:
{plan_str}

PERPLEXITY RECHERCHE-BASIS:
{research_brief[:2500]}

Antworte AUSSCHLIESSLICH mit JSON:
{{
  "red_team_verdict": "reliable|mostly_reliable|problematic",
  "critical_issues": [
    {{"severity": "high|medium|low", "claim": "Die beanstandete Aussage im Plan", "issue": "Was daran falsch oder fraglich ist", "suggestion": "Was realistischer wäre"}}
  ],
  "tourist_traps": ["Konkrete Touristenfallen oder Risiken für dieses Ziel"],
  "missing_info": ["Was im Plan fehlt und vor Buchung recherchiert werden muss"],
  "positive": "Ein Satz was am Plan wirklich gut ist"
}}"""

def _su_red_team_html(critique: dict) -> str:
    if not critique or critique.get('red_team_verdict') == 'reliable' and not critique.get('critical_issues'):
        pos = critique.get('positive', '') if critique else ''
        return f'<div class="rt-ok fade">✓ Red Team (ChatGPT): Keine kritischen Probleme gefunden.{" " + pos if pos else ""}</div>' if critique else ''
    verdict = critique.get('red_team_verdict', 'uncertain')
    VERDICT = {'reliable': ('#4caf82', 'Zuverlässig'), 'mostly_reliable': ('#d4a843', 'Überwiegend ok'), 'problematic': ('#e05252', 'Kritische Punkte'), 'uncertain': ('#888', 'Unsicher')}
    vc, vl = VERDICT.get(verdict, ('#888', verdict))
    SEV = {'high': ('🔴', '#e05252'), 'medium': ('🟡', '#d4a843'), 'low': ('⚪', '#666')}
    issues_html = ''
    for iss in critique.get('critical_issues', []):
        icon, color = SEV.get(iss.get('severity','low'), ('⚪','#666'))
        sug_html = f'<div class="rt-suggestion">→ {iss["suggestion"]}</div>' if iss.get('suggestion') else ''
        issues_html += f'<div class="rt-issue"><span class="rt-sev">{icon}</span><div><div class="rt-claim">„{iss.get("claim","")}"</div><div class="rt-issue-text" style="color:{color}">{iss.get("issue","")}</div>{sug_html}</div></div>'
    traps_html = ''
    if critique.get('tourist_traps'):
        items = ''.join(f'<div class="rt-trap">⚠ {t}</div>' for t in critique['tourist_traps'])
        traps_html = f'<div class="rt-sub-title">Touristenfallen &amp; Risiken</div>{items}'
    missing_html = ''
    if critique.get('missing_info'):
        items = ''.join(f'<div class="rt-missing-item">? {m}</div>' for m in critique['missing_info'])
        missing_html = f'<div class="rt-sub-title">Vor Buchung klären</div>{items}'
    pos_html = f'<div class="rt-positive">✓ {critique["positive"]}</div>' if critique.get('positive') else ''
    return f'''
<div class="redteam-section fade">
  <div class="s-label">⚔ Red Team — ChatGPT prüft kritisch</div>
  <div class="rt-head">
    <span class="rt-verdict-badge" style="color:{vc}">{vl}</span>
    <span class="rt-note">ChatGPT hat den Plan unabhängig angegriffen</span>
  </div>
  {issues_html}
  {traps_html}
  {missing_html}
  {pos_html}
</div>'''

def _su_booking_links_html(plan: dict, d: dict) -> str:
    """Generiert vorausgefüllte Booking-Links für Mathias."""
    import datetime as _dtb, urllib.parse as _upb
    destination = plan.get('destination', '')
    city = plan.get('city', destination.split('(')[0].strip())
    iata = plan.get('iata', '')
    wochen = d.get('wochen', [])
    if wochen:
        kw = min(wochen)
        checkin = _dtb.date.fromisocalendar(2026, kw, 1)
    else:
        checkin = _dtb.date(2026, 7, 7)
    checkout = checkin + _dtb.timedelta(days=14)
    ci = checkin.strftime('%Y-%m-%d')
    co = checkout.strftime('%Y-%m-%d')
    ci_compact = checkin.strftime('%Y%m%d')
    co_compact = checkout.strftime('%Y%m%d')
    date_label = f'{checkin.strftime("%-d.%b")} – {checkout.strftime("%-d.%b %Y")}'
    city_enc = _upb.quote(city)

    links = [
        ('🏨', 'Booking.com', f'https://www.booking.com/searchresults.de.html?ss={city_enc}&checkin={ci}&checkout={co}&group_adults=2&group_children=3&age=14&age=11&age=7&no_rooms=1&nflt=entire_homes%3D1', 'Ferienwohnung · 5 Personen'),
        ('🏠', 'Airbnb', f'https://www.airbnb.de/s/{city_enc}/homes?checkin={ci}&checkout={co}&adults=2&children=3', 'Ganze Unterkunft · 5 Personen'),
        ('🏩', 'HRS', f'https://www.hrs.de/web3/welcome.do?clientId=HRS&language=de&dest={city_enc}&checkIn={ci}&checkOut={co}&adults=2&children=3', 'Ferienwohnungen + Hotels'),
        ('🏡', 'Hotels.de', f'https://www.hotels.de/search/?destination={city_enc}&checkIn={ci}&checkOut={co}&rooms=1&adults=2&children=3', 'Unterkunft Preisvergleich'),
    ]
    if iata:
        links += [
            ('✈️', 'Skyscanner', f'https://www.skyscanner.de/transport/flights/ber/{iata.lower()}/{ci_compact}/{co_compact}/?adults=2&children=3', f'BER → {iata} · Hin + Rück'),
            ('🔍', 'Idealo Flüge', f'https://www.idealo.de/flug/ergebnis/0-BER,{iata}/{ci}/{co}/2-2.1.1/', f'Preisvergleich BER ↔ {iata}'),
        ]
    links.append(('🚗', 'Check24 Mietwagen', f'https://mietwagen.check24.de/mietwagen/ergebnis/{city_enc}/{ci}/{co}/', f'{date_label} · Familienvan'))

    cards = ''
    for icon, label, url, note in links:
        cards += f'''<a class="bl-card" href="{url}" target="_blank" rel="noopener">
  <span class="bl-icon">{icon}</span>
  <div class="bl-body"><div class="bl-name">{label}</div><div class="bl-note">{note}</div></div>
  <span class="bl-arrow">↗</span>
</a>'''

    return f'''<div class="booking-links-section fade">
  <div class="s-label">Direkt buchen — {date_label} · 5 Personen</div>
  <div class="bl-note-top">Alle Links öffnen mit {destination}, den gewählten Daten und der richtigen Personenzahl vorausgefüllt.</div>
  <div class="bl-grid">{cards}</div>
</div>'''

def _su_generate_html(d: dict, recs: dict) -> str:
    import datetime as _dt
    opts = recs.get('options', [])
    family_summary = recs.get('family_summary', '')
    weeks_used = d.get('wochen', [])
    budget_min = d.get('budget_min', 0)
    budget_max = d.get('budget_max', d.get('budget', 7500))
    budget = budget_max
    budget_str = f'{budget_min:,.0f}–{budget_max:,.0f} €' if budget_min else f'{budget_max:,.0f} €'
    generated_ts = _dt.datetime.now().strftime('%B %Y')

    def week_chips(kws):
        best_set = set(recs.get('best_weeks', []))
        chips = []
        for kw in sorted(kws):
            cls = 'tw best' if kw in best_set else 'tw'
            lbl = _su_week_label(kw)
            short = f'KW {kw}'
            chips.append(f'<div class="{cls}"><b>{short}</b><small>{lbl.split("(")[1].rstrip(")")}</small></div>')
        return '\n    '.join(chips)

    def option_html(opt, idx):
        num_map = {0: '①', 1: '②', 2: '③', 3: '④', 4: '⑤'}
        rank_map = {0: '— Empfehlung', 1: '', 2: '', 3: '', 4: '— Wildcard'}
        num = num_map.get(idx, f'#{idx+1}')
        rank = rank_map.get(idx, '')
        name = opt.get('name', '')
        region = opt.get('region', '')
        tagline = opt.get('tagline', '')
        why = opt.get('why_good', '')
        not_perfect = opt.get('not_perfect', '')
        unsplash_url = opt.get('unsplash_url', '')
        img_file = opt.get('image_wikimedia_filename', '')
        wikimedia_url = f'https://commons.wikimedia.org/wiki/Special:FilePath/{img_file}?width=800' if img_file else ''
        img_src = unsplash_url or wikimedia_url
        img_tag = f'<img class="opt-photo" src="{img_src}" alt="{name}" loading="lazy" onerror="this.style.display=\'none\'">' if img_src else ''

        warn_html = ''
        if not_perfect:
            warn_html = f'<div class="warn">⚠ {not_perfect}</div>'

        transport_rows = ''
        for t in opt.get('transport', []):
            link_html = ''
            if t.get('link_url') and t.get('link_label'):
                link_html = f'<a class="src-link" href="{t["link_url"]}" target="_blank">{t["link_label"]} ↗</a>'
            note = t.get('note', '')
            transport_rows += f'''
        <div class="d-row">
          <div class="d-lbl">
            {t.get("mode","")}: {t.get("route","")}{link_html}
            <small>{note}</small>
          </div>
          <div class="d-val gold">{t.get("price_display","")}</div>
        </div>'''
        if opt.get('transport_humble'):
            transport_rows += f'<div class="humble">{opt["transport_humble"]}</div>'

        acc_rows = ''
        for a in opt.get('accommodation', []):
            links_html = ''
            for lnk in a.get('links', []):
                links_html += f'<a class="src-link" href="{lnk["url"]}" target="_blank">{lnk["label"]} ↗</a>'
            note = a.get('note', '')
            acc_rows += f'''
        <div class="d-row">
          <div class="d-lbl">
            {a.get("type","")}{links_html}
            <small>{note}</small>
          </div>
          <div class="d-val gold">{a.get("price_display","")}</div>
        </div>'''
        if opt.get('acc_humble'):
            acc_rows += f'<div class="humble">{opt["acc_humble"]}</div>'

        budget_rows = ''
        for b in opt.get('budget_breakdown', []):
            row_cls = 'total' if b.get('total') else ''
            budget_rows += f'<div class="d-row"><div class="d-lbl">{b["label"]}</div><div class="d-val {row_cls}">{b["value"]}</div></div>'
        if opt.get('budget_humble'):
            budget_rows += f'<div class="humble">{opt["budget_humble"]}</div>'

        deeper_label = opt.get('deeper_label', f'Konkrete Apartments + Preise für {name} recherchieren →')

        # Gallery strip
        gallery = opt.get('gallery', [])
        gallery_html = ''
        if gallery:
            thumbs = ''
            for gi, g in enumerate(gallery):
                thumbs += f'<img class="gthumb" src="{g["thumb"]}" alt="{g["alt"]}" loading="lazy" onclick="openLb({idx},{gi})">'
            gallery_html = f'<div class="gallery-wrap"><div class="gallery-strip">{thumbs}</div><div class="gallery-hint">↔ scrollen · klicken zum Vergrößern</div></div>'

        return f'''
<div class="s fade">
  <div class="s-label">Option {num} {rank}</div>
  {img_tag}
  <div class="opt-title">{name}</div>
  <div class="opt-region">{region}</div>
  <p class="prose">{why}</p>
  {warn_html}
  {gallery_html}
  <div class="details-group">
    <details>
      <summary>Unterkunft — {opt.get("acc_price_range","")}</summary>
      <div class="det-body">{acc_rows}</div>
    </details>
    <details>
      <summary>Anreise — {opt.get("transport_summary","")}</summary>
      <div class="det-body">{transport_rows}</div>
    </details>
    <details>
      <summary>Budget — {opt.get("budget_label","")}</summary>
      <div class="det-body">{budget_rows}</div>
    </details>
  </div>
  <button class="deeper-btn" onclick="requestDeep(this,'{name}')">{deeper_label}</button>
</div>'''

    opts_html = '\n'.join(option_html(o, i) for i, o in enumerate(opts[:5]))
    # Embed gallery data as JSON for lightbox
    galleries_json = json.dumps(
        [[{k: v for k, v in g.items()} for g in o.get('gallery', [])] for o in opts[:5]],
        ensure_ascii=False
    )
    weeks_html = week_chips(weeks_used)

    next_steps = recs.get('next_steps', [
        'Option und Reisewoche festlegen',
        '2–3 konkrete Unterkünfte vergleichen',
        'Anreise buchen — Zug-Schlafwagen frühzeitig sichern'
    ])
    next_html = '\n'.join(f'<div class="step"><div class="sn">{i+1}</div><div>{s}</div></div>' for i, s in enumerate(next_steps))

    family_tags = ''
    musthave = d.get('musthave', [])
    lage = d.get('lage', [])
    if musthave:
        mh = ''.join(f'<span class="tag hi">{_TAG_LABELS.get(x,x)}</span>' for x in musthave)
        family_tags += f'<div class="p-row"><span class="p-lbl">Must-haves</span><div class="tags">{mh}</div></div>\n'
    if lage:
        lg = ''.join(f'<span class="tag hi">{_TAG_LABELS.get(x,x)}</span>' for x in lage)
        family_tags += f'<div class="p-row"><span class="p-lbl">Wünsche</span><div class="tags">{lg}</div></div>\n'
    for person, label in [('janno','Janno'),('thede','Thede'),('pina','Pina'),('mathias','Mathias'),('heidrun','Heidrun')]:
        vals = d.get(person, [])
        if vals:
            ts = ''.join(f'<span class="tag">{_TAG_LABELS.get(v,v)}</span>' for v in vals)
            family_tags += f'<div class="p-row"><span class="p-lbl">{label}</span><div class="tags">{ts}</div></div>\n'

    hero_sub = recs.get('hero_sub', f'5 Personen · 2 Wochen · ab Berlin · Budget {budget_str}')
    n_opts = len(opts)

    return f'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Watson Reisevorschlag — Mathias &amp; Familie</title>
<link rel="manifest" href="/sommerurlaub_angebot.webmanifest">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Reisevorschlag">
<meta name="theme-color" content="#0a0a0a">
<style>
*{{box-sizing:border-box;margin:0;padding:0;}}
:root{{
  --bg:#0c0c0c;--card:#141414;--border:#1e1e1e;--faint:#222;
  --text:#e8e4da;--muted:#666;--muted2:#444;
  --gold:#d4a843;--gold-dim:rgba(212,168,67,.13);
  --green:#4caf82;
  --r:12px;
  --font:system-ui,-apple-system,'Helvetica Neue',sans-serif;
}}
html{{background:var(--bg);color:var(--text);font-family:var(--font);}}
body{{max-width:600px;margin:0 auto;padding:env(safe-area-inset-top,20px) 22px calc(env(safe-area-inset-bottom,0px)+64px);}}
.hero{{padding:52px 0 32px;border-bottom:1px solid var(--border);margin-bottom:44px;}}
.hero-eye{{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;}}
.hero h1{{font-size:clamp(1.9rem,6.5vw,2.9rem);font-weight:700;line-height:1.08;letter-spacing:-.03em;margin-bottom:12px;}}
.hero h1 em{{font-style:normal;color:var(--gold);}}
.hero-sub{{font-size:14px;color:#888;line-height:1.6;}}
.s{{margin-bottom:52px;}}
.s-label{{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-bottom:20px;}}
.map-wrap{{margin-bottom:44px;border-radius:var(--r);overflow:hidden;border:1px solid var(--border);}}
.map-caption{{font-size:10px;color:var(--muted2);padding:7px 12px;border-top:1px solid var(--border);background:var(--card);}}
.opt-title{{font-size:21px;font-weight:700;line-height:1.15;letter-spacing:-.02em;margin-bottom:4px;}}
.opt-region{{font-size:12px;color:var(--muted);margin-bottom:18px;}}
.opt-photo{{width:100%;height:180px;object-fit:cover;display:block;border-radius:var(--r);margin-bottom:20px;background:var(--card);}}
.p-row{{display:flex;gap:10px;margin-bottom:8px;align-items:flex-start;}}
.p-lbl{{font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);width:76px;flex-shrink:0;padding-top:3px;}}
.tags{{display:flex;flex-wrap:wrap;gap:5px;}}
.tag{{font-size:11px;color:#888;background:var(--card);border:1px solid var(--border);border-radius:20px;padding:2px 9px;}}
.tag.hi{{background:var(--gold-dim);border-color:rgba(212,168,67,.3);color:var(--gold);}}
.timing-row{{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:16px;}}
.tw{{display:flex;flex-direction:column;align-items:center;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 11px;}}
.tw b{{font-size:12px;color:var(--text);margin-bottom:2px;}}
.tw small{{color:var(--muted);font-size:10px;}}
.tw.best{{background:rgba(76,175,130,.07);border-color:rgba(76,175,130,.25);}}
.tw.best b{{color:var(--green);}}
.prose{{font-size:15px;line-height:1.78;color:#bfbcb2;}}
.prose+.prose{{margin-top:14px;}}
.prose strong{{color:var(--text);font-weight:600;}}
.warn{{background:#12100a;border:1px solid #2e2410;border-radius:8px;padding:11px 14px;font-size:11px;color:#a09050;line-height:1.6;margin:16px 0 0;}}
.details-group{{margin-top:20px;border-top:1px solid var(--border);}}
details{{border-bottom:1px solid var(--border);}}
details summary{{cursor:pointer;padding:14px 0;font-size:12px;font-weight:700;color:var(--gold);letter-spacing:.03em;display:flex;justify-content:space-between;align-items:center;list-style:none;-webkit-appearance:none;user-select:none;}}
details summary::-webkit-details-marker{{display:none;}}
details summary::after{{content:'↓';font-size:11px;color:var(--muted);transition:transform .2s;}}
details[open]>summary::after{{transform:rotate(180deg);}}
.det-body{{padding:0 0 18px;}}
.d-row{{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;padding:8px 0;border-bottom:1px solid #1a1a1a;font-size:13px;}}
.d-row:last-of-type{{border-bottom:none;}}
.d-lbl{{color:#999;line-height:1.5;flex:1;}}
.d-lbl small{{display:block;font-size:11px;color:#555;margin-top:2px;}}
.d-val{{font-weight:700;color:var(--text);white-space:nowrap;text-align:right;flex-shrink:0;}}
.d-val.gold{{color:var(--gold);}}
.d-val.total{{font-size:15px;color:var(--green);}}
.src-link{{color:#4a7a9b;font-size:10px;text-decoration:none;margin-left:4px;white-space:nowrap;}}
.src-link:hover{{text-decoration:underline;}}
.humble{{font-size:10px;color:#3a3a3a;margin-top:10px;line-height:1.65;font-style:italic;}}
.deeper-btn{{display:block;width:100%;margin-top:16px;background:transparent;border:1px solid var(--border);border-radius:8px;padding:12px 16px;text-align:center;font-family:var(--font);font-size:12px;font-weight:700;color:var(--muted);cursor:pointer;letter-spacing:.04em;transition:border-color .2s,color .2s;}}
/* GALLERY */
.gallery-wrap{{margin:18px 0 20px;}}
.gallery-strip{{display:flex;gap:6px;overflow-x:auto;padding-bottom:6px;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;}}
.gallery-strip::-webkit-scrollbar{{display:none;}}
.gthumb{{height:80px;min-width:120px;max-width:160px;object-fit:cover;border-radius:6px;cursor:pointer;scroll-snap-align:start;flex-shrink:0;transition:opacity .15s;border:1px solid var(--border);}}
.gthumb:hover{{opacity:.85;border-color:rgba(212,168,67,.5);}}
.gallery-hint{{font-size:10px;color:var(--muted2);margin-top:5px;}}
/* LIGHTBOX */
#lb{{display:none;position:fixed;inset:0;background:rgba(0,0,0,.93);z-index:500;flex-direction:column;align-items:center;justify-content:center;padding:20px;touch-action:pan-y;}}
#lb.open{{display:flex;}}
#lb-img{{max-width:100%;max-height:60vh;object-fit:contain;border-radius:8px;display:block;}}
#lb-cap{{font-size:14px;color:var(--text);font-weight:600;margin-top:14px;text-align:center;line-height:1.4;max-width:480px;}}
#lb-cred{{font-size:10px;color:#555;margin-top:5px;text-align:center;}}
#lb-cred a{{color:#4a7a9b;text-decoration:none;}}
#lb-cred a:hover{{text-decoration:underline;}}
.lb-nav{{display:flex;gap:28px;margin-top:18px;}}
.lb-btn{{background:rgba(255,255,255,.06);border:1px solid #333;border-radius:50%;width:44px;height:44px;display:flex;align-items:center;justify-content:center;font-size:20px;color:#aaa;cursor:pointer;transition:background .15s,color .15s;flex-shrink:0;}}
.lb-btn:hover{{background:rgba(255,255,255,.12);color:var(--text);}}
.lb-close{{position:absolute;top:16px;right:18px;font-size:22px;color:#555;cursor:pointer;line-height:1;background:none;border:none;padding:4px;}}
.lb-close:hover{{color:var(--text);}}
.lb-counter{{font-size:10px;color:#444;margin-top:8px;letter-spacing:.1em;}}
.deeper-btn:hover{{border-color:rgba(212,168,67,.4);color:var(--gold);}}
.deeper-btn.done{{color:var(--green);border-color:rgba(76,175,130,.4);cursor:default;}}
.next{{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:22px;}}
.next-title{{font-size:12px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--gold);margin-bottom:16px;}}
.step{{display:flex;gap:11px;font-size:13px;color:#aaa;line-height:1.65;margin-bottom:11px;}}
.step:last-child{{margin-bottom:0;}}
.sn{{width:20px;height:20px;border-radius:50%;background:rgba(76,175,130,.1);border:1px solid rgba(76,175,130,.3);color:var(--green);font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:2px;}}
.footer{{text-align:center;padding:36px 0 16px;font-size:10px;color:#2a2a2a;line-height:1.9;}}
.footer a{{color:#3a3a3a;text-decoration:underline;}}
.fade{{opacity:0;transform:translateY(10px);transition:opacity .5s ease,transform .5s ease;}}
.fade.in{{opacity:1;transform:none;}}
.back-wrap{{margin-bottom:32px;}}
.back-btn{{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);text-decoration:none;padding:8px 14px;border:1px solid var(--border);border-radius:100px;transition:border-color .2s,color .2s;}}
.back-btn:hover{{border-color:rgba(212,168,67,.4);color:var(--gold);}}
.version-link{{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-bottom:1px solid var(--border);font-size:13px;color:#888;text-decoration:none;transition:color .2s;}}
.version-link::after{{content:'→';font-size:11px;color:var(--muted2);}}
.version-link:hover{{color:var(--text);}}
.version-link:last-child{{border-bottom:none;}}
.modal-backdrop{{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:100;align-items:center;justify-content:center;padding:24px;}}
.modal-backdrop.show{{display:flex;}}
.modal{{background:#161616;border:1px solid #2a2a2a;border-radius:16px;padding:24px;max-width:400px;width:100%;}}
.modal h3{{font-size:16px;font-weight:700;margin-bottom:10px;}}
.modal p{{font-size:13px;color:#aaa;line-height:1.65;margin-bottom:18px;}}
.modal-btn{{display:inline-flex;align-items:center;gap:6px;background:var(--gold);color:#0a0a0a;font-family:var(--font);font-size:13px;font-weight:700;padding:12px 22px;border-radius:100px;border:none;cursor:pointer;}}
.modal-close{{float:right;background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;line-height:1;}}
</style>
</head>
<body>

<div class="hero fade">
  <div class="hero-eye">Watson Rechercheauftrag · Sommer 2026</div>
  <h1>{n_opts} Vorschläge<br>für <em>Mathias &amp; Familie</em></h1>
  <p class="hero-sub">{hero_sub}</p>
</div>

<div class="back-wrap fade">
  <a href="/" class="back-btn">← Wünsche anpassen</a>
</div>

<div class="s fade">
  <div class="s-label">Was Watson gelesen hat</div>
  {family_tags}
</div>

<div class="s fade">
  <div class="s-label">Wann am besten</div>
  <div class="timing-row">
    {weeks_html}
  </div>
  <p class="prose">{recs.get("timing_note","")}</p>
</div>

{opts_html}

<div class="s fade">
  <div class="next">
    <div class="next-title">Was Watson als nächstes tun würde</div>
    {next_html}
  </div>
</div>

<div id="versions-section" class="s fade" style="display:none;">
  <div class="s-label">Frühere Vorschläge</div>
  <div id="versions-list"></div>
</div>

<div class="footer fade">
  Watson Rechercheauftrag · Preise recherchiert {generated_ts} · Alle Angaben Richtwerte<br>
  Fotos: Wikimedia Commons (CC)
</div>

<div class="modal-backdrop" id="modal">
  <div class="modal">
    <button class="modal-close" onclick="closeModal()">✕</button>
    <h3 id="modal-title">Tiefer recherchieren</h3>
    <p id="modal-body">Watson kann für diese Option konkrete Unterkünfte mit echten Preisen, verfügbare Flüge und Mietauto-Angebote heraussuchen. Das dauert ein paar Minuten.</p>
    <button class="modal-btn" id="modal-action">Jetzt recherchieren lassen →</button>
  </div>
</div>

<div id="deeper-overlay" style="display:none;position:fixed;inset:0;background:#0c0c0c;z-index:200;flex-direction:column;align-items:center;justify-content:center;padding:32px;">
  <div style="max-width:340px;width:100%;text-align:center;">
    <div style="font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:#666;margin-bottom:24px;">Watson Tiefenrecherche</div>
    <div id="deeper-dest" style="font-size:22px;font-weight:700;color:#d4a843;margin-bottom:8px;"></div>
    <div id="deeper-step" style="font-size:13px;color:#888;margin-bottom:28px;">Wird gestartet…</div>
    <div style="background:#1a1a1a;border-radius:100px;height:4px;overflow:hidden;">
      <div id="deeper-fill" style="height:100%;width:3%;background:#d4a843;border-radius:100px;transition:width .6s ease;"></div>
    </div>
  </div>
</div>

<script>
const obs = new IntersectionObserver(e => {{
  e.forEach(x => {{ if (x.isIntersecting) x.target.classList.add('in'); }});
}}, {{ threshold: .05, rootMargin: '0px 0px -20px 0px' }});
document.querySelectorAll('.fade').forEach(el => obs.observe(el));

let currentOption = null;
function requestDeep(btn, option) {{
  currentOption = option;
  document.getElementById('modal-title').textContent = `Tiefer recherchieren: ${{option}}`;
  document.getElementById('modal').classList.add('show');
  document.getElementById('modal-action').onclick = () => confirmDeep(btn, option);
}}
function closeModal() {{ document.getElementById('modal').classList.remove('show'); }}

async function confirmDeep(btn, option) {{
  closeModal();
  try {{
    await fetch('/api/sommerurlaub/deeper', {{
      method: 'POST',
      headers: {{ 'Content-Type': 'application/json' }},
      body: JSON.stringify({{ option, ts: new Date().toISOString() }})
    }});
  }} catch(e) {{}}
  const ov = document.getElementById('deeper-overlay');
  ov.style.display = 'flex';
  document.getElementById('deeper-dest').textContent = option;
  document.getElementById('deeper-step').textContent = 'Watson startet Tiefenrecherche…';
  try {{
    await fetch('/api/sommerurlaub/deeper_build', {{
      method: 'POST',
      headers: {{ 'Content-Type': 'application/json' }},
      body: JSON.stringify({{ option }})
    }});
  }} catch(e) {{}}
  pollDeeper();
}}

async function pollDeeper() {{
  try {{
    const r = await fetch('/api/sommerurlaub/deeper_status');
    const s = await r.json();
    if (s.progress) document.getElementById('deeper-fill').style.width = s.progress + '%';
    if (s.step) document.getElementById('deeper-step').textContent = s.step;
    if (s.status === 'done') {{
      document.getElementById('deeper-fill').style.width = '100%';
      document.getElementById('deeper-step').textContent = 'Fertig — Tiefenrecherche wartet auf euch.';
      setTimeout(() => {{ window.location.href = '/tief'; }}, 1200);
      return;
    }}
    if (s.status === 'error') {{
      document.getElementById('deeper-step').textContent = 'Fehler: ' + (s.error || 'unbekannt');
      return;
    }}
  }} catch(e) {{}}
  setTimeout(pollDeeper, 1500);
}}

document.getElementById('modal').addEventListener('click', e => {{
  if (e.target === document.getElementById('modal')) closeModal();
}});

(async () => {{
  try {{
    const r = await fetch('/api/sommerurlaub/versions');
    const j = await r.json();
    if (j.versions && j.versions.length > 0) {{
      document.getElementById('versions-section').style.display = '';
      document.getElementById('versions-list').innerHTML =
        j.versions.map(v => `<a class="version-link" href="/v/${{v.v}}">Vorschlag vom ${{v.label}}</a>`).join('');
    }}
  }} catch(e) {{}}
}})();

// Gallery lightbox
const GALLERIES = {galleries_json};
let _lbOpt = 0, _lbIdx = 0;
function openLb(opt, idx) {{
  _lbOpt = opt; _lbIdx = idx;
  _lbShow();
  document.getElementById('lb').classList.add('open');
  document.body.style.overflow = 'hidden';
}}
function _lbShow() {{
  const g = GALLERIES[_lbOpt];
  if (!g || !g[_lbIdx]) return;
  const item = g[_lbIdx];
  document.getElementById('lb-img').src = item.url;
  document.getElementById('lb-img').alt = item.alt;
  document.getElementById('lb-cap').textContent = item.caption;
  const cred = document.getElementById('lb-cred');
  cred.innerHTML = `Foto: <a href="${{item.credit_url}}" target="_blank" rel="noopener">${{item.credit_name}}</a> · <a href="${{item.photo_url}}" target="_blank" rel="noopener">Unsplash</a>`;
  document.getElementById('lb-counter').textContent = `${{_lbIdx + 1}} / ${{g.length}}`;
}}
function closeLb() {{
  document.getElementById('lb').classList.remove('open');
  document.body.style.overflow = '';
}}
function lbPrev() {{ const g = GALLERIES[_lbOpt]; if (!g) return; _lbIdx = (_lbIdx - 1 + g.length) % g.length; _lbShow(); }}
function lbNext() {{ const g = GALLERIES[_lbOpt]; if (!g) return; _lbIdx = (_lbIdx + 1) % g.length; _lbShow(); }}
document.addEventListener('keydown', e => {{
  if (!document.getElementById('lb').classList.contains('open')) return;
  if (e.key === 'ArrowLeft') lbPrev();
  else if (e.key === 'ArrowRight') lbNext();
  else if (e.key === 'Escape') closeLb();
}});
// Swipe on mobile
let _tsX = null;
document.getElementById('lb').addEventListener('touchstart', e => {{ _tsX = e.touches[0].clientX; }}, {{passive:true}});
document.getElementById('lb').addEventListener('touchend', e => {{
  if (_tsX === null) return;
  const dx = e.changedTouches[0].clientX - _tsX;
  _tsX = null;
  if (Math.abs(dx) > 40) {{ if (dx < 0) lbNext(); else lbPrev(); }}
}}, {{passive:true}});
</script>

<div id="lb">
  <button class="lb-close" onclick="closeLb()">✕</button>
  <img id="lb-img" src="" alt="">
  <div id="lb-cap"></div>
  <div id="lb-cred"></div>
  <div class="lb-nav">
    <button class="lb-btn" onclick="lbPrev()">‹</button>
    <div id="lb-counter" class="lb-counter"></div>
    <button class="lb-btn" onclick="lbNext()">›</button>
  </div>
</div>

</body>
</html>'''

def _su_build_prompt(d: dict) -> str:
    wochen = d.get('wochen', [])
    budget_min = d.get('budget_min', 0)
    budget_max = d.get('budget_max', d.get('budget', 7500))
    budget = budget_max
    budget_str = f'{budget_min:,.0f}–{budget_max:,.0f} €' if budget_min else f'{budget_max:,.0f} €'
    laender = d.get('ziel_laender', [])
    unterkunft = _su_tag(d.get('unterkunft', []))
    lage = _su_tag(d.get('lage', []))
    anreise = _su_tag(d.get('anreise', 'egal'))
    auto = _su_tag(d.get('auto', 'mieten'))
    musthave = _su_tag(d.get('musthave', []))
    wochen_str = ', '.join(_su_week_label(w) for w in sorted(wochen))
    laender_str = _su_tag(laender) if laender else 'Adria/Mittelmeer offen'
    kurzfristig = d.get('kurzfristig', False)
    kurzfristig_note = 'WICHTIG: Die Familie bucht kurzfristig. Zeige nur Optionen die jetzt noch realistisch buchbar sind. Last-Minute-Verfügbarkeit hat Vorrang vor Traumzielen. Preise können höher sein — das ist ok.' if kurzfristig else ''

    people = {
        'Janno': _su_tag(d.get('janno', [])),
        'Thede': _su_tag(d.get('thede', [])),
        'Pina': _su_tag(d.get('pina', [])),
        'Mathias': _su_tag(d.get('mathias', [])),
        'Heidrun': _su_tag(d.get('heidrun', [])),
    }
    people_str = '\n'.join(f'  - {name}: {vals}' for name, vals in people.items() if vals)

    return f"""Du bist ein Reiseplaner der eine konkrete Empfehlung für eine Familie erstellt.

Familie: 5 Personen (2 Erwachsene, 3 Kinder — Janno 14, Thede 11, Pina 7)
Abflugort: Berlin
Budget: {budget_str} gesamt für 2 Wochen (Unterkunft + Anreise + Mietauto + Essen/Ausflüge)
Zeitfenster: {wochen_str}
Zielregion: {laender_str}
Unterkunft-Typ: {unterkunft}
Lage-Wunsch: {lage}
Anreise-Präferenz: {anreise} · Auto vor Ort: {auto}
Must-haves: {musthave}
{kurzfristig_note}
Wünsche pro Person:
{people_str}

Erstelle 5 konkrete Reiseoptionen als JSON. Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, kein Markdown, kein Codeblock.

JSON-Schema:
{{
  "family_summary": "Kurze 1-Satz-Zusammenfassung der Familie und ihrer Wünsche",
  "hero_sub": "5 Personen · 2 Wochen · ab Berlin · Budget {budget:,} € · [Hauptregion]",
  "timing_note": "Ein Absatz (3-4 Sätze) über die beste Reisewoche basierend auf den gewählten Wochen und dem Ziel",
  "best_weeks": [Liste der empfohlenen KW-Nummern aus den gewählten Wochen],
  "next_steps": ["Schritt 1", "Schritt 2", "Schritt 3"],
  "options": [
    {{
      "name": "Destination Name + Titel",
      "region": "Region · Land",
      "iata": "Nächster Flughafen IATA-Code (z.B. SPU, FAO, LJU)",
      "tagline": "kurzer Slogan",
      "why_good": "2 Sätze Fließtext (reiner Text, kein HTML) warum diese Option gut für diese Familie ist",
      "not_perfect": "Falls es einen echten Vorbehalt gibt: ein kurzer Satz. Nur wenn wirklich relevant, sonst leer lassen.",
      "image_wikimedia_filename": "Exakter Dateiname auf Wikimedia Commons (z.B. Split_Croatia.jpg) — OHNE Special:FilePath, ohne Pfad, nur Dateiname+Extension. Nur echte Commons-Dateien angeben.",
      "transport_summary": "Nachtzug oder Direktflug",
      "transport": [
        {{
          "mode": "Nachtzug/Flug/Bus",
          "route": "Konkrete Strecke",
          "note": "Dauer, Details, Hinweis",
          "price_display": "~XXX €",
          "link_url": "https://...",
          "link_label": "seitenname.de"
        }}
      ],
      "transport_humble": "Hinweis dass Preise nicht live geprüft wurden",
      "acc_price_range": "XX–YY €/Nacht",
      "accommodation": [
        {{
          "type": "Unterkunfts-Typ + Beschreibung",
          "note": "Pers. · SZ · Ausstattung",
          "price_display": "XX–YY €/N",
          "links": [{{"url": "https://...", "label": "booking.com"}}]
        }}
      ],
      "acc_humble": "Hinweis dass Preise Richtwerte sind",
      "budget_label": "KW XX, Anreisetyp",
      "budget_breakdown": [
        {{"label": "Unterkunft 14 Nächte", "value": "X.XXX €"}},
        {{"label": "Anreise 5P", "value": "~XXX €"}},
        {{"label": "Mietauto 14 Tage", "value": "~XXX €"}},
        {{"label": "Essen, Ausflüge", "value": "~XXX €"}},
        {{"label": "Gesamt", "value": "~X.XXX €", "total": true}}
      ],
      "budget_humble": "Alle Angaben Richtwerte. X.XXX € Puffer vom Budget verbleiben.",
      "deeper_label": "Konkrete Apartments + Preise für [Name] recherchieren →"
    }}
  ]
}}

WICHTIGE REGELN:
1. Nur echte Orte, echte Fährverbindungen, echte Fluglinien — nichts erfinden
2. Preise als Richtwerte mit klaren Humbleness-Hinweisen — keine erfundenen Exakt-Preise
3. Wikimedia-Dateinamen: nur wenn du sicher bist dass die Datei existiert — sonst leer lassen
4. Links nur zu bekannten, echten Plattformen (booking.com, airbnb.com, nightjet.com, etc.)
5. Alle Texte auf Deutsch
6. Das Budget ({budget_str}) muss für alle 5 Optionen realistisch eingehalten werden
7. Auf die konkreten Personen (Janno 14, Thede 11, Pina 7, Mathias, Heidrun) eingehen
"""

def _su_build_thread(cockpit_dir: str):
    def _set(status=None, step=None, progress=None, error=None):
        with _SU_BUILD_LOCK:
            if status is not None: _SU_BUILD['status'] = status
            if step is not None: _SU_BUILD['step'] = step
            if progress is not None: _SU_BUILD['progress'] = progress
            if error is not None: _SU_BUILD['error'] = error

    data_file = Path(cockpit_dir) / 'sommerurlaub_feedback.json'
    out_file = Path(cockpit_dir) / 'sommerurlaub_angebot.html'

    try:
        _set(status='building', step='Fragebogen wird gelesen…', progress=5)
        d = json.loads(data_file.read_text(encoding='utf-8'))

        _set(step='Watson ruft Reise-Empfehlungen ab…', progress=20)
        api_key = _su_anthropic_key()
        if not api_key:
            _set(status='error', error='Kein Anthropic API Key gefunden')
            return

        prompt = _su_build_prompt(d)
        _set(step='Optionen werden zusammengestellt…', progress=45)
        raw = '{' + _su_call_claude(api_key, prompt, max_tokens=8000, prefill='{')

        _set(step='Empfehlungen werden verarbeitet…', progress=70)
        try:
            recs = _su_extract_json(raw)
        except Exception:
            # Compact fallback if full response was truncated
            _set(step='Kompakten Plan erstellen…', progress=55)
            raw = '{' + _su_call_claude(api_key, _su_build_prompt(d, compact=True), max_tokens=4000, prefill='{')
            recs = _su_extract_json(raw)

        # Archive old angebot before overwriting
        if out_file.exists():
            import datetime as _dt2
            versions_file = Path(cockpit_dir) / 'sommerurlaub_versions.json'
            try:
                vers = json.loads(versions_file.read_text(encoding='utf-8')) if versions_file.exists() else []
            except Exception:
                vers = []
            next_n = (vers[-1]['v'] + 1) if vers else 1
            archived_name = f'sommerurlaub_angebot_v{next_n:03d}.html'
            (Path(cockpit_dir) / archived_name).write_bytes(out_file.read_bytes())
            vers.append({
                'v': next_n,
                'ts': _dt2.datetime.now().isoformat(),
                'label': _dt2.datetime.now().strftime('%-d. %b %Y, %H:%M Uhr'),
                'file': archived_name
            })
            versions_file.write_text(json.dumps(vers, ensure_ascii=False, indent=2), encoding='utf-8')

        # Unsplash-Fotos + Galerien parallel abrufen
        def _fetch_photo(opt):
            name = opt.get('name', '') + ' ' + opt.get('region', '')
            opt['unsplash_url'] = _su_fetch_unsplash(name)
            opt['gallery'] = _su_fetch_gallery(name, d)
        import concurrent.futures as _cf
        with _cf.ThreadPoolExecutor(max_workers=5) as ex:
            list(ex.map(_fetch_photo, recs.get('options', [])))

        _set(step='Angebot wird gebaut…', progress=88)
        html = _su_generate_html(d, recs)

        out_file.write_text(html, encoding='utf-8')

        _set(status='done', step='Fertig — Angebot wartet auf euch.', progress=100)

    except Exception as ex:
        _set(status='error', step='', error=str(ex))
        print(f'[sommerurlaub_build] FEHLER: {ex}', flush=True)

def _su_deeper_prompt(option: str, d: dict) -> str:
    budget = d.get('budget', 7500)
    wochen = d.get('wochen', [])
    weeks_str = ', '.join(_su_week_label(w) for w in sorted(wochen))
    musthave = _su_tag(d.get('musthave', []))
    people = {
        'Janno (14)': _su_tag(d.get('janno', [])),
        'Thede (11)': _su_tag(d.get('thede', [])),
        'Pina (7)': _su_tag(d.get('pina', [])),
        'Mathias': _su_tag(d.get('mathias', [])),
        'Heidrun': _su_tag(d.get('heidrun', [])),
    }
    people_str = '\n'.join(f'  - {n}: {v}' for n, v in people.items() if v)
    return f"""Du bist ein Reiseplaner der eine detaillierte Tiefenrecherche für eine Familie erstellt.

ZIEL-OPTION: {option}
Familie: 5 Personen (2 Erw., 3 Kinder — Janno 14, Thede 11, Pina 7), ab Berlin
Budget gesamt: {budget:,.0f} € für 2 Wochen
Zeitfenster: {weeks_str}
Must-haves: {musthave}
Personen:
{people_str}

Erstelle eine detaillierte Recherche als JSON. Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, kein Markdown.

{{
  "destination": "{option}",
  "intro": "2-3 Sätze warum diese Option für diese Familie passt",
  "accommodations": [
    {{
      "name": "Typ + Lage (z.B. Ferienhaus in Brela, 150m vom Strand)",
      "area": "Ort/Stadtteil",
      "bedrooms": 3,
      "features": ["Klimaanlage", "Waschmaschine", "Parkplatz"],
      "description": "2-3 Sätze was diese Unterkunft für die Familie gut macht",
      "price_range": "~XXX–YYY €/Woche in KW XX",
      "booking_url": "https://www.booking.com/searchresults.html?... (echte Such-URL)",
      "booking_label": "booking.com",
      "airbnb_url": "https://www.airbnb.de/s/... (echte Such-URL)",
      "humble": "Preisangabe Richtwert — live prüfen"
    }}
  ],
  "flights": [
    {{
      "airline": "Airline-Name",
      "route": "BER → [Flughafen-Code]",
      "airport_dest": "Flughafen-Name (Kürzel)",
      "duration": "Xh XXmin",
      "frequency": "z.B. täglich",
      "price_5p_return": "~X.XXX € (5 Pers. hin+rück KW XX)",
      "book_url": "https://... (echte URL zur Airline oder Google Flights)",
      "book_label": "airline.com oder google.com/flights",
      "humble": "Preise nicht live geprüft — bitte direkt bei der Airline abrufen"
    }}
  ],
  "car_rental": {{
    "recommended_type": "z.B. VW Touran / Skoda Kodiaq",
    "seats": 5,
    "price_2weeks": "~XXX–YYY €",
    "tip": "Kurzer Hinweis (frühzeitig buchen, freie km etc.)",
    "search_url": "https://www.check24.de/mietwagen/... oder momondo",
    "search_label": "check24.de/mietwagen"
  }},
  "activities": [
    {{
      "for": "Janno",
      "title": "Aktivitäts-Titel",
      "description": "Kurze Beschreibung",
      "approx_cost": "~XX € pro Person"
    }}
  ],
  "budget_detail": {{
    "unterkunft_14n": "~X.XXX €",
    "anreise_5p": "~X.XXX €",
    "mietauto_14d": "~XXX €",
    "ausflüge": "~XXX €",
    "essen_täglich": "~XXX € (Ø pro Tag für 5P)",
    "gesamt": "~X.XXX €",
    "puffer": "~X.XXX € (vom Budget von {budget:,} €)",
    "humble": "Alle Angaben Richtwerte. Preise nicht live abgefragt."
  }},
  "best_week_for_option": "KW XX — kurze Begründung warum diese Woche für diesen Ort ideal ist",
  "one_concern": "Der eine echte Vorbehalt den man kennen muss (oder leer lassen)"
}}

REGELN:
1. Nur echte Orte, echte Fluglinien, echte Such-URLs (keine erfundenen URLs)
2. Booking.com und Airbnb Such-URLs: benutze die echten Such-Seiten (z.B. booking.com/searchresults mit ss= Parameter)
3. Preise als Richtwerte, immer humble note
4. 2-3 Unterkunfts-Optionen (verschiedene Typen/Lagen)
5. 1-2 Flug-Optionen
6. Aktivitäten: 3-5 Einträge insgesamt, für verschiedene Familienmitglieder
7. Alles auf Deutsch
"""

def _su_deeper_generate_html(option: str, data: dict, recs: dict, verify: dict = None, critique: dict = None) -> str:
    import datetime as _dt, urllib.parse as _up2
    generated_ts = _dt.datetime.now().strftime('%d.%m.%Y %H:%M')
    destination = recs.get('destination', option)
    intro = recs.get('intro', '')
    best_week = recs.get('best_week', recs.get('best_week_for_option', ''))
    one_concern = recs.get('one_concern', '')
    budget = data.get('budget', 7500)

    # Generate reliable booking URLs from destination + dates
    city = recs.get('city', destination.split('(')[0].split('&')[0].strip())
    iata = recs.get('iata', '')
    wochen = data.get('wochen', [])
    if wochen:
        ci_date = _dt.date.fromisocalendar(2026, min(wochen), 1)
    else:
        ci_date = _dt.date(2026, 7, 1)
    co_date = ci_date + _dt.timedelta(days=14)
    ci = ci_date.strftime('%Y-%m-%d')
    co = co_date.strftime('%Y-%m-%d')
    ci_c = ci_date.strftime('%Y%m%d')
    co_c = co_date.strftime('%Y%m%d')
    city_enc = _up2.quote(city)
    booking_url = f'https://www.booking.com/searchresults.de.html?ss={city_enc}&checkin={ci}&checkout={co}&group_adults=2&group_children=3&age=14&age=11&age=7&no_rooms=1&nflt=entire_homes%3D1'
    airbnb_url = f'https://www.airbnb.de/s/{city_enc}/homes?checkin={ci}&checkout={co}&adults=2&children=3'
    skyscanner_url = f'https://www.skyscanner.de/transport/flights/ber/{iata.lower()}/{ci_c}/{co_c}/?adults=2&children=3' if iata else ''
    gflights_url = f'https://www.google.com/travel/flights?q=Fl%C3%BCge+von+Berlin+nach+{city_enc}+am+{ci}' if city else ''
    nightjet_url = 'https://www.nightjet.com/de'
    bahn_url = f'https://www.bahn.de/buchung/fahrplan/suche#sts=true&so=Berlin%20Hbf&zo={city_enc}&hd={ci}'

    # Accommodations
    acc_cards = ''
    for a in recs.get('accommodations', []):
        feats = ' · '.join(a.get('features', []))
        links = (f'<a class="book-link" href="{booking_url}" target="_blank" rel="noopener">Booking.com ↗</a>'
                 f'<a class="book-link" href="{airbnb_url}" target="_blank" rel="noopener">Airbnb ↗</a>')
        humble = a.get('humble', '')
        acc_cards += f'''
<div class="card fade">
  <div class="card-head">
    <div>
      <div class="card-title">{a.get("name","")}</div>
      <div class="card-sub">{a.get("area","")} · {a.get("bedrooms",3)} Schlafzimmer</div>
    </div>
    <div class="card-price">{a.get("price_range","")}</div>
  </div>
  <p class="prose">{a.get("description","")}</p>
  <div class="feat-row">{feats}</div>
  <div class="card-links">{links}</div>
  {f'<div class="humble">{humble}</div>' if humble else ''}
</div>'''

    # Flights
    flight_rows = ''
    flight_links_html = ''
    if skyscanner_url:
        flight_links_html += f'<a class="book-link" href="{skyscanner_url}" target="_blank" rel="noopener">Skyscanner ↗</a>'
    if gflights_url:
        flight_links_html += f'<a class="book-link" href="{gflights_url}" target="_blank" rel="noopener">Google Flights ↗</a>'
    for f in recs.get('flights', []):
        humble = f.get('humble', '')
        flight_rows += f'''
<div class="d-row">
  <div class="d-lbl">
    {f.get("airline","")} · {f.get("route","")} · {f.get("duration","")}
    <small>{f.get("frequency","")} · {f.get("airport_dest","")}</small>
  </div>
  <div class="d-val gold">{f.get("price_5p_return", f.get("price_estimate",""))}</div>
</div>'''
    if flight_rows:
        first_humble = recs['flights'][0].get('humble','') if recs.get('flights') else ''
        if first_humble:
            flight_rows += f'<div class="humble">{first_humble}</div>'
    if flight_links_html:
        flight_rows += f'<div class="card-links" style="margin-top:10px">{flight_links_html}</div>'

    # Car rental
    car = recs.get('car_rental', {})
    car_html = ''
    if car:
        check24_url = f'https://mietwagen.check24.de/mietwagen/ergebnis/{city_enc}/{ci}/{co}/'
        car_link = f'<a class="book-link" href="{check24_url}" target="_blank" rel="noopener">Check24 ↗</a>'
        car_html = f'''
<div class="d-row">
  <div class="d-lbl">
    {car.get("recommended_type","")} · {car.get("seats",5)} Sitzplätze
    <small>{car.get("tip","")}</small>
  </div>
  <div class="d-val gold">{car.get("price_2weeks","")}</div>
</div>
<div class="card-links" style="margin-top:6px">{car_link}</div>'''

    # Trains
    train_rows = ''
    train_links_html = (f'<a class="book-link" href="{nightjet_url}" target="_blank" rel="noopener">Nightjet ↗</a>'
                        f'<a class="book-link" href="{bahn_url}" target="_blank" rel="noopener">Bahn.de ↗</a>')
    for tr in recs.get('trains', []):
        humble = tr.get('humble', '')
        train_rows += f'''
<div class="d-row">
  <div class="d-lbl">
    {tr.get("type","Zug")} · {tr.get("route","")} · {tr.get("duration","")}
    <small>{humble}</small>
  </div>
  <div class="d-val gold">{tr.get("price_5p_return", tr.get("price_estimate",""))}</div>
</div>'''
    if train_rows:
        train_rows += f'<div class="card-links" style="margin-top:6px">{train_links_html}</div>'

    # Data gaps
    gaps = recs.get('data_gaps', [])
    gaps_html = ''
    if gaps:
        items = ''.join(f'<div class="gap-item">? {g}</div>' for g in gaps[:4])
        gaps_html = f'<div class="data-gaps fade"><div class="s-label">Nicht recherchiert — bitte selbst prüfen</div>{items}</div>'

    # Activities
    act_rows = ''
    for act in recs.get('activities', []):
        act_rows += f'''
<div class="act-row fade">
  <div class="act-for">{act.get("for","")}</div>
  <div class="act-body">
    <div class="act-title">{act.get("title","")}</div>
    <div class="act-desc">{act.get("description","")}</div>
  </div>
  <div class="act-cost">{act.get("approx_cost","")}</div>
</div>'''

    # Budget — Tufte: Gesamtsumme prominent, Details eingeklappt
    bd = recs.get('budget_detail', {})
    budget_detail_rows = ''
    for label, key in [('Unterkunft 14 Nächte','unterkunft_14n'),('Anreise 5 Personen','anreise_5p'),
                       ('Mietauto 14 Tage','mietauto_14d'),('Ausflüge & Aktivitäten','ausflüge'),
                       ('Essen (Ø/Tag × 14)','essen_täglich')]:
        if bd.get(key):
            budget_detail_rows += f'<div class="d-row"><div class="d-lbl">{label}</div><div class="d-val">{bd[key]}</div></div>'
    if bd.get('puffer'):
        budget_detail_rows += f'<div class="d-row"><div class="d-lbl">Puffer</div><div class="d-val" style="color:var(--green)">{bd["puffer"]}</div></div>'
    if bd.get('humble'):
        budget_detail_rows += f'<div class="humble">{bd["humble"]}</div>'
    import re as _re_b
    _gesamt_raw = bd.get('gesamt', '')
    # Extract just the leading price (e.g. "~4.590 €" from verbose strings)
    _m = _re_b.match(r'(~?[\d.,]+\s*[€])', _gesamt_raw.strip())
    budget_gesamt = _m.group(1) if _m else ''
    if not budget_gesamt:
        # Fallback: sum line-item numbers
        total = 0
        for key in ('unterkunft_14n', 'anreise_5p', 'mietauto_14d', 'ausflüge', 'essen_täglich'):
            nums = _re_b.findall(r'\d[\d.]+', bd.get(key, '').replace('.', ''))
            if nums:
                try: total += int(nums[0])
                except: pass
        if total > 500:
            budget_gesamt = f'~{total:,} €'.replace(',', '.')

    concern_html = f'<div class="warn fade">⚠ {one_concern}</div>' if one_concern else ''
    verify_html = _su_verification_html(verify) if verify else ''
    red_team_html = _su_red_team_html(critique) if critique else ''
    booking_links_html = _su_booking_links_html(recs, data)

    return f'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Watson Tiefenrecherche — {destination}</title>
<link rel="manifest" href="/sommerurlaub_tief.webmanifest">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Tiefenrecherche">
<meta name="theme-color" content="#0a0a0a">
<style>
*{{box-sizing:border-box;margin:0;padding:0;}}
:root{{
  --bg:#0c0c0c;--card:#141414;--border:#1e1e1e;
  --text:#e8e4da;--muted:#666;--muted2:#444;
  --gold:#d4a843;--gold-dim:rgba(212,168,67,.13);
  --green:#4caf82;
  --r:12px;
  --font:system-ui,-apple-system,'Helvetica Neue',sans-serif;
}}
html{{background:var(--bg);color:var(--text);font-family:var(--font);}}
body{{max-width:600px;margin:0 auto;padding:env(safe-area-inset-top,20px) 22px calc(env(safe-area-inset-bottom,0px)+64px);}}
.hero{{padding:52px 0 32px;border-bottom:1px solid var(--border);margin-bottom:44px;}}
.hero-eye{{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:16px;}}
.hero h1{{font-size:clamp(1.9rem,6.5vw,2.8rem);font-weight:700;line-height:1.08;letter-spacing:-.03em;margin-bottom:12px;}}
.hero h1 em{{font-style:normal;color:var(--gold);}}
.hero-sub{{font-size:14px;color:#888;line-height:1.6;}}
.s{{margin-bottom:48px;}}
.s-label{{font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-bottom:20px;}}
.prose{{font-size:15px;line-height:1.78;color:#bfbcb2;}}
/* CARDS */
.card{{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:20px;margin-bottom:14px;}}
.card-head{{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:12px;}}
.card-title{{font-size:15px;font-weight:700;line-height:1.3;margin-bottom:3px;}}
.card-sub{{font-size:11px;color:var(--muted);}}
.card-price{{font-size:13px;font-weight:700;color:var(--gold);white-space:nowrap;text-align:right;flex-shrink:0;}}
.feat-row{{font-size:11px;color:#555;margin-top:10px;}}
.card-links{{margin-top:9px;display:flex;gap:8px;flex-wrap:wrap;}}
/* DETAIL ROWS */
.details-group{{border-top:1px solid var(--border);margin-top:0;}}
details{{border-bottom:1px solid var(--border);}}
details summary{{cursor:pointer;padding:14px 0;font-size:12px;font-weight:700;color:var(--gold);letter-spacing:.03em;display:flex;justify-content:space-between;align-items:center;list-style:none;-webkit-appearance:none;user-select:none;}}
details summary::-webkit-details-marker{{display:none;}}
details summary::after{{content:'↓';font-size:11px;color:var(--muted);transition:transform .2s;}}
details[open]>summary::after{{transform:rotate(180deg);}}
.det-body{{padding:0 0 18px;}}
.d-row{{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;padding:8px 0;border-bottom:1px solid #1a1a1a;font-size:13px;}}
.d-row:last-of-type{{border-bottom:none;}}
.d-lbl{{color:#999;line-height:1.5;flex:1;}}
.d-lbl small{{display:block;font-size:11px;color:#555;margin-top:2px;}}
.d-val{{font-weight:700;color:var(--text);white-space:nowrap;text-align:right;flex-shrink:0;}}
.d-val.gold{{color:var(--gold);}}
.d-val.total{{font-size:15px;color:var(--green);}}
.src-link{{color:#4a7a9b;font-size:10px;text-decoration:none;margin-left:4px;white-space:nowrap;}}
.src-link:hover{{text-decoration:underline;}}
.book-link{{display:inline-flex;align-items:center;font-size:11px;font-weight:600;color:#4a7a9b;text-decoration:none;padding:5px 11px;border:1px solid #1e3a4a;border-radius:100px;white-space:nowrap;transition:border-color .2s,color .2s;}}
.book-link:hover{{color:#7ab4d4;border-color:#2a5a74;}}
.humble{{font-size:10px;color:#3a3a3a;margin-top:10px;line-height:1.65;font-style:italic;}}
/* BUDGET HERO */
.budget-hero{{text-align:center;padding:28px 0 20px;}}
.budget-total{{font-size:2.6rem;font-weight:700;color:var(--gold);letter-spacing:-.04em;line-height:1;}}
.budget-label{{font-size:11px;color:var(--muted);margin-top:8px;}}
/* ACTIVITIES */
.act-row{{display:flex;gap:12px;padding:12px 0;border-bottom:1px solid #1a1a1a;align-items:flex-start;}}
.act-row:last-child{{border-bottom:none;}}
.act-for{{font-size:10px;font-weight:700;color:var(--gold);letter-spacing:.1em;text-transform:uppercase;width:56px;flex-shrink:0;padding-top:2px;}}
.act-body{{flex:1;}}
.act-title{{font-size:13px;font-weight:700;margin-bottom:3px;}}
.act-desc{{font-size:12px;color:#888;line-height:1.5;}}
.act-cost{{font-size:12px;color:var(--muted);white-space:nowrap;flex-shrink:0;text-align:right;}}
/* WARN */
.warn{{background:#12100a;border:1px solid #2e2410;border-radius:8px;padding:11px 14px;font-size:11px;color:#a09050;line-height:1.6;margin-bottom:24px;}}
/* BACK LINK */
.back{{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);text-decoration:none;padding:10px 0 30px;}}
.back:hover{{color:var(--text);}}
/* FOOTER */
.footer{{text-align:center;padding:36px 0 16px;font-size:10px;color:#2a2a2a;line-height:1.9;}}
.footer a{{color:#3a3a3a;text-decoration:underline;}}
.fade{{opacity:0;transform:translateY(10px);transition:opacity .5s ease,transform .5s ease;}}
.fade.in{{opacity:1;transform:none;}}
/* MOTORHAUBE */
.motorhaube{{margin-bottom:44px;border:1px solid #1e1e1e;border-radius:var(--r);}}
.motorhaube>summary{{cursor:pointer;padding:16px 20px;font-size:12px;font-weight:700;color:var(--muted);letter-spacing:.04em;display:flex;justify-content:space-between;align-items:center;list-style:none;-webkit-appearance:none;user-select:none;}}
.motorhaube>summary::-webkit-details-marker{{display:none;}}
.motorhaube>summary::after{{content:'↓';font-size:11px;color:var(--muted2);transition:transform .2s;}}
.motorhaube[open]>summary{{border-bottom:1px solid #1e1e1e;}}
.motorhaube[open]>summary::after{{transform:rotate(180deg);}}
.motorhaube-body{{padding:24px 20px;}}
/* VERIFICATION */
.verify-ok{{font-size:11px;color:#4caf82;margin-bottom:32px;padding:10px 14px;background:rgba(76,175,130,.07);border:1px solid rgba(76,175,130,.2);border-radius:8px;}}
.verify-section{{margin-bottom:44px;}}
.verify-note{{font-size:11px;color:#555;margin-bottom:18px;line-height:1.6;font-style:italic;}}
.vs-grid{{display:flex;flex-direction:column;gap:12px;}}
.vs-block{{background:#141414;border:1px solid #1e1e1e;border-radius:10px;padding:16px 18px;}}
.vs-head{{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}}
.vs-name{{font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:#aaa;}}
.vs-overall{{font-size:11px;font-weight:700;}}
.vs-summary{{font-size:11px;color:#777;margin-bottom:10px;line-height:1.55;font-style:italic;}}
.vf-row{{display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid #1a1a1a;font-size:12px;}}
.vf-row:last-child{{border-bottom:none;}}
.vf-icon{{font-weight:700;font-size:13px;flex-shrink:0;width:14px;}}
.vf-claim{{color:#888;line-height:1.4;}}
.vf-note{{font-size:10px;color:#555;margin-top:2px;line-height:1.5;}}
/* RED TEAM */
.rt-ok{{font-size:11px;color:#4caf82;margin-bottom:32px;padding:10px 14px;background:rgba(76,175,130,.07);border:1px solid rgba(76,175,130,.2);border-radius:8px;}}
.redteam-section{{margin-bottom:44px;}}
.rt-head{{display:flex;align-items:center;gap:10px;margin-bottom:16px;}}
.rt-verdict-badge{{font-size:11px;font-weight:700;letter-spacing:.06em;}}
.rt-note{{font-size:11px;color:#444;font-style:italic;}}
.rt-issue{{display:flex;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid #1a1a1a;}}
.rt-issue:last-of-type{{border-bottom:none;}}
.rt-sev{{font-size:14px;flex-shrink:0;width:18px;}}
.rt-claim{{font-size:12px;color:#888;line-height:1.4;margin-bottom:3px;font-style:italic;}}
.rt-issue-text{{font-size:12px;line-height:1.45;}}
.rt-suggestion{{font-size:11px;color:#555;margin-top:3px;}}
.rt-sub-title{{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin:16px 0 8px;}}
.rt-trap{{font-size:12px;color:#a09050;padding:5px 0;border-bottom:1px solid #1a1a1a;}}
.rt-trap:last-child{{border-bottom:none;}}
.rt-missing-item{{font-size:12px;color:#666;padding:5px 0;border-bottom:1px solid #1a1a1a;}}
.rt-missing-item:last-child{{border-bottom:none;}}
.rt-positive{{font-size:12px;color:#4caf82;margin-top:14px;padding:10px 14px;background:rgba(76,175,130,.05);border-radius:6px;}}
/* DATA GAPS */
.data-gaps{{margin-bottom:44px;}}
.gap-item{{font-size:12px;color:#444;padding:5px 0;border-bottom:1px solid #161616;}}
.gap-item:last-child{{border-bottom:none;}}
/* BOOKING LINKS */
.booking-links-section{{margin-bottom:44px;}}
.bl-note-top{{font-size:11px;color:#444;margin-bottom:14px;line-height:1.6;font-style:italic;}}
.bl-grid{{display:flex;flex-direction:column;gap:10px;}}
.bl-card{{display:flex;align-items:center;gap:14px;background:#141414;border:1px solid #1e1e1e;border-radius:10px;padding:14px 16px;text-decoration:none;color:var(--text);transition:border-color .2s;}}
.bl-card:hover{{border-color:rgba(212,168,67,.4);}}
.bl-icon{{font-size:20px;flex-shrink:0;width:28px;text-align:center;}}
.bl-body{{flex:1;}}
.bl-name{{font-size:13px;font-weight:700;margin-bottom:2px;}}
.bl-note{{font-size:11px;color:#555;}}
.bl-arrow{{font-size:14px;color:var(--gold);flex-shrink:0;}}
</style>
</head>
<body>

<a class="back" href="/angebot">← Zurück zur Übersicht</a>

<div class="hero fade">
  <div class="hero-eye">Watson Tiefenrecherche · Sommer 2026</div>
  <h1><em>{destination}</em><br>im Detail</h1>
  <p class="hero-sub">5 Personen · 2 Wochen · ab Berlin · Budget {budget:,.0f} €</p>
</div>

<div class="s fade">
  <p class="prose">{intro}</p>
  {'<p class="prose" style="margin-top:14px;font-size:13px;color:#888;">📅 ' + best_week + '</p>' if best_week else ''}
</div>

{concern_html}

<div class="s">
  <div class="s-label">Unterkünfte</div>
  {acc_cards}
</div>

<div class="s">
  <div class="s-label">Anreise</div>
  <div class="details-group">
    <details open>
      <summary>Flüge ab Berlin</summary>
      <div class="det-body">{flight_rows}</div>
    </details>
    {'<details><summary>Nachtzug / Zug</summary><div class="det-body">' + train_rows + '</div></details>' if train_rows else ''}
    {'<details><summary>Mietauto vor Ort</summary><div class="det-body">' + car_html + '</div></details>' if car_html else ''}
  </div>
</div>

<div class="s">
  <div class="s-label">Aktivitäten für alle</div>
  <div style="border-top:1px solid var(--border);">{act_rows}</div>
</div>

<div class="s">
  <div class="s-label">Gesamtbudget</div>
  {f'<div class="budget-hero"><div class="budget-total">{budget_gesamt}</div><div class="budget-label">Geschätzte Gesamtkosten · 5 Personen · 2 Wochen</div></div>' if budget_gesamt else ''}
  <div class="details-group">
    <details>
      <summary>Aufschlüsselung anzeigen</summary>
      <div class="det-body">{budget_detail_rows}</div>
    </details>
  </div>
</div>

{booking_links_html}

{'<details class="motorhaube"><summary>Der Blick unter die Motorhaube — wie diese Empfehlung entstand</summary><div class="motorhaube-body">' + verify_html + red_team_html + '</div></details>' if (verify_html or red_team_html) else ''}

<div class="footer fade">
  Watson Tiefenrecherche · Erstellt {generated_ts} · Alle Angaben Richtwerte · Live-Daten: Perplexity · Verifikation: ChatGPT Red Team + Gemini + Claude
</div>

<script>
const obs = new IntersectionObserver(e => {{
  e.forEach(x => {{ if (x.isIntersecting) x.target.classList.add('in'); }});
}}, {{ threshold: .05 }});
document.querySelectorAll('.fade').forEach(el => obs.observe(el));
</script>
</body>
</html>'''

def _su_deeper_thread(cockpit_dir: str, option: str):
    def _set(**kw):
        with _SU_DEEPER_LOCK:
            _SU_DEEPER.update(kw)

    data_file = Path(cockpit_dir) / 'sommerurlaub_feedback.json'
    out_file = Path(cockpit_dir) / 'sommerurlaub_tief.html'

    try:
        _set(status='building', step='Fragebogen wird gelesen…', progress=5, option=option)
        d = json.loads(data_file.read_text(encoding='utf-8')) if data_file.exists() else {}

        # Phase 1: Perplexity gathers live data (5 parallel queries)
        _set(step=f'Perplexity sucht Live-Daten für {option}…', progress=12)
        research_brief = _su_perplexity_gather(option, d)

        # Phase 2: Claude structures from Perplexity data only
        _set(step='Watson strukturiert Plan aus Live-Daten…', progress=40)
        api_key = _su_anthropic_key()
        if not api_key:
            _set(status='error', error='Kein Anthropic API Key')
            return

        # Trim brief: too long = less output capacity for Claude
        brief_trimmed = research_brief[:6000] if len(research_brief) > 6000 else research_brief
        struct_prompt = _su_claude_structure_prompt(brief_trimmed, d, option)
        raw_plan = _su_call_claude(api_key, struct_prompt, max_tokens=8000)

        _set(step='Plan wird verarbeitet…', progress=62)
        try:
            plan = _su_extract_json(raw_plan)
        except Exception as _je:
            # Retry with compact prompt — fewer fields, shorter descriptions
            print(f'[sommerurlaub_json] JSON-Fehler, Retry mit kompaktem Prompt: {_je}', flush=True)
            _set(step='Plan wird neu strukturiert (kompakt)…', progress=65)
            compact_prompt = _su_claude_structure_prompt(brief_trimmed[:3000], d, option, compact=True)
            raw_plan2 = _su_call_claude(api_key, compact_prompt, max_tokens=4000)
            plan = _su_extract_json(raw_plan2)

        # Phase 3: ChatGPT Red Team attacks the plan
        _set(step='Red Team (ChatGPT) prüft den Plan kritisch…', progress=72)
        critique = {}
        try:
            openai_key = _su_api_key('openai')
            if openai_key:
                rt_prompt = _su_chatgpt_red_team_prompt(
                    json.dumps(plan, ensure_ascii=False)[:3000], research_brief, option)
                raw_critique = _su_call_openai(openai_key, rt_prompt, max_tokens=1500)
                critique = _su_extract_json(raw_critique)
        except Exception as rtex:
            print(f'[sommerurlaub_redteam] WARNUNG: {rtex}', flush=True)

        # Phase 4: Fact verification (Perplexity + Gemini + Claude cross-check)
        _set(step='Fakten-Verifikation (Perplexity + Gemini + Claude)…', progress=85)
        budget = d.get('budget_max', d.get('budget', 7500))
        verify = {}
        try:
            verify = _su_run_verification(plan, option, budget)
        except Exception as vex:
            print(f'[sommerurlaub_verify] WARNUNG: {vex}', flush=True)

        _set(step='Seite wird gebaut…', progress=96)
        html = _su_deeper_generate_html(option, d, plan, verify=verify, critique=critique)
        out_file.write_text(html, encoding='utf-8')

        _set(status='done', step='Tiefenrecherche + Red Team + Verifikation fertig.', progress=100)

    except Exception as ex:
        _set(status='error', step='', error=str(ex))
        print(f'[sommerurlaub_deeper] FEHLER: {ex}', flush=True)

# ── Sancho Spawns ──────────────────────────────────────────────────────
_SPAWNS_CACHE: dict = {}   # {'ts': float, 'data': dict}
_SPAWNS_LOCK = threading.Lock()
_SPAWNS_CACHE_TTL = 60     # 60 Sekunden

def _spawns_get() -> dict:
    """Liest alle JSONL-Sessions unter ~/.claude/projects/ die in den letzten 24h geändert wurden.
    Gibt Spawns-Liste + Summary zurück. Crasht nie."""
    import time as _time
    with _SPAWNS_LOCK:
        now = _time.time()
        if _SPAWNS_CACHE and (now - _SPAWNS_CACHE.get('ts', 0)) < _SPAWNS_CACHE_TTL:
            return _SPAWNS_CACHE.get('data', {})

    try:
        result = _spawns_fetch()
    except Exception as _exc:
        print(f'[spawns] Fehler: {_exc}', flush=True)
        result = {'spawns': [], 'summary': {'haiku': 0, 'sonnet': 0, 'opus': 0, 'unknown': 0}, 'total': 0}

    with _SPAWNS_LOCK:
        _SPAWNS_CACHE['ts'] = _time.time()
        _SPAWNS_CACHE['data'] = result
    return result


def _spawns_fetch() -> dict:
    """Durchsucht ~/.claude/projects/ nach JSONL-Dateien der letzten 24h."""
    import glob as _glob
    import time as _time

    cutoff = _time.time() - 86400  # 24 Stunden
    projects_root = Path.home() / '.claude' / 'projects'

    if not projects_root.exists():
        return {'spawns': [], 'summary': {'haiku': 0, 'sonnet': 0, 'opus': 0, 'unknown': 0}, 'total': 0}

    spawns = []

    for jsonl_path in projects_root.rglob('*.jsonl'):
        try:
            if jsonl_path.stat().st_mtime < cutoff:
                continue
        except OSError:
            continue

        # Projekt-Pfad aus Ordnerstruktur ableiten
        try:
            rel = jsonl_path.relative_to(projects_root)
            parts = rel.parts
            project_raw = parts[0] if parts else 'unknown'
            # Ordnername ist URL-kodiert (z.B. -Users-victorholland-Vibe-Coding-dispatcher)
            # Letztes Segment nach letztem Bindestrich-Cluster als lesbaren Namen extrahieren
            project_name = project_raw.replace('-', '/').lstrip('/').split('/')[-1] or project_raw
            is_subagent = len(parts) >= 2 and parts[1] == 'subagents'
            session_id = jsonl_path.stem
        except Exception:
            project_name = 'unknown'
            is_subagent = False
            session_id = jsonl_path.stem

        # Ersten Eintrag und ersten assistant-Eintrag lesen
        timestamp_str = ''
        model_raw = ''
        try:
            with open(jsonl_path, 'r', encoding='utf-8', errors='replace') as _f:
                for i, line in enumerate(_f):
                    if i > 20:
                        break
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        entry = json.loads(line)
                    except (json.JSONDecodeError, ValueError):
                        continue
                    # Zeitstempel aus erstem Eintrag
                    if not timestamp_str:
                        ts_raw = entry.get('timestamp', '')
                        if ts_raw:
                            timestamp_str = ts_raw
                    # Modell aus erstem assistant-Eintrag
                    if not model_raw:
                        msg = entry.get('message', {})
                        if isinstance(msg, dict) and msg.get('role') == 'assistant':
                            model_raw = msg.get('model', '')
        except (OSError, IOError):
            pass

        # Uhrzeit formatieren
        time_display = ''
        if timestamp_str:
            try:
                import datetime as _dt
                # ISO-Format: 2026-05-27T14:23:01.123Z oder ähnlich
                ts_clean = timestamp_str[:19].replace('T', ' ')
                dt = _dt.datetime.fromisoformat(ts_clean)
                time_display = dt.strftime('%H:%M')
            except Exception:
                time_display = timestamp_str[:5]

        # Modell-Tier bestimmen
        model_lower = model_raw.lower()
        if 'haiku' in model_lower:
            tier = 'haiku'
        elif 'opus' in model_lower:
            tier = 'opus'
        elif 'sonnet' in model_lower or model_lower:
            tier = 'sonnet' if model_lower else 'unknown'
        else:
            tier = 'unknown'

        spawns.append({
            'time': time_display,
            'model': model_raw or 'unknown',
            'tier': tier,
            'project': project_name,
            'id': session_id[:12],
            'is_subagent': is_subagent,
            'mtime': jsonl_path.stat().st_mtime if jsonl_path.exists() else 0,
        })

    # Nach mtime sortieren, neueste zuerst
    spawns.sort(key=lambda x: x.get('mtime', 0), reverse=True)
    # mtime aus Rückgabe entfernen
    for s in spawns:
        s.pop('mtime', None)

    summary = {'haiku': 0, 'sonnet': 0, 'opus': 0, 'unknown': 0}
    for s in spawns:
        t = s['tier']
        if t in summary:
            summary[t] += 1
        else:
            summary['unknown'] += 1

    return {'spawns': spawns, 'summary': summary, 'total': len(spawns)}

def _anthropic_admin_key() -> str:
    """Liest Admin-Key aus macOS Keychain. Gibt '' zurück wenn nicht gefunden."""
    try:
        r = subprocess.run(
            ['security', 'find-generic-password', '-s', 'anthropic-admin', '-a', 'dispatcher-usage', '-w'],
            capture_output=True, text=True, timeout=5
        )
        return r.stdout.strip() if r.returncode == 0 else ''
    except Exception:
        return ''

def _anthropic_costs_fetch(api_key: str) -> list:
    """Ruft Anthropic Cost Report API ab, paginiert. Gibt aggregierte Modell-Kosten zurück."""
    import urllib.request as _ureq
    from datetime import date as _date

    today = _date.today()
    start = today.replace(day=1).isoformat()
    base_url = f'https://api.anthropic.com/v1/organizations/cost_report?starting_at={start}&group_by[]=description'
    hdrs = {'x-api-key': api_key, 'anthropic-version': '2023-06-01'}

    # Paginierung + Aggregation pro Modell (Beträge in Cent → USD)
    model_totals: dict = {}
    page = None
    try:
        while True:
            url = base_url + (f'&page={page}' if page else '')
            req = _ureq.Request(url, headers=hdrs)
            with _ureq.urlopen(req, timeout=15) as resp:
                raw = json.loads(resp.read().decode())
            for entry in raw.get('data', []):
                for res in (entry.get('results') or []):
                    model = res.get('model') or 'unknown'
                    try:
                        cents = float(res.get('amount', 0))
                    except (TypeError, ValueError):
                        cents = 0.0
                    model_totals[model] = model_totals.get(model, 0.0) + cents
            if not raw.get('has_more') or not raw.get('next_page'):
                break
            page = raw['next_page']
    except Exception as exc:
        print(f'[anthropic_costs] API-Fehler: {exc}', flush=True)

    # Umrechnung Cent → USD, Modell-Namen vereinfachen
    result = []
    for model, cents in model_totals.items():
        tier = 'haiku' if 'haiku' in model.lower() else ('opus' if 'opus' in model.lower() else 'sonnet')
        result.append({'model': model, 'tier': tier, 'cost_usd': round(cents / 100, 4)})
    result.sort(key=lambda x: x['cost_usd'], reverse=True)
    return result

def _anthropic_costs_get() -> dict:
    """Gibt gecachte oder frisch abgerufene Kostendaten zurück."""
    import time as _time
    with _ANTHROPIC_COSTS_LOCK:
        now = _time.time()
        if _ANTHROPIC_COSTS_CACHE and (now - _ANTHROPIC_COSTS_CACHE.get('ts', 0)) < _ANTHROPIC_CACHE_TTL:
            return {'data': _ANTHROPIC_COSTS_CACHE['data'], 'cached': True}

    api_key = _anthropic_admin_key()
    if not api_key:
        return {'error': 'no_key', 'data': []}

    data = _anthropic_costs_fetch(api_key)
    with _ANTHROPIC_COSTS_LOCK:
        _ANTHROPIC_COSTS_CACHE['ts'] = _time.time()
        _ANTHROPIC_COSTS_CACHE['data'] = data
    return {'data': data, 'cached': False}

def _rd_token() -> str | None:
    r = subprocess.run(['security', 'find-generic-password', '-s', 'realdebrid', '-a', 'api_key', '-w'],
                       capture_output=True, text=True)
    return r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else None

def _rd_save_token(token: str) -> None:
    subprocess.run(['security', 'delete-generic-password', '-s', 'realdebrid', '-a', 'api_key'],
                   capture_output=True)
    subprocess.run(['security', 'add-generic-password', '-s', 'realdebrid', '-a', 'api_key', '-w', token],
                   capture_output=True)

def _rd_api(method: str, endpoint: str, data: dict | None = None, token: str | None = None):
    import urllib.request as _ureq, urllib.parse as _uparse
    url = RD_API_BASE + endpoint
    headers = {'Authorization': f'Bearer {token or _rd_token() or ""}'}
    body = None
    if data:
        body = _uparse.urlencode(data).encode()
        headers['Content-Type'] = 'application/x-www-form-urlencoded'
    req = _ureq.Request(url, data=body, headers=headers, method=method)
    with _ureq.urlopen(req, timeout=20) as resp:
        raw = resp.read().decode()
        return json.loads(raw) if raw.strip() else {}

def _rd_target_folder(folder: str) -> Path:
    target = Path(folder).expanduser()
    if target.is_absolute():
        return target
    return WATSON_VOICES_DIR / 'raw_sources' / target

def _extract_german_audio(video_path: Path, dest_folder: Path) -> str | None:
    import subprocess as _sp, json as _json
    try:
        probe = _sp.run(
            ['/opt/homebrew/bin/ffprobe', '-v', 'quiet', '-print_format', 'json',
             '-show_streams', str(video_path)],
            capture_output=True, text=True, timeout=60
        )
        if probe.returncode != 0:
            return None
        streams = _json.loads(probe.stdout).get('streams', [])
        audio_streams = [s for s in streams if s.get('codec_type') == 'audio']
        audio_idx = next(
            (i for i, s in enumerate(audio_streams)
             if s.get('tags', {}).get('language', '').lower() in ('ger', 'deu', 'de')),
            None
        )
        if audio_idx is None:
            return None
        out_path = dest_folder / (video_path.stem + '_ger.flac')
        res = _sp.run(
            ['/opt/homebrew/bin/ffmpeg', '-y', '-i', str(video_path),
             '-map', f'0:a:{audio_idx}', '-vn', '-c:a', 'flac', str(out_path)],
            capture_output=True, text=True, timeout=7200
        )
        return str(out_path) if res.returncode == 0 and out_path.exists() else None
    except Exception:
        return None


def _jackett_session_cookie() -> str:
    """Get (or refresh) Jackett session cookie."""
    import urllib.request as _ureq
    with _JACKETT_LOCK:
        if _JACKETT_SESSION['cookie']:
            return _JACKETT_SESSION['cookie']
        # Trigger redirect to get TestCookie, then follow to Dashboard
        opener = _ureq.build_opener(_ureq.HTTPCookieProcessor())
        try:
            opener.open(f'{JACKETT_URL}/UI/Dashboard', timeout=10)
        except Exception:
            pass
        # Cookie jar now has the session; extract it
        import http.cookiejar as _cj
        jar = _cj.CookieJar()
        opener2 = _ureq.build_opener(_ureq.HTTPCookieProcessor(jar))
        try:
            opener2.open(f'{JACKETT_URL}/UI/Dashboard', timeout=10)
        except Exception:
            pass
        cookies = '; '.join(f'{c.name}={c.value}' for c in jar)
        _JACKETT_SESSION['cookie'] = cookies
        return cookies


def _jackett_search(query: str, cat: str = '2000') -> list:
    """Search Jackett via Torznab 'all' indexer. Returns list of dicts."""
    import urllib.request as _ureq, urllib.parse as _up, xml.etree.ElementTree as ET
    params = _up.urlencode({'apikey': JACKETT_KEY, 't': 'search', 'q': query, 'cat': cat})
    url = f'{JACKETT_URL}/api/v2.0/indexers/all/results/torznab?{params}'
    req = _ureq.Request(url)
    try:
        with _ureq.urlopen(req, timeout=30) as r:
            content = r.read().decode('utf-8', errors='replace')
    except Exception as e:
        return []
    try:
        root = ET.fromstring(content)
    except ET.ParseError:
        return []
    ns = 'http://torznab.com/schemas/2015/feed'
    results = []
    for item in root.findall('.//item'):
        def _t(tag):
            el = item.find(tag)
            return el.text if el is not None else ''
        def _attr(name):
            el = item.find(f'{{{ns}}}attr[@name="{name}"]')
            return el.get('value', '') if el is not None else ''
        title    = _t('title')
        link     = _t('link')
        size_str = _attr('size') or _t('size')
        seeds    = _attr('seeders')
        indexer  = _attr('jackettindexer') or ''
        # magnet is in enclosure or link; Jackett proxy URLs 302 → magnet
        enc = item.find('enclosure')
        magnet = ''
        torrent_url = ''
        _candidates = []
        if enc is not None:
            _candidates.append(enc.get('url', ''))
        if link:
            _candidates.append(link)
        for _val in _candidates:
            if not _val:
                continue
            if _val.startswith('magnet:'):
                magnet = _val
                break
            if _val.startswith('http') and not magnet:
                # Follow Jackett proxy 302 redirect to get magnet
                try:
                    import urllib.request as _ur2
                    class _NR(_ur2.HTTPRedirectHandler):
                        def redirect_request(self, *a, **kw): return None
                    _opener = _ur2.build_opener(_NR())
                    try:
                        _opener.open(_val, timeout=5)
                    except _ur2.HTTPError as _he:
                        _loc = _he.headers.get('Location', '')
                        if _loc.startswith('magnet:'):
                            magnet = _loc
                            break
                except Exception:
                    pass
                if not magnet:
                    torrent_url = _val
        try:
            size_mb = round(int(size_str) / 1024 / 1024)
        except Exception:
            size_mb = 0
        results.append({
            'title': title,
            'magnet': magnet,
            'torrent_url': torrent_url,
            'size_mb': size_mb,
            'seeds': seeds,
            'indexer': indexer,
        })
    return results


def _sabnzbd_api(mode: str, extra: dict = None) -> dict:
    import urllib.request as _u, urllib.parse as _up
    params = {'mode': mode, 'apikey': SABNZBD_KEY, 'output': 'json'}
    if extra:
        params.update(extra)
    url = f'{SABNZBD_URL}/api?{_up.urlencode(params)}'
    try:
        with _u.urlopen(url, timeout=8) as r:
            return json.loads(r.read())
    except Exception as e:
        return {'error': str(e)}


def _nzbgeek_search(query: str) -> list:
    import urllib.request as _u, urllib.parse as _up, xml.etree.ElementTree as ET
    cfg = {}
    if NZBGEEK_CFG.exists():
        cfg = json.loads(NZBGEEK_CFG.read_text())
    api_key = cfg.get('api_key', '')
    if not api_key:
        return []
    params = _up.urlencode({'t': 'search', 'q': query, 'apikey': api_key, 'cat': '2000'})
    url = f'https://api.nzbgeek.info/api?{params}'
    try:
        req = _u.Request(url, headers={'User-Agent': 'SABnzbd'})
        with _u.urlopen(req, timeout=15) as r:
            root = ET.fromstring(r.read())
        ns = {'torznab': 'http://torznab.com/schemas/2015/feed'}
        results = []
        for item in root.findall('.//item'):
            title   = item.findtext('title', '')
            link    = item.findtext('link', '')
            size_el = item.find('.//torznab:attr[@name="size"]', ns)
            seeds_el = item.find('.//torznab:attr[@name="seeders"]', ns)
            size_mb = round(int(size_el.get('value', 0)) / 1024 / 1024) if size_el is not None else 0
            seeds   = int(seeds_el.get('value', 0)) if seeds_el is not None else 0
            results.append({'title': title, 'nzb_url': link, 'size_mb': size_mb, 'seeds': seeds})
        return sorted(results, key=lambda x: -x['seeds'])
    except Exception as e:
        return [{'error': str(e)}]


def _ytdlp_worker(job_id: str, url: str, audio_only: bool, out_dir: Path) -> None:
    import subprocess as _sp
    def _upd(**kw):
        with _YTDLP_LOCK:
            _YTDLP_JOBS[job_id].update(kw)
    _upd(status='downloading')
    out_dir.mkdir(parents=True, exist_ok=True)
    cmd = ['/opt/homebrew/bin/yt-dlp', '--no-playlist']
    if audio_only:
        cmd += ['-x', '--audio-format', 'wav', '--audio-quality', '0']
    cmd += ['-o', str(out_dir / '%(title)s.%(ext)s'), url]
    try:
        proc = _sp.Popen(cmd, stdout=_sp.PIPE, stderr=_sp.STDOUT, text=True)
        lines = []
        for line in proc.stdout:
            line = line.rstrip()
            lines.append(line)
            if '[download]' in line and '%' in line:
                _upd(progress=line.strip())
        proc.wait()
        if proc.returncode == 0:
            _upd(status='done', progress='100%', log='\n'.join(lines[-5:]))
        else:
            _upd(status='error', error='\n'.join(lines[-5:]))
    except Exception as e:
        _upd(status='error', error=str(e))


def _audible_library() -> list:
    import subprocess as _sp
    if not AUDIBLE_CLI.exists():
        return []
    try:
        r = _sp.run([str(AUDIBLE_CLI), 'library', 'list', '--format', 'tsv'],
                    capture_output=True, text=True, timeout=30)
        books = []
        for line in r.stdout.splitlines()[1:]:  # skip header
            parts = line.split('\t')
            if len(parts) >= 3:
                books.append({'asin': parts[0], 'title': parts[1], 'author': parts[2] if len(parts) > 2 else ''})
        return books
    except Exception:
        return []


def _audible_download_worker(job_id: str, asin: str) -> None:
    import subprocess as _sp
    def _upd(**kw):
        with _YTDLP_LOCK:
            _YTDLP_JOBS[job_id].update(kw)
    _upd(status='downloading')
    STIMMEN_AUDIBLE.mkdir(parents=True, exist_ok=True)
    try:
        r = _sp.run(
            [str(AUDIBLE_CLI), 'download', '--asin', asin, '--output-dir', str(STIMMEN_AUDIBLE),
             '--pdf', 'False', '--annotation', 'False'],
            capture_output=True, text=True, timeout=300
        )
        if r.returncode == 0:
            # Also extract audio to wav
            aax_files = list(STIMMEN_AUDIBLE.glob('*.aax')) + list(STIMMEN_AUDIBLE.glob('*.m4b'))
            latest = max(aax_files, key=lambda f: f.stat().st_mtime) if aax_files else None
            if latest:
                # Get activation bytes
                rb = _sp.run([str(AUDIBLE_CLI), 'activation-bytes'], capture_output=True, text=True, timeout=30)
                act_bytes = rb.stdout.strip().split()[-1] if rb.returncode == 0 else ''
                if act_bytes:
                    wav_out = STIMMEN_AUDIO / (latest.stem + '_augustinski.wav')
                    STIMMEN_AUDIO.mkdir(parents=True, exist_ok=True)
                    _sp.run([
                        '/opt/homebrew/bin/ffmpeg', '-y',
                        '-activation_bytes', act_bytes,
                        '-i', str(latest),
                        '-acodec', 'pcm_s16le', '-ar', '22050', '-ac', '1',
                        str(wav_out)
                    ], timeout=600)
                    _upd(status='done', file=str(wav_out))
                    return
            _upd(status='done', file=str(STIMMEN_AUDIBLE))
        else:
            _upd(status='error', error=r.stderr[-500:] if r.stderr else 'Unbekannter Fehler')
    except Exception as e:
        _upd(status='error', error=str(e))


def _rd_download_worker(job_id: str, magnet: str, folder: str, torrent_url: str = '') -> None:
    import urllib.request as _ureq, time as _time
    def _upd(**kw):
        with RD_LOCK:
            RD_JOBS[job_id].update(kw)
            RD_DOWNLOADS_FILE.write_text(json.dumps(RD_JOBS, ensure_ascii=False, indent=2))
    try:
        token = _rd_token()
        if not token:
            _upd(status='error', error='Kein API-Key konfiguriert')
            return
        _upd(status='adding', progress='Torrent wird an Real-Debrid übergeben…')
        if magnet and magnet.startswith('magnet:'):
            # 1a. Magnet hinzufügen
            info = _rd_api('POST', '/torrents/addMagnet', {'magnet': magnet}, token)
        elif torrent_url:
            # 1b. .torrent-Datei von Jackett laden und an RD schicken
            import urllib.request as _ur2, urllib.error as _ue2
            try:
                with _ur2.urlopen(torrent_url, timeout=30) as _tr:
                    torrent_data = _tr.read()
            except _ue2.HTTPError as e:
                redirect_url = e.headers.get('Location', '')
                if e.code in (301, 302, 303, 307, 308) and redirect_url.startswith('magnet:'):
                    info = _rd_api('POST', '/torrents/addMagnet', {'magnet': redirect_url}, token)
                    torrent_data = None
                else:
                    raise
            import urllib.request as _ur3, urllib.parse as _up3
            if torrent_data is not None:
                rd_url = RD_API_BASE + '/torrents/addTorrent'
                _req3 = _ur3.Request(rd_url, data=torrent_data,
                                     headers={'Authorization': f'Bearer {token}',
                                              'Content-Type': 'application/x-bittorrent'},
                                     method='PUT')
                with _ur3.urlopen(_req3, timeout=30) as _r3:
                    info = json.loads(_r3.read().decode())
        else:
            _upd(status='error', error='Kein Magnet und keine Torrent-URL')
            return
        rd_id = info.get('id')
        if not rd_id:
            _upd(status='error', error=f'Kein ID von Real-Debrid: {info}')
            return
        # 2. Alle Dateien auswählen
        _rd_api('POST', f'/torrents/selectFiles/{rd_id}', {'files': 'all'}, token)
        _upd(status='downloading', rd_id=rd_id, progress='Real-Debrid lädt…')
        # 3. Pollen bis fertig
        for _ in range(360):  # max 60 Min
            _time.sleep(10)
            tinfo = _rd_api('GET', f'/torrents/info/{rd_id}', token=token)
            st = tinfo.get('status', '')
            prog = tinfo.get('progress', 0)
            _upd(progress=f'Real-Debrid: {st} {prog}%', rd_status=st)
            if st == 'downloaded':
                break
            if st in ('error', 'virus', 'dead', 'magnet_error', 'magnet_conversion'):
                _upd(status='error', error=f'Real-Debrid: {st}')
                return
        else:
            _upd(status='error', error='Timeout: Real-Debrid hat nicht fertig geladen')
            return
        # 4. Links unrestrichten
        links = tinfo.get('links', [])
        _upd(status='unrestricting', progress=f'{len(links)} Datei(en) werden vorbereitet…')
        target = _rd_target_folder(folder)
        target.mkdir(parents=True, exist_ok=True)
        downloaded = []
        for link in links:
            dl = _rd_api('POST', '/unrestrict/link', {'link': link}, token)
            direct_url = dl.get('download', '')
            filename = dl.get('filename', 'download')
            if not direct_url:
                continue
            dest = target / filename
            _upd(progress=f'Lade herunter: {filename}')
            with _ureq.urlopen(direct_url, timeout=3600) as resp, open(dest, 'wb') as fout:
                while True:
                    chunk = resp.read(1024 * 1024)
                    if not chunk:
                        break
                    fout.write(chunk)
            downloaded.append(str(dest))
        # Extract German audio and delete video files
        final_files = []
        for vp_str in downloaded:
            vp = Path(vp_str)
            if vp.suffix.lower() in ('.mkv', '.mp4', '.avi', '.m2ts', '.ts', '.mov'):
                _upd(progress=f'Extrahiere deutsche Tonspur: {vp.name}')
                audio = _extract_german_audio(vp, target)
                if audio:
                    final_files.append(audio)
                    vp.unlink()
                else:
                    final_files.append(vp_str)
            else:
                final_files.append(vp_str)
        _upd(status='done', progress='Fertig', files=final_files)
    except Exception as e:
        _upd(status='error', error=str(e))

# Persistente Jobs beim Start laden
if RD_DOWNLOADS_FILE.exists():
    try:
        RD_JOBS = json.loads(RD_DOWNLOADS_FILE.read_text())
        changed = False
        for job in RD_JOBS.values():
            if job.get('status') in ('queued', 'adding', 'downloading', 'unrestricting'):
                job['status'] = 'error'
                job['progress'] = 'Unterbrochen — bitte neu starten'
                job['error'] = 'Cockpit wurde neu gestartet; der Hintergrundjob läuft nicht mehr.'
                changed = True
        if changed:
            RD_DOWNLOADS_FILE.write_text(json.dumps(RD_JOBS, ensure_ascii=False, indent=2))
    except Exception:
        pass

# ── iCloud Photo Cache ─────────────────────────────────────────────────
PHOTO_CACHE_DIR = Path(__file__).parent / 'photo_cache'
PHOTO_CACHE_DIR.mkdir(exist_ok=True)
_FETCH_SCRIPT = Path(__file__).parent / 'fetch_icloud_thumb.py'
_FETCH_QUEUE: dict = {}   # uuid -> 'pending' | 'done' | 'error'
_FETCH_LOCK = threading.Lock()
_FETCH_SEM = threading.Semaphore(2)   # max 2 concurrent iCloud downloads

def _icloud_fetch_bg(uuids: list) -> None:
    """Schedule background iCloud thumbnail downloads for a list of UUIDs."""
    def _do(u: str) -> None:
        out = str(PHOTO_CACHE_DIR / f'{u}.jpg')
        if Path(out).exists():
            with _FETCH_LOCK:
                _FETCH_QUEUE[u] = 'done'
            return
        with _FETCH_SEM:
            try:
                r = subprocess.run(
                    [sys.executable, str(_FETCH_SCRIPT), u, out],
                    capture_output=True, timeout=45
                )
                with _FETCH_LOCK:
                    _FETCH_QUEUE[u] = 'done' if r.returncode == 0 else 'error'
            except Exception:
                with _FETCH_LOCK:
                    _FETCH_QUEUE[u] = 'error'

    for u in uuids:
        with _FETCH_LOCK:
            if u in _FETCH_QUEUE:
                continue
            _FETCH_QUEUE[u] = 'pending'
        threading.Thread(target=_do, args=(u,), daemon=True).start()


_PREFETCH_DONE = False   # only run once per server lifetime

def _prefetch_all_events() -> None:
    """On boot: query DB for all events in reise_events.json and queue missing iCloud photos."""
    global _PREFETCH_DONE
    if _PREFETCH_DONE:
        return
    _PREFETCH_DONE = True

    events_file = Path(__file__).parent / 'reise_events.json'
    photos_db = Path.home() / 'Pictures/Fotos-Mediathek.photoslibrary/database/Photos.sqlite'
    if not events_file.exists() or not photos_db.exists() or not _FETCH_SCRIPT.exists():
        return

    import json as _json, sqlite3 as _sq, datetime as _dt
    try:
        events = _json.loads(events_file.read_text(encoding='utf-8'))
    except Exception:
        return

    epoch2001 = _dt.datetime(2001, 1, 1)
    def _ts(d):
        if len(d) == 7: d += '-01'
        return (_dt.datetime.fromisoformat(d) - epoch2001).total_seconds()

    HOME_EXCL = 'NOT (ZLATITUDE BETWEEN 52.3 AND 52.7 AND ZLONGITUDE BETWEEN 13.0 AND 13.7)'

    def _query_event(ev):
        try:
            date = ev.get('date', '')
            date_end = ev.get('date_end') or date
            if not date: return []
            ts0, ts1 = _ts(date), _ts(date_end) + 86400
            lat, lon = ev.get('lat') or 0, ev.get('lon') or 0
            radius = 3.5

            db = _sq.connect(f'file:{photos_db}?mode=ro', uri=True)
            if lat and lon:
                rows = db.execute(
                    '''SELECT ZUUID, ZFAVORITE FROM ZASSET
                       WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0
                         AND ZLATITUDE BETWEEN ? AND ? AND ZLONGITUDE BETWEEN ? AND ?
                       ORDER BY ZFAVORITE DESC, ZDATECREATED''',
                    (ts0, ts1, lat-radius, lat+radius, lon-radius, lon+radius)
                ).fetchall()
            else:
                rows = []
            # Fallback to date range if no GPS results
            if not rows:
                rows = db.execute(
                    f'''SELECT ZUUID, ZFAVORITE FROM ZASSET
                        WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0 AND {HOME_EXCL}
                        ORDER BY ZFAVORITE DESC, ZDATECREATED LIMIT 1000''',
                    (ts0, ts1)
                ).fetchall()
            db.close()
            # Return top 32 UUIDs not already cached
            result = []
            for u, _ in rows:
                if u and not (PHOTO_CACHE_DIR / f'{u}.jpg').exists():
                    result.append(u)
                if len(result) >= 32:
                    break
            return result
        except Exception:
            return []

    def _run():
        for ev in events:
            uuids = _query_event(ev)
            if uuids:
                _icloud_fetch_bg(uuids)

    threading.Thread(target=_run, daemon=True).start()
    print(f'[photos] prefetch started for {len(events)} events', flush=True)


# Start prefetch shortly after server boot (give server time to initialize)
threading.Timer(3.0, _prefetch_all_events).start()

COCKPIT_DIR = Path(__file__).parent
KAMERAMOTOR_DIR = COCKPIT_DIR.parent / 'kameramotor'
KLING_KAMERAMOTOR_DIR = COCKPIT_DIR.parent / 'kling-kameramotor'
KAMERAMOTOR_LEGACY_JOBS_DIR = KAMERAMOTOR_DIR / 'jobs'
KAMERAMOTOR_LEGACY_STATE_DIR = KAMERAMOTOR_DIR / 'state'
KAMERAMOTOR_MAGNIFIC_JOBS_DIR = KAMERAMOTOR_DIR / 'jobs_magnific'
KAMERAMOTOR_MAGNIFIC_STATE_DIR = KAMERAMOTOR_DIR / 'state_magnific'
KLING_KAMERAMOTOR_JOBS_DIR = KLING_KAMERAMOTOR_DIR / 'jobs'
KLING_KAMERAMOTOR_STATE_DIR = KLING_KAMERAMOTOR_DIR / 'state'

def _atomic_json_write(path: Path, data) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + '.tmp')
    tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
    tmp.replace(path)

def _kameramotor_provider_from_payload(data: dict) -> str:
    marker_values = [
        data.get('provider'),
        data.get('engine'),
        data.get('generator'),
        data.get('type'),
        data.get('mode'),
        data.get('source'),
    ]
    marker = ' '.join(str(v).lower() for v in marker_values if v is not None)
    if data.get('kling') is True or data.get('video') is True:
        return 'kling'
    if 'kling' in marker:
        return 'kling'
    if 'magnific' in marker or 'nano' in marker or 'imagen' in marker:
        return 'magnific'
    return 'magnific'

def _validate_kameramotor_payload(data: dict, provider: str) -> tuple[bool, str]:
    if provider not in {'magnific', 'kling'}:
        return False, f'Unbekannter Kameramotor-Provider: {provider}'
    forbidden_needles = [
        'localhost:9222', '127.0.0.1:9222',  # provider-guard: allow-rule-literal
        'localhost:9223', '127.0.0.1:9223',  # provider-guard: allow-rule-literal
        'Runtime.evaluate', 'connectOverCDP',  # provider-guard: allow-rule-literal
        'webSocketDebuggerUrl',  # provider-guard: allow-rule-literal
    ]
    payload_text = json.dumps(data, ensure_ascii=False, default=str)
    for needle in forbidden_needles:
        if needle in payload_text:
            return False, f'Direkter Provider-/CDP-Zugriff im Job verboten: {needle}'
    if provider == 'kling' and not (data.get('start_image') or data.get('image') or data.get('prompt') or data.get('prompt_file')):
        return False, 'Kling-Job braucht mindestens Bild oder Prompt.'
    if provider == 'magnific' and not (data.get('image') or data.get('prompt') or data.get('prompt_file')):
        return False, 'Magnific-Job braucht mindestens Bild oder Prompt.'
    return True, ''

def _build_kameramotor_job(data: dict, job_id: str, submitted_ms: int, provider: str) -> dict:
    job = dict(data)
    job['id'] = job_id
    job['provider'] = provider
    job['submitted_at'] = submitted_ms
    if provider == 'magnific':
        job.setdefault('mode', 'imagen-nano-banana-2-flash')
        job.setdefault('ratio', 'auto')
        job.setdefault('num_images', 2)
        job.setdefault('resolution', '4k')
        job.setdefault('thinking_level', 'high')
        job.setdefault('source', 'The Camera')
        job.setdefault('output_dir', str(Path.home() / 'Desktop' / 'Kameramotor'))
        job['group_id'] = data.get('group_id') or None
        if 'seeds' in data and not isinstance(data.get('seeds'), list):
            job.pop('seeds', None)
    else:
        job.setdefault('type', 'video')
        job.setdefault('video', True)
        job.setdefault('engine', 'kling')
        job.setdefault('kling', True)
        job.setdefault('duration', 5)
        job.setdefault('source', 'The Camera Kling')
        job.setdefault('output_dir', str(Path.home() / 'Desktop' / 'Kling-Kameramotor'))
    return job

def _kameramotor_target_dirs(provider: str) -> tuple[Path, Path]:
    if provider == 'kling':
        return KLING_KAMERAMOTOR_JOBS_DIR, KLING_KAMERAMOTOR_STATE_DIR
    return KAMERAMOTOR_MAGNIFIC_JOBS_DIR, KAMERAMOTOR_MAGNIFIC_STATE_DIR

# ── Originals-Registry (SHA-256-Dedup für upload-image) ──────────────────────
_ORIGINALS_REGISTRY_PATH = COCKPIT_DIR.parent / 'kameramotor' / 'originals_registry.json'
_originals_registry_lock = threading.Lock()

def _load_originals_registry():
    """Lädt die Registry — gibt {} zurück wenn Datei fehlt oder kaputt."""
    try:
        return json.loads(_ORIGINALS_REGISTRY_PATH.read_text(encoding='utf-8'))
    except Exception:
        return {}

def _save_originals_registry(registry):
    """Schreibt die Registry atomar (write→rename)."""
    tmp = _ORIGINALS_REGISTRY_PATH.with_suffix('.tmp')
    tmp.write_text(json.dumps(registry, indent=2, ensure_ascii=False), encoding='utf-8')
    tmp.replace(_ORIGINALS_REGISTRY_PATH)

def _sha256_of_file(path):
    """Berechnet SHA-256 eines Dateipfads — gibt Hex-String zurück."""
    h = hashlib.sha256()
    with open(path, 'rb') as f:
        for chunk in iter(lambda: f.read(65536), b''):
            h.update(chunk)
    return h.hexdigest()


# ── Time Travel Camera API ────────────────────────────────────────────────
TTC_ROOT_DIR = COCKPIT_DIR.parent / 'time-travel-camera'
TTC_JOBS_DIR = TTC_ROOT_DIR / 'jobs'
TTC_OUTPUT_DIR = COCKPIT_DIR.parent / 'fotolabor' / 'output' / 'time-travel-camera'

def _ttc_safe_job_id(raw: str) -> str:
    safe = re.sub(r'[^a-zA-Z0-9_-]+', '_', raw or '').strip('_')
    return safe[:96] or f'ttc_{uuid.uuid4().hex}'

def _ttc_job_dir(job_id: str) -> Path:
    return TTC_JOBS_DIR / _ttc_safe_job_id(job_id)

def _ttc_meta_path(job_id: str) -> Path:
    return _ttc_job_dir(job_id) / 'manifest.json'

def _ttc_load_meta(job_id: str) -> dict:
    p = _ttc_meta_path(job_id)
    if p.exists():
        try:
            return json.loads(p.read_text(encoding='utf-8'))
        except Exception:
            pass
    return {}

def _ttc_asset_url(job_id: str, name: str) -> str:
    return f'/api/time-camera/jobs/{job_id}/assets/{name}'

def _ttc_result_files(job_id: str, output_dir: str = '') -> list[Path]:
    candidates = []
    for d in [Path(output_dir) if output_dir else None, TTC_OUTPUT_DIR / job_id]:
        if not d or not d.exists() or not d.is_dir():
            continue
        for f in sorted(d.iterdir()):
            if f.name.startswith('.') or f.name.startswith('_original'):
                continue
            if f.suffix.lower() in ('.jpg', '.jpeg', '.png', '.webp'):
                # TTC zeigt das Original separat. Der Worker legt teils zusätzlich
                # eine kleine JPG-Kopie im Output ab; die ist kein Generatorresultat.
                if 'time-travel-100-scout-gpt-5-nano' in f.name or f.suffix.lower() in ('.png', '.webp'):
                    candidates.append(f)
    seen, out = set(), []
    for f in candidates:
        key = str(f.resolve())
        if key not in seen:
            seen.add(key); out.append(f)
    return out

def _ttc_make_thumbnail(source: Path, dest: Path) -> None:
    try:
        dest.parent.mkdir(parents=True, exist_ok=True)
        subprocess.run([
            'sips', '-Z', '900', str(source),
            '--setProperty', 'format', 'jpeg',
            '--setProperty', 'formatOptions', '82',
            '--out', str(dest),
        ], capture_output=True, timeout=30)
        if not dest.exists():
            dest.write_bytes(source.read_bytes())
    except Exception:
        try:
            dest.write_bytes(source.read_bytes())
        except Exception:
            pass

def _ttc_status_for(job_id: str, meta: dict | None = None) -> dict:
    meta = meta or _ttc_load_meta(job_id)
    request = meta.get('request') or {}
    km_id = request.get('id') or meta.get('kameramotor_job_id') or job_id
    output_dir = request.get('output_dir') or str(TTC_OUTPUT_DIR / job_id)
    failed = KAMERAMOTOR_DIR / 'failed_magnific' / f'{km_id}.json'
    done = KAMERAMOTOR_DIR / 'done_magnific' / f'{km_id}.json'
    state = KAMERAMOTOR_MAGNIFIC_STATE_DIR / f'{km_id}.json'
    queued = KAMERAMOTOR_MAGNIFIC_JOBS_DIR / f'{km_id}.json'
    results = _ttc_result_files(km_id, output_dir)
    status = 'uploaded'
    if failed.exists():
        status = 'failed'
    elif done.exists() or results:
        status = 'done'
    elif state.exists():
        status = 'running'
    elif queued.exists() or meta.get('submitted_to_fotolabor'):
        status = 'queued'
    if meta.get('status') == 'needs_victor':
        status = 'needs_victor'
    return {
        'ok': True,
        'job_id': job_id,
        'kameramotor_job_id': km_id,
        'status': status,
        'results_count': len(results),
        'manifest': _ttc_asset_url(job_id, 'manifest.json'),
    }

def _ttc_manifest(job_id: str) -> dict:
    meta = _ttc_load_meta(job_id)
    request = meta.get('request') or {}
    km_id = request.get('id') or meta.get('kameramotor_job_id') or job_id
    output_dir = request.get('output_dir') or str(TTC_OUTPUT_DIR / km_id)
    results = _ttc_result_files(km_id, output_dir)
    assets_dir = _ttc_job_dir(job_id) / 'assets'
    manifest = {
        'ok': True,
        'job_id': job_id,
        'kameramotor_job_id': km_id,
        'status': _ttc_status_for(job_id, meta).get('status'),
        'original': _ttc_asset_url(job_id, 'original.jpg'),
        'thumbnail': _ttc_asset_url(job_id, 'thumb.jpg'),
        'results': [],
        'created_at': meta.get('created_at'),
        'updated_at': int(__import__('time').time() * 1000),
    }
    for idx, path in enumerate(results, start=1):
        name = f'result_{idx}{path.suffix.lower()}'
        link = assets_dir / name
        try:
            link.write_bytes(path.read_bytes())
        except Exception:
            pass
        manifest['results'].append({
            'name': name,
            'url': _ttc_asset_url(job_id, name),
            'bytes': path.stat().st_size if path.exists() else 0,
            'mtime': int(path.stat().st_mtime) if path.exists() else 0,
        })
    _atomic_json_write(_ttc_meta_path(job_id), {**meta, 'last_manifest': manifest})
    return manifest

def _ttc_submit_to_fotolabor(job_id: str, request: dict, image_path: Path, prompt_path: Path) -> dict:
    import time as _ttc_time
    km_id = _ttc_safe_job_id(request.get('id') or job_id)
    output_dir = Path(request.get('output_dir') or (TTC_OUTPUT_DIR / km_id))
    output_dir.mkdir(parents=True, exist_ok=True)
    payload = dict(request)
    payload.update({
        'id': km_id,
        'image': str(image_path),
        'prompt_file': str(prompt_path),
        'provider': 'magnific',
        'generator': 'nano',
        'mode': 'imagen-nano-banana-2-flash',
        'num_images': int(payload.get('num_images') or 2),
        'resolution': payload.get('resolution') or '4k',
        'ratio': payload.get('ratio') or 'auto',
        'source': 'Time Travel Camera iOS',
        'output_dir': str(output_dir),
        'stem': payload.get('stem') or km_id,
        'filter': payload.get('filter') or 'time-travel-100-scout-gpt-5-nano',
    })
    provider = _kameramotor_provider_from_payload(payload)
    valid, error = _validate_kameramotor_payload(payload, provider)
    if not valid:
        raise ValueError(error)
    submitted_ms = int(_ttc_time.time() * 1000)
    job = _build_kameramotor_job(payload, km_id, submitted_ms, provider)
    jobs_dir, state_dir = _kameramotor_target_dirs(provider)
    _atomic_json_write(state_dir / f'{km_id}.json', {
        'submitted_at': submitted_ms,
        'provider': provider,
        'queue': str(jobs_dir),
        'ttc_job_id': job_id,
        'original_path': str(image_path),
        'output_dir_actual': str(output_dir),
    })
    _atomic_json_write(jobs_dir / f'{km_id}.json', job)
    return {'kameramotor_job_id': km_id, 'queue': str(jobs_dir), 'output_dir': str(output_dir)}

# ─────────────────────────────────────────────────────────────────────────────

TASK_TOKEN_FILE = Path(__file__).parent.parent / '.task_api_token'
TASK_LIMITS_FILE = Path(__file__).parent.parent / 'task_limits.json'
TASK_DAILY_LIMIT = 30
TASK_MAX_PAYLOAD_BYTES = 50_000
TASK_PAYLOAD_BLACKLIST = ['/api/task/submit', 'outbox_watcher.py']

def _get_task_token() -> str:
    try:
        return TASK_TOKEN_FILE.read_text(encoding='utf-8').strip()
    except Exception:
        return ''

def _check_task_auth(headers) -> bool:
    token = _get_task_token()
    if not token:
        return False
    auth = headers.get('Authorization', '')
    return auth == f'Bearer {token}'

def _check_task_daily_limit() -> tuple[bool, int]:
    import datetime as _dt
    today = _dt.date.today().isoformat()
    try:
        limits = json.loads(TASK_LIMITS_FILE.read_text()) if TASK_LIMITS_FILE.exists() else {}
    except Exception:
        limits = {}
    if limits.get('date') != today:
        limits = {'date': today, 'count': 0, 'blocked': 0}
    if limits['count'] >= TASK_DAILY_LIMIT:
        limits['blocked'] = limits.get('blocked', 0) + 1
        TASK_LIMITS_FILE.write_text(json.dumps(limits, ensure_ascii=False))
        return False, limits['count']
    return True, limits['count']

def _increment_task_daily_count():
    import datetime as _dt
    today = _dt.date.today().isoformat()
    try:
        limits = json.loads(TASK_LIMITS_FILE.read_text()) if TASK_LIMITS_FILE.exists() else {}
    except Exception:
        limits = {}
    if limits.get('date') != today:
        limits = {'date': today, 'count': 0, 'blocked': 0}
    limits['count'] = limits.get('count', 0) + 1
    TASK_LIMITS_FILE.write_text(json.dumps(limits, ensure_ascii=False))

LEXIKON_DIR = Path(__file__).parent.parent / 'lexikon'
WATSON_VOICES_DIR = Path('/Users/victorholland/Vibe Coding/Voice Output/watson_voices')
INBOX_DIR = Path(__file__).parent.parent / 'inboxes'
CREDENTIALS_FILE = Path(__file__).parent.parent / 'hue' / 'credentials.env'
SANCHO_REGISTRY = Path(__file__).parent.parent / 'sancho_registry.json'
COSTS_FILE = Path(__file__).parent.parent / '_costs' / 'costs.jsonl'
ANRUFE_DIR = Path(__file__).parent.parent / 'anrufe'
ANRUFE_DIR.mkdir(exist_ok=True)
ANRUF_STATUS = {}  # id -> {'status': 'processing'|'done'|'error', 'result': dict|None, 'error': str|None}
JOBS_QUEUE_FILE = Path(__file__).parent.parent / 'jobs_queue.jsonl'
REISE_COMMENTS_FILE = Path(__file__).parent / 'reise_comments.json'
REISE_ANNOTATIONS_FILE = Path(__file__).parent / 'reise_annotations.json'
TRAVEL_DATA_DIR = Path(__file__).parent.parent / 'travel_data'
PORT = 8089


def _load_openai_key() -> str:
    """Liest OPENAI_API_KEY: credentials.env → op:// → Keychain-Fallback."""
    try:
        for line in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
            line = line.strip()
            if line.startswith('OPENAI_API_KEY='):
                val = line.split('=', 1)[1].strip()
                if val.startswith('op://'):
                    try:
                        import subprocess as _sp_op
                        resolved = _sp_op.check_output(
                            ['op', 'read', val], text=True, stderr=_sp_op.DEVNULL, timeout=5
                        ).strip()
                        if resolved and resolved.startswith('sk-'):
                            return resolved
                    except Exception:
                        pass
                    # op:// failed — Keychain-Fallback
                    try:
                        import subprocess as _sp_kc
                        return _sp_kc.check_output(
                            ['security', 'find-generic-password', '-s', 'openai', '-a', 'rat-der-weisen', '-w'],
                            text=True, stderr=_sp_kc.DEVNULL
                        ).strip()
                    except Exception:
                        pass
                    return ''
                return val
    except Exception:
        pass
    return ''


def _load_deepgram_key() -> str:
    try:
        for line in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
            line = line.strip()
            if line.startswith('DEEPGRAM_API_KEY='):
                return line.split('=', 1)[1].strip()
    except Exception:
        pass
    return ''


def _load_anthropic_key() -> str:
    try:
        for line in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
            line = line.strip()
            if line.startswith('ANTHROPIC_API_KEY='):
                return line.split('=', 1)[1].strip()
    except Exception:
        pass
    return ''


# ── Anruf-Verarbeitung ─────────────────────────────────────────────
def _anruf_set(anruf_id: str, **fields) -> None:
    cur = ANRUF_STATUS.get(anruf_id, {})
    cur.update(fields)
    ANRUF_STATUS[anruf_id] = cur
    # Persistieren als <id>.json bei done/error
    if cur.get('status') in ('done', 'error'):
        try:
            anruf_dir = ANRUFE_DIR / anruf_id
            anruf_dir.mkdir(exist_ok=True)
            out = anruf_dir / f'{anruf_id}.json'
            payload = {
                'id': anruf_id,
                'status': cur.get('status'),
                'error': cur.get('error'),
                'result': cur.get('result'),
            }
            if cur.get('result'):
                payload.update(cur['result'])  # flach
            out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
        except Exception as _e:
            print(f'[anruf] persist fail {anruf_id}: {_e}', flush=True)


def _process_anruf(anruf_id: str, audio_path: Path, audio_mime: str) -> None:
    """Verarbeitet einen Anruf im Hintergrund:
    1) Deepgram-Transkription (diarize, paragraphs, utterances)
    2) Claude/OpenAI-Analyse → Zusammenfassung, Schlüsselpunkte, Action Items
    3) Speichert <id>.json + setzt Status auf done/error
    """
    import datetime as _dt
    import urllib.request as _ur
    import urllib.error as _ue
    try:
        # ── 1) Deepgram ────────────────────────────────────────────
        dg_key = _load_deepgram_key()
        if not dg_key:
            _anruf_set(anruf_id, status='error', error='DEEPGRAM_API_KEY fehlt in credentials.env')
            return

        audio_data = audio_path.read_bytes()
        if not audio_data or len(audio_data) < 1000:
            _anruf_set(anruf_id, status='error', error='Aufnahme leer oder zu kurz')
            return

        dg_url = (
            'https://api.deepgram.com/v1/listen'
            '?model=nova-2&language=de'
            '&diarize=true&punctuate=true&smart_format=true'
            '&paragraphs=true&utterances=true'
        )
        req = _ur.Request(
            dg_url,
            data=audio_data,
            headers={
                'Authorization': f'Token {dg_key}',
                'Content-Type': audio_mime or 'audio/webm',
            },
            method='POST',
        )
        try:
            with _ur.urlopen(req, timeout=180) as resp:
                dg_raw = resp.read().decode('utf-8')
            dg_json = json.loads(dg_raw)
        except _ue.HTTPError as he:
            body = ''
            try:
                body = he.read().decode('utf-8', errors='replace')[:400]
            except Exception:
                pass
            _anruf_set(anruf_id, status='error',
                       error=f'Deepgram HTTP {he.code}: {body or he.reason}')
            return
        except Exception as e:
            _anruf_set(anruf_id, status='error', error=f'Deepgram-Fehler: {str(e)[:300]}')
            return

        # Utterances aus Deepgram-Antwort extrahieren
        transcript_lines: list = []
        unclear_moments: list = []
        full_text_parts: list = []
        duration_s = 0.0
        try:
            duration_s = float(dg_json.get('metadata', {}).get('duration', 0) or 0)
        except Exception:
            duration_s = 0.0

        utts = dg_json.get('results', {}).get('utterances', []) or []
        for u in utts:
            sp = int(u.get('speaker', 0) or 0)
            text = str(u.get('transcript', '') or '').strip()
            start = float(u.get('start', 0) or 0)
            end = float(u.get('end', 0) or 0)
            conf = float(u.get('confidence', 1.0) or 1.0)
            if not text:
                continue
            transcript_lines.append({
                'speaker': sp,
                'start': round(start, 2),
                'end': round(end, 2),
                'text': text,
                'confidence': round(conf, 3),
            })
            full_text_parts.append(f'Sprecher {"AB"[sp % 2]}: {text}')
            # Unklare Stelle wenn Confidence < 0.7
            if conf < 0.7:
                unclear_moments.append({
                    'speaker': sp,
                    'start': round(start, 2),
                    'text': text,
                    'reason': f'Wörter undeutlich (Erkennungs-Sicherheit {int(conf*100)} %)',
                })

        # Fallback wenn keine utterances: aus alternatives.words diarisieren
        if not transcript_lines:
            try:
                alt = dg_json['results']['channels'][0]['alternatives'][0]
                text = str(alt.get('transcript', '') or '').strip()
                if text:
                    transcript_lines.append({
                        'speaker': 0, 'start': 0.0, 'end': duration_s,
                        'text': text, 'confidence': 1.0,
                    })
                    full_text_parts.append(text)
            except Exception:
                pass

        if not transcript_lines:
            _anruf_set(anruf_id, status='error', error='Kein Sprachinhalt erkannt')
            return

        # Begrenzung — kollabiere benachbarte unclear-Momente desselben Sprechers
        # und nehme max 6 Stück
        unclear_moments = unclear_moments[:6]

        full_transcript_text = '\n'.join(full_text_parts)

        # ── 2) Analyse via Claude (Anthropic) ─────────────────────
        anthropic_key = _load_anthropic_key()
        openai_key = _load_openai_key()

        analysis = None
        analysis_error = None

        if anthropic_key:
            analysis, analysis_error = _analyze_with_claude(anthropic_key, full_transcript_text, duration_s)
        if analysis is None and openai_key:
            analysis, analysis_error = _analyze_with_openai(openai_key, full_transcript_text, duration_s)

        if analysis is None:
            # Mindestens Transkript ist da — wir liefern fallback-Ergebnis
            analysis = {
                'summary': 'Analyse nicht verfügbar. ' + (analysis_error or 'Kein KI-Key in credentials.env.'),
                'key_points': [],
                'action_items': [],
            }

        # ── 3) Ergebnis bauen + speichern ─────────────────────────
        result = {
            'id': anruf_id,
            'ts': _dt.datetime.now().isoformat(timespec='seconds'),
            'duration_s': int(round(duration_s)),
            'status': 'done',
            'transcript': transcript_lines,
            'summary': analysis.get('summary', ''),
            'key_points': analysis.get('key_points', []) or [],
            'action_items': analysis.get('action_items', []) or [],
            'unclear_moments': unclear_moments,
            'audio_mime': audio_mime,
        }
        _anruf_set(anruf_id, status='done', result=result, error=None)

    except Exception as e:
        import traceback as _tb
        print(f'[anruf] {anruf_id} crash: {_tb.format_exc()}', flush=True)
        _anruf_set(anruf_id, status='error', error=f'Unerwarteter Fehler: {str(e)[:300]}')


def _analyze_with_claude(api_key: str, transcript: str, duration_s: float):
    """Schickt Transkript an Claude Sonnet, erwartet JSON. Gibt (dict, error) zurück."""
    import urllib.request as _ur
    import urllib.error as _ue
    prompt = (
        "Du bekommst das Transkript eines geschäftlichen Telefonats von Victor Holland (Filmregisseur). "
        "Ein Kunde ruft an und beauftragt einen Film. Victor verliert beim ersten Gespräch oft Details.\n\n"
        "Deine Aufgabe: Erstelle aus dem Transkript ein präzises Briefing als JSON-Objekt mit diesen Feldern:\n"
        "- summary (string): 2-3 Sätze, was wurde besprochen, informell, in Victors Sprache (knapp, aktiv)\n"
        "- key_points (array of strings): 3-7 Bulletpoints mit den wichtigsten Fakten\n"
        "- action_items (array of objects mit text, urgency, category): konkrete Aufgaben für Victor. "
        "urgency = 'hoch'|'mittel'|'niedrig'. category z.B. 'Angebot', 'Termin', 'Klärung', 'Recherche'\n\n"
        "Wichtige Regeln:\n"
        "- Nichts erfinden was nicht im Transkript steht\n"
        "- Wenn etwas unklar ist, erwähne es im Punkt aber spekuliere nicht\n"
        "- Deutsch, knapp, keine Marketing-Sprache, kein 'der Kunde wünscht sich freundlicherweise'\n"
        "- Antworte AUSSCHLIESSLICH mit dem JSON-Objekt, kein Markdown-Codeblock\n\n"
        f"Transkript (Dauer ca. {int(duration_s)}s):\n"
        f"{transcript}"
    )

    payload = json.dumps({
        'model': 'claude-3-5-sonnet-latest',
        'max_tokens': 2000,
        'messages': [{'role': 'user', 'content': prompt}],
    }).encode('utf-8')
    req = _ur.Request(
        'https://api.anthropic.com/v1/messages',
        data=payload,
        headers={
            'x-api-key': api_key,
            'anthropic-version': '2023-06-01',
            'Content-Type': 'application/json',
        },
        method='POST',
    )
    try:
        with _ur.urlopen(req, timeout=90) as resp:
            data = json.loads(resp.read().decode('utf-8'))
        content = data.get('content', [])
        text = ''
        if isinstance(content, list) and content:
            text = content[0].get('text', '')
        text = (text or '').strip()
        # JSON-Block extrahieren, falls in Codeblock
        if text.startswith('```'):
            text = text.split('```', 2)[1]
            if text.startswith('json'):
                text = text[4:]
            text = text.strip().rstrip('`').strip()
        parsed = json.loads(text)
        return parsed, None
    except _ue.HTTPError as he:
        body = ''
        try:
            body = he.read().decode('utf-8', errors='replace')[:300]
        except Exception:
            pass
        return None, f'Claude HTTP {he.code}: {body or he.reason}'
    except Exception as e:
        return None, f'Claude-Analyse: {str(e)[:200]}'


def _analyze_with_openai(api_key: str, transcript: str, duration_s: float):
    """Fallback: GPT-4o-mini mit JSON-Mode."""
    import urllib.request as _ur
    import urllib.error as _ue
    system = (
        "Du baust Briefings für Victor Holland (Filmregisseur) aus Anruf-Transkripten. "
        "Antworte nur mit JSON: {summary, key_points[], action_items[{text, urgency, category}]}. "
        "Deutsch, knapp, nichts erfinden."
    )
    user = (
        f"Transkript (~{int(duration_s)}s):\n{transcript}\n\n"
        "Felder: summary (2-3 Sätze), key_points (3-7 Stichpunkte), "
        "action_items (urgency=hoch|mittel|niedrig, category z.B. Angebot/Termin/Klärung)."
    )
    payload = json.dumps({
        'model': 'gpt-4o-mini',
        'messages': [
            {'role': 'system', 'content': system},
            {'role': 'user', 'content': user},
        ],
        'response_format': {'type': 'json_object'},
        'temperature': 0.3,
    }).encode('utf-8')
    req = _ur.Request(
        'https://api.openai.com/v1/chat/completions',
        data=payload,
        headers={
            'Authorization': f'Bearer {api_key}',
            'Content-Type': 'application/json',
        },
        method='POST',
    )
    try:
        with _ur.urlopen(req, timeout=90) as resp:
            data = json.loads(resp.read().decode('utf-8'))
        text = data['choices'][0]['message']['content']
        return json.loads(text), None
    except _ue.HTTPError as he:
        body = ''
        try:
            body = he.read().decode('utf-8', errors='replace')[:300]
        except Exception:
            pass
        return None, f'OpenAI HTTP {he.code}: {body or he.reason}'
    except Exception as e:
        return None, f'OpenAI-Analyse: {str(e)[:200]}'


def _load_anruf_from_disk(anruf_id: str) -> dict:
    """Lädt einen abgeschlossenen Anruf von Disk falls nicht im RAM."""
    p = ANRUFE_DIR / anruf_id / f'{anruf_id}.json'
    if not p.exists():
        return None
    try:
        d = json.loads(p.read_text(encoding='utf-8'))
        return d
    except Exception:
        return None


def _read_jsonl(path: Path) -> list:
    """Read all entries from a JSONL file. Returns [] if file missing or corrupt."""
    if not path.exists():
        return []
    entries = []
    for line in path.read_text(encoding='utf-8').splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            entries.append(json.loads(line))
        except json.JSONDecodeError:
            pass
    return entries


def _append_jsonl(path: Path, entry: dict) -> None:
    """Append one entry as a JSONL line. Creates file + parent dirs as needed."""
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open('a', encoding='utf-8') as fh:
        fh.write(json.dumps(entry, ensure_ascii=False) + '\n')

ALERTS_DIR = COCKPIT_DIR.parent / 'alerts'
ALERTS_LOG_FILE = ALERTS_DIR / 'system_alerts.jsonl'
ALERTS_ACK_LOG_FILE = ALERTS_DIR / 'alarm_ack.jsonl'
ALERTS_ACTIVE_FILE = ALERTS_DIR / 'active_alert.json'
APPLE_ALARM_DEVICES_FILE = ALERTS_DIR / 'apple_alarm_devices.json'


def _alarm_event_id(entry: dict) -> str:
    """Stable id for legacy alarm lines that were written before ids existed."""
    raw = '|'.join(str(entry.get(k, '')) for k in ('ts', 'title', 'message', 'source'))
    return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]


def _alarm_normalize(entry: dict) -> dict:
    event = dict(entry)
    event.setdefault('id', _alarm_event_id(event))
    event.setdefault('ts', '')
    event.setdefault('title', 'System-Alarm')
    event.setdefault('message', '')
    event.setdefault('source', 'system')
    event.setdefault('priority', 'high')
    event.setdefault('tag', 'warning')
    event.setdefault('click_url', 'https://cockpit.beachorchestra.com/alarme')
    event['acknowledged'] = bool(event.get('acknowledged', False))
    return event


def _alarm_ack_map() -> dict:
    acked = {}
    for entry in _read_jsonl(ALERTS_ACK_LOG_FILE):
        alarm_id = str(entry.get('id', '') or entry.get('alarm_id', '')).strip()
        if alarm_id:
            acked[alarm_id] = entry
    if ALERTS_DIR.exists():
        for path in ALERTS_DIR.glob('ack_*'):
            alarm_id = path.name[len('ack_'):]
            if alarm_id and alarm_id not in acked:
                try:
                    ts = __import__('datetime').datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec='seconds')
                except Exception:
                    ts = ''
                acked[alarm_id] = {'id': alarm_id, 'ts': ts, 'actor': 'mac-dialog'}
    return acked


def _alarm_state(limit: int = 80) -> dict:
    entries = [_alarm_normalize(e) for e in _read_jsonl(ALERTS_LOG_FILE)]
    acked = _alarm_ack_map()
    for event in entries:
        ack = acked.get(event['id'])
        if ack:
            event['acknowledged'] = True
            event['ack'] = ack
    entries.sort(key=lambda e: str(e.get('ts', '')), reverse=True)
    active = [e for e in entries if not e.get('acknowledged')]
    return {
        'ok': True,
        'updated_at': __import__('datetime').datetime.now().isoformat(timespec='seconds'),
        'active_count': len(active),
        'active': active[:20],
        'events': entries[:limit],
        'log_file': str(ALERTS_LOG_FILE),
        'ack_file': str(ALERTS_ACK_LOG_FILE),
    }


def _alarm_append(title: str, message: str, priority: str = 'high', source: str = 'cockpit',
                  tag: str = 'warning', click_url: str = 'https://cockpit.beachorchestra.com/alarme') -> dict:
    import datetime as _dt
    event = {
        'ts': _dt.datetime.now().isoformat(timespec='seconds'),
        'id': _dt.datetime.now().strftime('%Y%m%d_%H%M%S_') + uuid.uuid4().hex[:6],
        'title': title or 'System-Alarm',
        'message': message or 'Ein System braucht Victor.',
        'tag': tag or 'warning',
        'priority': priority or 'high',
        'source': source or 'cockpit',
        'click_url': click_url or 'https://cockpit.beachorchestra.com/alarme',
        'acknowledged': False,
    }
    _append_jsonl(ALERTS_LOG_FILE, event)
    _atomic_json_write(ALERTS_ACTIVE_FILE, event)
    return event


def _alarm_ack(alarm_id: str, actor: str = 'victor', note: str = '') -> dict:
    import datetime as _dt
    entry = {
        'ts': _dt.datetime.now().isoformat(timespec='seconds'),
        'id': alarm_id,
        'actor': actor or 'victor',
        'note': note or '',
    }
    _append_jsonl(ALERTS_ACK_LOG_FILE, entry)
    return entry


def _apple_register_device_token(data: dict) -> dict:
    import datetime as _dt
    device_id = str(data.get('device_id', '') or '').strip()
    token = str(data.get('apns_token', '') or '').strip()
    if not device_id or not token:
        raise ValueError('device_id and apns_token required')

    try:
        registry = json.loads(APPLE_ALARM_DEVICES_FILE.read_text(encoding='utf-8'))
    except Exception:
        registry = {'devices': []}

    devices = registry.setdefault('devices', [])
    now = _dt.datetime.now().isoformat(timespec='seconds')
    match = None
    for device in devices:
        if device.get('id') == device_id:
            match = device
            break
    if match is None:
        match = {'id': device_id}
        devices.append(match)

    match.update({
        'label': str(data.get('device_label', match.get('label', device_id)))[:120],
        'kind': str(data.get('kind', match.get('kind', 'apple')))[:40],
        'bundle_id': str(data.get('bundle_id', ''))[:160],
        'apns_token': token,
        'status': 'token-registered',
        'updated_at': now,
    })
    _atomic_json_write(APPLE_ALARM_DEVICES_FILE, registry)
    _append_jsonl(ALERTS_DIR / 'apple_alarm_device_tokens.jsonl', {
        'ts': now,
        'device_id': device_id,
        'kind': match.get('kind'),
        'bundle_id': match.get('bundle_id'),
        'token_len': len(token),
        'token_prefix': token[:8],
        'note': 'APNs token registered; full token stored only in device registry.',
    })
    return {'ok': True, 'device_id': device_id, 'status': match['status'], 'token_len': len(token)}


def _costs_summary() -> dict:
    """Liest _costs/costs.jsonl und gibt Zusammenfassung zurueck."""
    import datetime as _dt
    entries = _read_jsonl(COSTS_FILE)
    today_str = _dt.date.today().isoformat()
    week_start = (_dt.date.today() - _dt.timedelta(days=_dt.date.today().weekday())).isoformat()

    today_usd = 0.0
    week_usd = 0.0
    total_usd = 0.0
    by_sancho: dict = {}

    for e in entries:
        cost = float(e.get('cost_usd', 0) or 0)
        ts = str(e.get('ts', ''))
        day = ts[:10] if ts else ''
        name = str(e.get('sancho_name', 'unknown') or 'unknown')

        total_usd += cost
        if day >= today_str:
            today_usd += cost
        if day >= week_start:
            week_usd += cost

        by_sancho.setdefault(name, 0.0)
        by_sancho[name] += cost

    # Runden auf 4 Nachkommastellen fuer saubere JSON-Werte
    return {
        'today_usd':    round(today_usd, 4),
        'this_week_usd': round(week_usd, 4),
        'total_usd':    round(total_usd, 4),
        'by_sancho':    {k: round(v, 4) for k, v in sorted(by_sancho.items(), key=lambda x: -x[1])},
        'entry_count':  len(entries),
    }


# ── Kostenzähler aus Claude Code Session-JSONL ───────────────────────

KOSTEN_PRICES = {
    "claude-sonnet-4-6": {"input": 3.0, "output": 15.0, "cache_write": 3.75, "cache_read": 0.30},
    "claude-sonnet-4-5": {"input": 3.0, "output": 15.0, "cache_write": 3.75, "cache_read": 0.30},
    "claude-opus-4-7":   {"input": 5.0, "output": 25.0, "cache_write": 6.25, "cache_read": 0.50},
    "claude-opus-4-6":   {"input": 5.0, "output": 25.0, "cache_write": 6.25, "cache_read": 0.50},
    "claude-haiku-4-5":              {"input": 1.0, "output": 5.0, "cache_write": 1.25, "cache_read": 0.10},
    "claude-haiku-4-5-20251001":     {"input": 1.0, "output": 5.0, "cache_write": 1.25, "cache_read": 0.10},
}
KOSTEN_FALLBACK_PRICES = {"input": 3.0, "output": 15.0, "cache_write": 3.75, "cache_read": 0.30}
KOSTEN_EUR_USD_RATE = 0.92  # Fallback-Rate, taeglich hardcoded aktualisierbar


def _calc_cost_usd(model: str, usage: dict) -> float:
    p = KOSTEN_PRICES.get(model, KOSTEN_FALLBACK_PRICES)
    inp   = float(usage.get("input_tokens", 0) or 0)
    out   = float(usage.get("output_tokens", 0) or 0)
    cw    = float(usage.get("cache_creation_input_tokens", 0) or 0)
    cr    = float(usage.get("cache_read_input_tokens", 0) or 0)
    return (inp * p["input"] + out * p["output"] + cw * p["cache_write"] + cr * p["cache_read"]) / 1_000_000.0


def _kosten_data(range_: str) -> dict:
    """Kombiniert zwei Datenquellen:
    1. ~/.claude/projects/**/*.jsonl — Claude Code Abo-Wert (kein echter Betrag, durch Max-Abo gedeckt)
    2. ~/Vibe Coding/Rat der Weisen/sessions/costs.jsonl — echte API-Kosten (rdw.py etc.)
    range_: 'today' oder 'month'"""
    import glob as _glob
    from datetime import datetime as _dt, timezone as _tz, date as _date

    now = _dt.now(_tz.utc)
    local_now = _dt.now()
    today_str = _date.today().isoformat()
    month_str = today_str[:7]  # YYYY-MM

    if range_ == "today":
        midnight_local = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
        since_utc = midnight_local.astimezone(_tz.utc)
        date_filter = today_str
    else:  # month
        month_start_local = local_now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
        since_utc = month_start_local.astimezone(_tz.utc)
        date_filter = month_str  # prefix match

    # ── Quelle 1: Claude Code JSONL → Abo-Wert ───────────────────────────
    by_model: dict = {}
    subscription_usd = 0.0

    pattern = str(Path.home() / ".claude" / "projects" / "**" / "*.jsonl")
    for path in _glob.glob(pattern, recursive=True):
        try:
            with open(path, encoding="utf-8", errors="replace") as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        obj = json.loads(line)
                        if obj.get("type") != "assistant":
                            continue
                        msg = obj.get("message", {})
                        if not isinstance(msg, dict):
                            continue
                        usage = msg.get("usage", {})
                        if not usage:
                            continue
                        ts_str = obj.get("timestamp", "")
                        if not ts_str:
                            continue
                        ts = _dt.fromisoformat(ts_str.replace("Z", "+00:00"))
                        if ts < since_utc:
                            continue
                        model = str(msg.get("model", "unknown") or "unknown")
                        inp = int(usage.get("input_tokens", 0) or 0)
                        out = int(usage.get("output_tokens", 0) or 0)
                        cw  = int(usage.get("cache_creation_input_tokens", 0) or 0)
                        cr  = int(usage.get("cache_read_input_tokens", 0) or 0)
                        cost_usd = _calc_cost_usd(model, usage)
                        subscription_usd += cost_usd
                        if model not in by_model:
                            by_model[model] = {"usd": 0.0, "input_tokens": 0,
                                               "output_tokens": 0, "cache_write": 0, "cache_read": 0}
                        by_model[model]["usd"]          += cost_usd
                        by_model[model]["input_tokens"]  += inp
                        by_model[model]["output_tokens"] += out
                        by_model[model]["cache_write"]   += cw
                        by_model[model]["cache_read"]    += cr
                    except (json.JSONDecodeError, ValueError, KeyError, TypeError):
                        continue
        except (IOError, PermissionError, OSError):
            continue

    subscription_eur = round(subscription_usd * KOSTEN_EUR_USD_RATE, 4)
    by_model_clean = {}
    for m, v in sorted(by_model.items(), key=lambda x: -x[1]["usd"]):
        by_model_clean[m] = {
            "eur": round(v["usd"] * KOSTEN_EUR_USD_RATE, 4),
            "usd": round(v["usd"], 4),
            "input_tokens":  v["input_tokens"],
            "output_tokens": v["output_tokens"],
            "cache_write":   v["cache_write"],
            "cache_read":    v["cache_read"],
        }

    # ── Quelle 2: costs.jsonl → echte API-Kosten ─────────────────────────
    real_api_usd = 0.0
    real_by_provider_usd: dict = {}

    rdw_costs = Path.home() / "Vibe Coding" / "Rat der Weisen" / "sessions" / "costs.jsonl"
    if rdw_costs.exists():
        try:
            with open(rdw_costs, encoding="utf-8", errors="replace") as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        obj = json.loads(line)
                        d = str(obj.get("date", ""))
                        if not d:
                            continue
                        if range_ == "today":
                            if d != date_filter:
                                continue
                        else:  # month
                            if not d.startswith(date_filter):
                                continue
                        cost = float(obj.get("estimated_cost_usd", 0) or 0)
                        model = str(obj.get("model", "") or "")
                        provider = str(obj.get("provider", "") or "")
                        # Infer provider from model name if not set
                        if not provider:
                            if any(x in model for x in ("gpt", "o1", "o3", "o4")):
                                provider = "openai"
                            elif "gemini" in model:
                                provider = "gemini"
                            elif "claude" in model:
                                provider = "anthropic"
                            elif any(x in model for x in ("sonar", "perplexity")):
                                provider = "perplexity"
                            else:
                                provider = "openai"
                        real_api_usd += cost
                        real_by_provider_usd[provider] = real_by_provider_usd.get(provider, 0.0) + cost
                    except (json.JSONDecodeError, ValueError, KeyError, TypeError):
                        continue
        except (IOError, PermissionError, OSError):
            pass

    real_api_eur = round(real_api_usd * KOSTEN_EUR_USD_RATE, 4)
    real_by_provider = {}
    for prov, usd_val in sorted(real_by_provider_usd.items(), key=lambda x: -x[1]):
        real_by_provider[prov] = {
            "eur": round(usd_val * KOSTEN_EUR_USD_RATE, 4),
            "usd": round(usd_val, 4),
        }

    return {
        # Echte API-Kosten (rdw.py etc.)
        "real_api_eur": real_api_eur,
        "real_api_usd": round(real_api_usd, 4),
        "real_by_provider": real_by_provider,
        # Abo-Wert (Claude Code, durch Max-Abo gedeckt)
        "subscription_value_eur": subscription_eur,
        "subscription_value_usd": round(subscription_usd, 4),
        "subscription_note": "Mit Claude Max Abo gedeckt — kein echter Betrag",
        "by_model": by_model_clean,
        # Meta
        "last_updated": now.isoformat(),
        "range": range_,
        "eur_usd_rate": KOSTEN_EUR_USD_RATE,
    }


def _tmux_cmd() -> list:
    """Gibt den tmux-Befehl mit explizitem Socket zurück.
    tmux-Prozesse die via nohup/launchd gestartet wurden haben keinen
    TMUX_TMPDIR in ihrer Umgebung — Socket-Pfad explizit angeben."""
    import getpass
    uid = os.getuid()
    socket = f'/private/tmp/tmux-{uid}/default'
    tmux_bin = '/opt/homebrew/bin/tmux'
    if not Path(tmux_bin).exists():
        tmux_bin = 'tmux'
    if Path(socket).exists():
        return [tmux_bin, '-S', socket]
    return [tmux_bin]


def _tmux_session_alive(session: str) -> bool:
    """Prüft ob eine tmux-Session noch läuft. Gibt False bei leerem session-Name zurück."""
    if not session:
        return False
    try:
        result = subprocess.run(
            _tmux_cmd() + ['has-session', '-t', session],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=3
        )
        return result.returncode == 0
    except Exception:
        return False


def _load_sancho_registry() -> list:
    """Liest sancho_registry.json und gibt Liste mit pane_id-Feld zurück. Gibt [] zurück bei Fehler."""
    if not SANCHO_REGISTRY.exists():
        return []
    try:
        raw = json.loads(SANCHO_REGISTRY.read_text(encoding='utf-8'))
        if not isinstance(raw, dict):
            return []
        result = []
        for pane_id, info in raw.items():
            if not isinstance(info, dict):
                continue
            # Einträge ohne Name/Session überspringen
            name = str(info.get('name', '') or info.get('birth_name', '')).strip()
            session = str(info.get('session', '')).strip()
            if not name and not session:
                continue
            result.append({
                'pane_id': pane_id,
                'name': name,
                'birth_name': str(info.get('birth_name', '')).strip(),
                'session': session,
                'born': str(info.get('born', '')),
                'last_seen': str(info.get('last_seen', '')),
                'last_seen_ts': info.get('last_seen_ts', 0),
            })
        return result
    except Exception:
        return []


def _lb_session_log(event_type: str, data: dict):
    """Append one event to today's Emil session JSONL — used by foto/kurzcheck/frage handlers."""
    import time as _t, json as _j
    sessions_dir = COCKPIT_DIR / 'lernbegleiter_sessions'
    sessions_dir.mkdir(exist_ok=True)
    today = __import__('datetime').date.today().isoformat()
    log_file = sessions_dir / f'{today}.jsonl'
    entry = {'ts': _t.time(), 'type': event_type, **data}
    with open(log_file, 'a', encoding='utf-8') as f:
        f.write(_j.dumps(entry, ensure_ascii=False) + '\n')


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

    def _serve_bytes(self, data, content_type, cache_control='no-store', include_body=True):
        self.send_response(200)
        self.send_header('Content-Type', content_type)
        self.send_header('Content-Length', len(data))
        self.send_header('Cache-Control', cache_control)
        self._cors()
        self.end_headers()
        if include_body:
            self.wfile.write(data)

    def _serve_cockpit_file(self, filename, content_type, cache_control='no-store', include_body=True):
        file_path = Path(COCKPIT_DIR) / filename
        if not file_path.exists():
            self._json(404, {'error': 'not found'})
            return
        self._serve_bytes(file_path.read_bytes(), content_type, cache_control, include_body)

    def _redirect(self, location):
        self.send_response(302)
        self.send_header('Location', location)
        self._cors()
        self.end_headers()

    def do_HEAD(self):
        parsed = urlparse(self.path)
        host = self.headers.get('Host', '')

        if host.startswith('voice.') and parsed.path in ('/', ''):
            self._redirect('/mikrofon')
            return
        if host.startswith('reisebericht.') and parsed.path in ('/', ''):
            self._redirect('/reisebericht.html')
            return
        if host.startswith('kameramotor.') and parsed.path in ('/', ''):
            self._redirect('/kameramotor.html')
            return
        if host.startswith('fotolabor.') and parsed.path in ('/', ''):
            self._redirect('/thecamera.html?labor=fotolabor')
            return
        if host.startswith('filmlabor.') and parsed.path in ('/', ''):
            self._redirect('/thecamera.html?labor=filmlabor')
            return
        if host.startswith('camera.') and parsed.path in ('/', ''):
            self._redirect('/thecamera.html')
            return

        head_routes = {
            ('/mikrofon', '/mikrofon/'): ('voice_v2.html', 'text/html; charset=utf-8', 'no-store'),
            ('/mikrofon.webmanifest',): ('mikrofon.webmanifest', 'application/manifest+json', 'no-store'),
            ('/mikrofon-v2', '/mikrofon-v2/'): ('mikrofon_v2.html', 'text/html; charset=utf-8', 'no-store'),
            ('/mikrofon-v2.webmanifest',): ('mikrofon-v2.webmanifest', 'application/manifest+json', 'no-store'),
            ('/alarme', '/alarme/', '/alarmzentrale', '/alarmzentrale/'): ('alarmzentrale.html', 'text/html; charset=utf-8', 'no-store'),
            ('/alarmzentrale.webmanifest',): ('alarmzentrale.webmanifest', 'application/manifest+json', 'no-store'),
            ('/thecamera', '/thecamera/'): ('thecamera.html', 'text/html; charset=utf-8', 'no-store'),
            ('/mathe-mai', '/mathe-mai/'): ('mathe-lernhilfe.html', 'text/html; charset=utf-8', 'no-store'),
            ('/emil/spickzettel', '/spickzettel'): ('emil_spickzettel.html', 'text/html; charset=utf-8', 'no-store'),
            ('/emil/rueckseite', '/rueckseite'): ('emil_rueckseite.html', 'text/html; charset=utf-8', 'no-store'),
        }
        for paths, route in head_routes.items():
            if parsed.path in paths:
                self._serve_cockpit_file(*route, include_body=False)
                return

        super().do_HEAD()

    def _voice_v32_proxy(self, body=b''):
        """Bridge the recovered Voice Input V3.2 UI through the fixed Cockpit door."""
        parsed = urlparse(self.path)
        if not parsed.path.startswith('/api/voice-v32/'):
            return False
        target_path = '/api/' + parsed.path[len('/api/voice-v32/'):]
        if parsed.query:
            target_path += '?' + parsed.query
        url = 'http://127.0.0.1:9090' + target_path
        try:
            def _once():
                headers = {}
                ctype = self.headers.get('Content-Type')
                if ctype:
                    headers['Content-Type'] = ctype
                method = 'POST' if self.command == 'POST' else 'GET'
                req = _urlrequest.Request(
                    url,
                    data=(body if method == 'POST' else None),
                    headers=headers,
                    method=method,
                )
                with _urlrequest.urlopen(req, timeout=4) as resp:
                    return resp.read(), resp.headers.get('Content-Type', 'application/json')
            try:
                data, content_type = _once()
            except Exception:
                bridge_dir = Path('/Users/victorholland/Vibe Coding/Voice Input')
                log_fh = open('/tmp/voice_bridge.log', 'ab')
                subprocess.Popen(
                    ['/opt/homebrew/bin/python3', '-u', 'voice_bridge_server.py'],
                    cwd=str(bridge_dir),
                    stdin=subprocess.DEVNULL,
                    stdout=log_fh,
                    stderr=log_fh,
                    start_new_session=True,
                )
                import time as _time_v32
                for _ in range(12):
                    _time_v32.sleep(0.25)
                    try:
                        data, content_type = _once()
                        break
                    except Exception:
                        data, content_type = None, None
                if data is None:
                    raise
            self.send_response(200)
            self.send_header('Content-Type', content_type)
            self.send_header('Content-Length', len(data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(data)
        except Exception as e:
            self._json(502, {
                'ok': False,
                'error': 'Voice Input V3.2 ist lokal noch nicht gestartet',
                'detail': str(e)[:160],
            })
        return True

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.end_headers()

    def do_POST(self):
        te = self.headers.get('Transfer-Encoding', '')
        if 'chunked' in te.lower():
            chunks = []
            while True:
                try:
                    size_line = self.rfile.readline().decode('ascii', errors='replace').strip()
                    if not size_line:
                        continue
                    chunk_size = int(size_line.split(';')[0], 16)
                    if chunk_size == 0:
                        break
                    chunks.append(self.rfile.read(chunk_size))
                    self.rfile.read(2)  # CRLF nach chunk
                except Exception:
                    break
            body = b''.join(chunks)
        else:
            length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(length)
        if self._voice_v32_proxy(body):
            return
        if self.path == '/api/wohnungen/ratings':
            try:
                data = json.loads(body)
                addr = re.sub(r'[^a-z0-9_]', '_', (data.get('addr') or 'unknown').lower())
                ratings = data.get('ratings', {})
                out = COCKPIT_DIR / f'wohnungen_ratings_{addr}.json'
                out.write_text(json.dumps(ratings, indent=2, ensure_ascii=False))
                self._json(200, {'status': 'ok'})
            except Exception as e:
                self._json(500, {'error': str(e)})
        elif self.path == '/api/wohnung/save':
            try:
                data = json.loads(body)
                out = COCKPIT_DIR / 'wohnung_data.json'
                out.write_text(json.dumps(data, indent=2, ensure_ascii=False))
                self._json(200, {'status': 'ok'})
            except Exception as e:
                self._json(500, {'error': str(e)})
        elif self.path == '/api/netzwerk/save':
            try:
                data = json.loads(body)
                out = COCKPIT_DIR / 'netzwerk_data.json'
                out.write_text(json.dumps(data, indent=2, ensure_ascii=False))
                self._json(200, {'status': 'ok'})
            except Exception as e:
                self._json(500, {'error': str(e)})
        elif self.path == '/api/lernbegleiter/hilfe':
            try:
                data = json.loads(body)
                topic = data.get('topic', 'Mathematik')
                blatt = data.get('blatt', '')
                block_num = data.get('block_num', 1)
                claude_key = _su_anthropic_key()
                if not claude_key:
                    self._json(500, {'error': 'Kein Anthropic-Key'}); return
                import urllib.request as _urlreqH, json as _jsonH
                prompt = (
                    f"Du erklärst Emil (14 Jahre, ADHS, Klasse 8, Mathe-Schularbeit morgen) das Thema '{topic}' ({blatt}, Block {block_num} von 6).\n\n"
                    "Schreib eine Schritt-für-Schritt-Erklärung in einfacher Alltagssprache:\n"
                    "1. Das Grundprinzip in 2-3 Sätzen — mit einem Alltagsbild oder Vergleich\n"
                    "2. Die wichtigste Formel oder Methode — klar und groß\n"
                    "3. Ein durchgerechnetes Beispiel — jeden Schritt einzeln kommentiert\n"
                    "4. Der häufigste Fehler — und wie man ihn vermeidet\n"
                    "5. Ein Merksatz (1 Satz) der alles zusammenfasst\n\n"
                    "Schreib auf Deutsch. Keine Fachbegriffe ohne Erklärung. Maximal 350 Wörter. "
                    "Formatiere mit kurzen Abschnitten und Zeilenumbrüchen — kein Fließtext."
                )
                payload = {
                    "model": "claude-sonnet-4-6",
                    "max_tokens": 700,
                    "messages": [{"role": "user", "content": prompt}]
                }
                req = _urlreqH.Request(
                    'https://api.anthropic.com/v1/messages',
                    data=_jsonH.dumps(payload).encode(),
                    headers={'Content-Type': 'application/json', 'x-api-key': claude_key, 'anthropic-version': '2023-06-01'}
                )
                with _urlreqH.urlopen(req, timeout=30) as r:
                    result = _jsonH.loads(r.read())
                self._json(200, {'erklaerung': result['content'][0]['text']})
            except Exception as e:
                self._json(500, {'error': str(e)})
        elif self.path == '/api/lernbegleiter/save':
            try:
                data = json.loads(body)
                out = COCKPIT_DIR / 'lernbegleiter_state.json'
                import time as _time
                data['_saved_at'] = _time.time()
                _tmp = out.with_suffix('.tmp')
                _tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
                _tmp.replace(out)
                self._json(200, {'status': 'ok'})
            except Exception as e:
                self._json(500, {'error': str(e)})
        elif self.path == '/api/lernbegleiter/kurzcheck':
            try:
                data = json.loads(body)
                question = data.get('question', '')
                answer = data.get('answer', '')
                topic = data.get('topic', 'Mathematik')
                blatt = data.get('blatt', '')
                claude_key = _su_anthropic_key()
                if not claude_key:
                    self._json(500, {'error': 'Kein Anthropic-Key'}); return
                import urllib.request as _urlreq3, json as _json4
                prompt = (
                    f"Emil (14, ADHS, Klasse 8, Mathe-Arbeit morgen) hat gerade '{topic}' geübt ({blatt}).\n\n"
                    f"Frage: {question}\n\n"
                    f"Emils Antwort: {answer}\n\n"
                    "Bewerte seine Antwort kurz und freundlich:\n"
                    "- Was ist richtig daran? (immer zuerst)\n"
                    "- Was fehlt oder ist ungenau? (konkret, ein Satz)\n"
                    "- Die vollständige richtige Antwort in 2-3 Sätzen — einfach, klar, ohne Fachjargon\n"
                    "- Ein kurzer aufmunternder Abschluss\n\n"
                    "Maximal 6 Sätze gesamt. Warm, direkt, kein Lehrerton."
                )
                payload = {
                    "model": "claude-sonnet-4-6",
                    "max_tokens": 400,
                    "messages": [{"role": "user", "content": prompt}]
                }
                req = _urlreq3.Request(
                    'https://api.anthropic.com/v1/messages',
                    data=_json4.dumps(payload).encode(),
                    headers={'Content-Type': 'application/json', 'x-api-key': claude_key, 'anthropic-version': '2023-06-01'}
                )
                with _urlreq3.urlopen(req, timeout=30) as r:
                    result = _json4.loads(r.read())
                feedback_text = result['content'][0]['text']
                _lb_session_log('kurzcheck', {
                    'topic': topic, 'blatt': blatt,
                    'question': question, 'answer': answer, 'feedback': feedback_text
                })
                self._json(200, {'feedback': feedback_text})
            except Exception as e:
                self._json(500, {'error': str(e)})
        elif self.path == '/api/lernbegleiter/frage':
            try:
                data = json.loads(body)
                history = data.get('history', [])
                topic = data.get('topic', 'Mathematik')
                blatt = data.get('blatt', '')
                block_num = data.get('block_num', 1)
                total_blocks = data.get('total_blocks', 6)
                elapsed_pct = data.get('elapsed_pct', 0)
                phase_type = data.get('phase_type', 'work')
                claude_key = _su_anthropic_key()
                if not claude_key:
                    self._json(500, {'error': 'Kein Anthropic-Key'}); return
                import urllib.request as _urlreq2, json as _json3
                system_msg = (
                    "Du begleitest Emil durch seinen gesamten Lernabend. "
                    f"Emil ist 14 Jahre alt, hat ADHS (diagnostiziert), und schreibt morgen eine Mathe-Schularbeit in Klasse 8 Gymnasium (aktuelle Note: 4).\n\n"
                    f"Aktueller Stand: Block {block_num} von {total_blocks}, Thema: '{topic}'"
                    + (f" ({blatt})" if blatt else "")
                    + f", {int(elapsed_pct)}% des Blocks sind rum.\n\n"
                    "Die 5 Themen heute Abend (in dieser Reihenfolge):\n"
                    "1. Lineare Funktionen — y=mx+n, Steigung m, y-Achsenabschnitt n, Gleichung aus 2 Punkten, Nullstelle\n"
                    "2. Satz des Pythagoras — a²+b²=c², alle 3 Varianten, Höhe im gleichschenk. Dreieck, Raumdiagonale\n"
                    "3. LGS algebraisch — Gleichsetzen-Verfahren, Einsetzen, Addition\n"
                    "4. LGS grafisch — Wertetabelle, Geraden mit Lineal einzeichnen, Schnittpunkt ablesen\n"
                    "5. LGS Textaufgaben — Variablen benennen, Gleichungen aufstellen, Antwortsatz\n\n"
                    "Wie du mit Emil sprichst:\n"
                    "- Du bist der ruhige ältere Freund, nicht der Lehrer — warmherzig, nie ungeduldig\n"
                    "- Schritt für Schritt, maximal 3-4 Sätze pro Erklärungsschritt\n"
                    "- Alltagssprache — jeden Fachbegriff sofort mit einem Bild oder Beispiel erklären\n"
                    "- Wenn etwas falsch ist: zuerst was richtig war, dann sanft korrigieren, dann Lösungsweg zeigen\n"
                    "- Nie: 'Das ist doch einfach', 'Das hatten wir schon', 'Das solltest du wissen'\n"
                    "- Am Ende jeder Antwort: eine kurze Einladung — 'Macht das Sinn?' oder 'Magst du es kurz selbst versuchen?'\n"
                    "- Wenn Emil nachhakt: anders erklären, neue Analogie finden, Geduld zeigen\n"
                    "- Du kennst seinen Fortschritt: was er schon geschafft hat, was noch kommt\n"
                    "- Wenn er frustriert klingt: erst kurz anerkennen, dann sachlich weiterhelfen"
                )
                import urllib.request as _urlreq2, json as _json3
                payload = {
                    "model": "claude-sonnet-4-6",
                    "max_tokens": 600,
                    "system": system_msg,
                    "messages": [
                        {"role": m["role"], "content": m["content"]}
                        for m in history
                        if m["role"] in ("user", "assistant")
                    ]
                }
                req = _urlreq2.Request(
                    'https://api.anthropic.com/v1/messages',
                    data=_json3.dumps(payload).encode(),
                    headers={
                        'Content-Type': 'application/json',
                        'x-api-key': claude_key,
                        'anthropic-version': '2023-06-01'
                    }
                )
                with _urlreq2.urlopen(req, timeout=30) as r:
                    result = _json3.loads(r.read())
                answer = result['content'][0]['text']
                last_user = next((m['content'] for m in reversed(history) if m['role'] == 'user'), '')
                _lb_session_log('chat', {
                    'topic': topic, 'blatt': blatt,
                    'block_num': block_num, 'total_blocks': total_blocks,
                    'question': last_user, 'answer': answer
                })
                self._json(200, {'answer': answer})
            except Exception as e:
                self._json(500, {'error': str(e)})
        elif self.path == '/api/lernbegleiter/foto':
            try:
                data = json.loads(body)
                image_b64 = data.get('image', '')
                topic = data.get('topic', 'Mathematik')
                blatt = data.get('blatt', 'das Rechenblatt')
                if not image_b64:
                    self._json(400, {'error': 'Kein Bild'}); return
                claude_key = _su_anthropic_key()
                if not claude_key:
                    self._json(500, {'error': 'Kein Anthropic-Key'}); return
                import urllib.request as _urlreq, json as _json2
                prompt = (
                    f"Du bist Emils freundlicher Mathe-Nachhilfelehrer. Emil ist 14 Jahre alt, "
                    f"hat ADHS und schreibt morgen eine Schularbeit in Klasse 8 Gymnasium. "
                    f"Er hat gerade '{topic}' geübt ({blatt}).\n\n"
                    "WICHTIG — prüfe zuerst die Lesbarkeit:\n"
                    "Wenn die Handschrift so unleserlich ist, dass du eine oder mehrere Aufgaben "
                    "nicht auswerten kannst, antworte AUSSCHLIESSLICH mit diesem JSON (keine Korrektur):\n"
                    '{"illegible": true, "illegible_detail": "Ich kann [was genau] leider nicht entziffern."}\n\n'
                    "Nur wenn alles ausreichend lesbar ist:\n"
                    "Analysiere das handgeschriebene Rechenblatt sorgfältig und gib eine ausführliche, "
                    "warme Korrektur in vier Abschnitten:\n"
                    "1. ✓ Was Emil richtig gemacht hat (konkret, mit echtem Lob)\n"
                    "2. → Welche Fehler passiert sind (Schritt für Schritt erklären, wo genau)\n"
                    "3. → Wie man es richtig macht (kurzer, klarer Lösungsweg)\n"
                    "4. Ein persönlicher, aufmunternder Abschlusssatz für morgen.\n\n"
                    "Schreibe auf Deutsch, in einfacher warmer Sprache — keine Fremdwörter, keine Lehrerdistanz. "
                    "Kurze Absätze. Antworte NUR mit dem Korrekturtext, kein JSON."
                )
                payload = {
                    "model": "claude-sonnet-4-6",
                    "max_tokens": 900,
                    "messages": [{
                        "role": "user",
                        "content": [
                            {"type": "image", "source": {
                                "type": "base64",
                                "media_type": "image/jpeg",
                                "data": image_b64
                            }},
                            {"type": "text", "text": prompt}
                        ]
                    }]
                }
                req = _urlreq.Request(
                    'https://api.anthropic.com/v1/messages',
                    data=_json2.dumps(payload).encode(),
                    headers={
                        'Content-Type': 'application/json',
                        'x-api-key': claude_key,
                        'anthropic-version': '2023-06-01'
                    }
                )
                with _urlreq.urlopen(req, timeout=60) as r:
                    result = _json2.loads(r.read())
                raw = result['content'][0]['text'].strip()
                if raw.startswith('{') and '"illegible"' in raw:
                    try:
                        parsed_raw = _json2.loads(raw)
                    except Exception:
                        parsed_raw = {'illegible': True, 'illegible_detail': raw}
                    _lb_session_log('foto', {
                        'topic': topic, 'blatt': blatt,
                        'illegible': True,
                        'illegible_detail': parsed_raw.get('illegible_detail', ''),
                        'image_b64': image_b64
                    })
                    self._json(200, parsed_raw)
                else:
                    _lb_session_log('foto', {
                        'topic': topic, 'blatt': blatt,
                        'illegible': False,
                        'correction': raw,
                        'image_b64': image_b64
                    })
                    self._json(200, {'correction': raw})
            except Exception as e:
                self._json(500, {'error': str(e)})
        elif self.path == '/api/sancho/start':
            try:
                data = json.loads(body) if body else {}
                name = str(data.get('name', '')).strip()
                if not name:
                    self._json(400, {'ok': False, 'error': 'name required'})
                    return
                launcher = Path(__file__).parent.parent / 'Sancho starten.command'
                subprocess.Popen(
                    ['bash', str(launcher), name],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    start_new_session=True
                )
                self._json(200, {'ok': True, 'name': name})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path == '/api/sancho/send':
            # Sendet eine Nachricht an eine tmux-Session via send-keys.
            # Body: {session: str, message: str}
            try:
                data = json.loads(body) if body else {}
                session = str(data.get('session', '')).strip()
                message = str(data.get('message', '')).strip()
                if not session:
                    self._json(400, {'ok': False, 'error': 'session required'})
                    return
                if not message:
                    self._json(400, {'ok': False, 'error': 'message required'})
                    return
                # Sicherheitsprüfung: Session-Name nur erlaubte Zeichen
                import re as _re
                if not _re.match(r'^[a-zA-Z0-9_\-:\.]+$', session):
                    self._json(400, {'ok': False, 'error': 'session enthält ungültige Zeichen'})
                    return
                # Prüfen ob Session läuft
                if not _tmux_session_alive(session):
                    self._json(200, {'ok': False, 'error': f'Session {session!r} nicht gefunden — Sancho schläft?'})
                    return
                # Nachricht senden — tmux send-keys bekommt Text als Argument,
                # kein Shell-Quoting nötig. Socket explizit übergeben.
                result = subprocess.run(
                    _tmux_cmd() + ['send-keys', '-t', session, message, 'Enter'],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.PIPE,
                    timeout=5
                )
                if result.returncode != 0:
                    err = result.stderr.decode(errors='replace').strip()
                    self._json(200, {'ok': False, 'error': f'tmux Fehler: {err}'})
                else:
                    self._json(200, {'ok': True})
            except subprocess.TimeoutExpired:
                self._json(200, {'ok': False, 'error': 'tmux Timeout'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path == '/api/prefetch_photos':
            _prefetch_all_events()
            pending = sum(1 for v in _FETCH_QUEUE.values() if v == 'pending')
            done = sum(1 for v in _FETCH_QUEUE.values() if v == 'done')
            self._json(200, {'ok': True, 'pending': pending, 'done': done})
        elif self.path == '/api/alarms/ack':
            try:
                data = json.loads(body) if body else {}
                alarm_id = str(data.get('id', '') or data.get('alarm_id', '')).strip()
                if not alarm_id:
                    self._json(400, {'ok': False, 'error': 'id required'})
                    return
                ack = _alarm_ack(
                    alarm_id,
                    actor=str(data.get('actor', 'victor') or 'victor')[:80],
                    note=str(data.get('note', '') or '')[:500],
                )
                self._json(200, {'ok': True, 'ack': ack, 'state': _alarm_state()})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path == '/api/alarms/create':
            try:
                data = json.loads(body) if body else {}
                event = _alarm_append(
                    title=str(data.get('title', 'System-Alarm'))[:180],
                    message=str(data.get('message', 'Ein System braucht Victor.'))[:1200],
                    priority=str(data.get('priority', 'high'))[:40],
                    source=str(data.get('source', 'cockpit'))[:120],
                    tag=str(data.get('tag', 'warning'))[:80],
                    click_url=str(data.get('click_url', 'https://cockpit.beachorchestra.com/alarme'))[:500],
                )
                if data.get('notify'):
                    subprocess.Popen(
                        [str(COCKPIT_DIR.parent / 'tools' / 'system_alert.sh'),
                         event['title'], event['message'], event['tag'], event['priority'], event['source'], event['click_url']],
                        stdout=subprocess.DEVNULL,
                        stderr=subprocess.DEVNULL,
                        start_new_session=True,
                    )
                self._json(200, {'ok': True, 'event': event})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path == '/api/apple/device-token':
            try:
                data = json.loads(body) if body else {}
                result = _apple_register_device_token(data)
                self._json(200, result)
            except Exception as e:
                self._json(400, {'ok': False, 'error': str(e)})
        elif self.path == '/api/version/bump':
            try:
                import time as _vt, json as _vj
                _vdata = _vj.loads(body.decode('utf-8')) if body else {}
                _vpage = _vdata.get('page', 'unknown')
                _vmsg  = _vdata.get('change', '')
                _vfile = COCKPIT_DIR.parent / 'version_bumps.json'
                _vstore = {}
                if _vfile.exists():
                    try: _vstore = _vj.loads(_vfile.read_text())
                    except: pass
                _vstore[_vpage] = {'version': int(_vt.time() * 1000), 'change': _vmsg, 'ts': _vt.time()}
                _vfile.write_text(_vj.dumps(_vstore, indent=2))
                self._json(200, {'status': 'ok', 'page': _vpage, 'version': _vstore[_vpage]['version']})
            except Exception as _ve:
                self._json(200, {'status': 'ok'})
        elif self.path == '/api/stimmen/feedback':
            # Speichert Stimmen-Bewertungen atomisch als JSON + JSONL-Log
            try:
                import tempfile as _tf2
                data = json.loads(body) if body else {}
                dest_json = WATSON_VOICES_DIR / 'stimmen_bewertungen.json'
                dest_jsonl = WATSON_VOICES_DIR / 'stimmen_bewertungen.jsonl'
                # Bestehende Daten laden und mergen
                existing = {}
                if dest_json.exists():
                    try:
                        existing = json.loads(dest_json.read_text(encoding='utf-8'))
                    except Exception:
                        existing = {}
                existing.update(data)
                # Atomar schreiben via Temp-Datei
                with _tf2.NamedTemporaryFile(
                    mode='w', encoding='utf-8', delete=False,
                    dir=str(WATSON_VOICES_DIR), suffix='.tmp'
                ) as tmp:
                    json.dump(existing, tmp, ensure_ascii=False, indent=2)
                    tmp_path = tmp.name
                os.replace(tmp_path, str(dest_json))
                # JSONL-Backup (append-only)
                import datetime as _dt2
                _append_jsonl(dest_jsonl, {'ts': _dt2.datetime.now().isoformat(), 'data': data})
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path in ('/api/feedback', '/api/design_feedback'):
            # Accepts a JSON array of feedback entries.
            # Each entry is saved as a JSONL line in lexikon/design_feedback.jsonl.
            try:
                entries = json.loads(body)
                if isinstance(entries, dict):
                    entries = [entries]
                if not isinstance(entries, list):
                    self._json(400, {'ok': False, 'error': 'expected array'})
                    return
                dest = LEXIKON_DIR / 'design_feedback.jsonl'
                for entry in entries:
                    if isinstance(entry, dict):
                        _append_jsonl(dest, entry)
                self._json(200, {'ok': True, 'saved': len(entries)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path == '/api/design_eval':
            # Accepts a single eval entry (dict).
            try:
                entry = json.loads(body)
                if isinstance(entry, list):
                    entry = entry[0] if entry else {}
                if not isinstance(entry, dict):
                    self._json(400, {'ok': False, 'error': 'expected object'})
                    return
                dest = LEXIKON_DIR / 'design_eval.jsonl'
                _append_jsonl(dest, entry)
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path == '/api/design_standard':
            # Saves a "wird Standard für" entry per principle.
            try:
                entry = json.loads(body)
                if isinstance(entry, list):
                    entry = entry[0] if entry else {}
                dest = LEXIKON_DIR / 'design_standards.jsonl'
                _append_jsonl(dest, entry)
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path.startswith('/api/inbox/'):
            role = self.path[len('/api/inbox/'):].strip('/')
            if not role or not all(c.isalnum() or c in '-_' for c in role):
                self._json(400, {'ok': False, 'error': 'invalid role'})
                return
            try:
                data = json.loads(body) if body else {}
                msg = str(data.get('message', '')).strip()
                sender = str(data.get('sender', 'watson')).strip()
                if not msg:
                    self._json(400, {'ok': False, 'error': 'message required'})
                    return
                import datetime
                ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
                INBOX_DIR.mkdir(exist_ok=True)
                inbox_file = INBOX_DIR / f'{role}.md'
                with inbox_file.open('a', encoding='utf-8') as fh:
                    fh.write(f'\n---\n**{sender}** ({ts}):\n{msg}\n')
                self._json(200, {'ok': True, 'to': role})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path == '/api/zotify/auth':
            # Zotify/librespot broken — not used.
            self._json(200, {'ok': False, 'error': 'Zotify nicht verfügbar'})

        elif self.path == '/api/votify/setup':
            try:
                data = json.loads(body) if body else {}
                sp_dc = str(data.get('sp_dc', '')).strip()
                if not sp_dc:
                    self._json(400, {'ok': False, 'error': 'sp_dc erforderlich'})
                    return
                cookies_dir = Path.home() / 'Library/Application Support/Votify'
                cookies_dir.mkdir(parents=True, exist_ok=True)
                cookies_file = cookies_dir / 'cookies.txt'
                cookies_file.write_text(
                    f'# Netscape HTTP Cookie File\n'
                    f'.spotify.com\tTRUE\t/\tTRUE\t9999999999\tsp_dc\t{sp_dc}\n'
                )
                # Quick auth test
                result = subprocess.run(
                    ['votify', '--cookies-path', str(cookies_file),
                     '--session-type', 'web', '--no-exceptions',
                     'https://open.spotify.com/track/11dFghVXANMlKmJXsNCbNl'],
                    capture_output=True, text=True, timeout=30,
                    env={**os.environ, 'PATH': '/opt/homebrew/bin:/usr/bin:/bin'},
                    cwd='/tmp'
                )
                out = (result.stdout + result.stderr).lower()
                if 'error' in out and 'invalid' in out:
                    self._json(200, {'ok': False, 'error': 'sp_dc ungültig oder abgelaufen'})
                else:
                    self._json(200, {'ok': True, 'message': 'Spotify verbunden ✓'})
            except subprocess.TimeoutExpired:
                cookies_file = Path.home() / 'Library/Application Support/Votify/cookies.txt'
                self._json(200, {'ok': cookies_file.exists(), 'message': 'Gespeichert (Timeout OK)'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:200]})

        elif self.path == '/api/votify/download':
            try:
                data = json.loads(body) if body else {}
                url = str(data.get('url', '')).strip()
                dest = str(data.get('dest', 'spotify')).strip()
                if not url or not url.startswith('https://open.spotify.com/'):
                    self._json(400, {'ok': False, 'error': 'Spotify-URL erforderlich'})
                    return
                cookies_file = Path.home() / 'Library/Application Support/Votify/cookies.txt'
                if not cookies_file.exists():
                    self._json(400, {'ok': False, 'error': 'Nicht eingerichtet — sp_dc fehlt'})
                    return
                job_id = str(uuid.uuid4())[:8]
                ZOTIFY_JOBS[job_id] = {'status': 'running', 'url': url, 'log': ''}
                out_dir = WATSON_VOICES_DIR / 'raw_sources' / dest
                out_dir.mkdir(parents=True, exist_ok=True)

                def run_votify(jid, spotify_url, output_dir, cfile):
                    try:
                        proc = subprocess.run(
                            ['votify',
                             '--cookies-path', str(cfile),
                             '--session-type', 'web',
                             '--audio-quality', 'vorbis-high,aac-high,vorbis-medium',
                             '--output', str(output_dir),
                             spotify_url],
                            capture_output=True, text=True, timeout=7200,
                            env={**os.environ, 'PATH': '/opt/homebrew/bin:/usr/bin:/bin'},
                            cwd=str(output_dir)
                        )
                        ZOTIFY_JOBS[jid]['status'] = 'done' if proc.returncode == 0 else 'error'
                        ZOTIFY_JOBS[jid]['log'] = (proc.stdout + proc.stderr)[-2000:]
                    except Exception as ex:
                        ZOTIFY_JOBS[jid]['status'] = 'error'
                        ZOTIFY_JOBS[jid]['log'] = str(ex)

                t = threading.Thread(target=run_votify, args=(job_id, url, out_dir, cookies_file), daemon=True)
                t.start()
                self._json(200, {'ok': True, 'job_id': job_id})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/voice/transcribe':
            # Push-to-Talk Transkription via gpt-4o-transcribe
            try:
                import tempfile as _tf
                # body wurde bereits am Anfang von do_POST gelesen
                audio_data = body
                if not audio_data:
                    self._json(400, {'ok': False, 'error': 'Kein Audio'})
                    return
                openai_key = _load_openai_key()
                if not openai_key:
                    self._json(400, {'ok': False, 'error': 'Kein OPENAI_API_KEY in credentials.env'})
                    return
                from openai import OpenAI as _OAI
                client = _OAI(api_key=openai_key)
                ext = self.headers.get('X-Audio-Ext', 'webm')
                vocab = self.headers.get('X-Vocabulary', '').strip()
                if not vocab:
                    try:
                        glossary_file = Path(__file__).parent.parent / 'victor_input' / 'glossary' / 'glossary.json'
                        if glossary_file.exists():
                            glossary = json.loads(glossary_file.read_text(encoding='utf-8'))
                            vocab = str(glossary.get('prompt', '')).strip()
                    except Exception:
                        vocab = ''
                mime = 'audio/mp4' if ext == 'mp4' else 'audio/webm'
                with _tf.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as f:
                    f.write(audio_data)
                    tmp = f.name
                remux_tmp = None
                wav_tmp = None
                try:
                    # PRIMÄR — WebM-Remux: repariert Placeholder-Duration im Header (~80ms, verlustlos)
                    audio_file = tmp
                    audio_ext = ext
                    audio_mime = mime
                    if ext == 'webm':
                        try:
                            ffmpeg_bin = '/opt/homebrew/bin/ffmpeg'
                            if not os.path.isfile(ffmpeg_bin):
                                import shutil as _shutil
                                ffmpeg_bin = _shutil.which('ffmpeg') or ffmpeg_bin
                            remux_tmp = tmp + '_remux.webm'
                            proc = subprocess.run(
                                [ffmpeg_bin, '-y', '-v', 'error', '-i', tmp, '-c:a', 'copy', remux_tmp],
                                capture_output=True, timeout=30
                            )
                            if proc.returncode == 0:
                                os.unlink(tmp)
                                audio_file = remux_tmp
                                remux_tmp = None  # wird jetzt als audio_file verwaltet
                        except Exception:
                            pass  # Remux fehlgeschlagen — Original weiterverwenden

                    def _do_transcribe(filepath, fname, fmime):
                        with open(filepath, 'rb') as af:
                            kw = dict(
                                model='gpt-4o-transcribe',
                                file=(fname, af, fmime),
                                response_format='text',
                                language='de',
                            )
                            if vocab:
                                kw['prompt'] = vocab
                            return client.audio.transcriptions.create(**kw)

                    try:
                        result = _do_transcribe(audio_file, f'audio.{audio_ext}', audio_mime)
                    except Exception as first_err:
                        # SEKUNDÄR — Retry als WAV bei 400/corrupted-Fehler
                        err_str = str(first_err)
                        if any(x in err_str for x in ('400', 'invalid_value', 'corrupted')):
                            try:
                                ffmpeg_bin = '/opt/homebrew/bin/ffmpeg'
                                if not os.path.isfile(ffmpeg_bin):
                                    import shutil as _shutil
                                    ffmpeg_bin = _shutil.which('ffmpeg') or ffmpeg_bin
                                wav_tmp = audio_file + '_retry.wav'
                                proc2 = subprocess.run(
                                    [ffmpeg_bin, '-y', '-v', 'error', '-i', audio_file,
                                     '-ar', '16000', '-ac', '1', wav_tmp],
                                    capture_output=True, timeout=30
                                )
                                if proc2.returncode == 0:
                                    result = _do_transcribe(wav_tmp, 'audio.wav', 'audio/wav')
                                else:
                                    raise first_err
                            except Exception as retry_err:
                                if retry_err is not first_err:
                                    raise first_err
                                raise
                        else:
                            raise

                    text = result.strip() if isinstance(result, str) else result.text.strip()
                    self._json(200, {'ok': True, 'text': text})
                finally:
                    for _f in (audio_file, remux_tmp, wav_tmp):
                        if _f:
                            try:
                                os.unlink(_f)
                            except Exception:
                                pass
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/voice/save_transcript':
            try:
                import datetime as _dt_vs
                data = json.loads(body.decode('utf-8')) if body else {}
                text = data.get('text', '')
                path = Path(__file__).parent.parent / 'victor_input' / 'transcripts'
                path.mkdir(parents=True, exist_ok=True)
                date_str = _dt_vs.datetime.now().strftime('%Y-%m-%d')
                transcript_file = path / f'transcript_{date_str}.txt'
                with open(transcript_file, 'a', encoding='utf-8') as f:
                    f.write(f'[{_dt_vs.datetime.now().isoformat()}] {text}\n')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/voice/intake':
            try:
                import datetime as _dt_vi
                import re as _re_vi
                data = json.loads(body.decode('utf-8')) if body else {}
                text = str(data.get('text', '')).strip()
                if not text:
                    self._json(400, {'ok': False, 'error': 'Kein Text'}); return
                tasks_file = Path(__file__).parent.parent / 'WATSON_AUFGABEN.md'
                ts = _dt_vi.datetime.now().strftime('%Y-%m-%d %H:%M')
                task_id = 'VE1'
                if tasks_file.exists():
                    existing = tasks_file.read_text(encoding='utf-8')
                    ve_nums = [int(n) for n in _re_vi.findall(r'\bVE(\d+)\b', existing)]
                    task_id = 'VE' + str(max(ve_nums) + 1 if ve_nums else 1)
                preview = (text[:100] + '…') if len(text) > 100 else text
                entry_line = f'| {task_id} | **Sprach-Entwurf** — {preview} | Diktat {ts} | — |\n'
                section_header = '\n## Voice-Entwürfe (Diktat — unklassifiziert)\n'
                table_header = '| # | Aufgabe | Kontext | Deadline |\n|---|---|---|---|\n'
                if tasks_file.exists():
                    content = tasks_file.read_text(encoding='utf-8')
                    if 'Voice-Entwürfe (Diktat' in content:
                        m = _re_vi.search(r'(## Voice-Entwürfe \(Diktat[^\n]*\n\|[^\n]+\n\|[-| ]+\n)', content)
                        if m:
                            insert_pos = m.end()
                            content = content[:insert_pos] + entry_line + content[insert_pos:]
                        else:
                            content += entry_line
                    else:
                        content += section_header + table_header + entry_line
                    tasks_file.write_text(content, encoding='utf-8')
                self._json(200, {'ok': True, 'task_id': task_id})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/voice/debug_log':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                log_path = Path(__file__).parent.parent / 'victor_input' / 'debug' / 'voice_debug.log'
                log_path.parent.mkdir(parents=True, exist_ok=True)
                with open(log_path, 'a') as f:
                    msg_text = data.get('message', data.get('msg', ''))
                    f.write(f"{msg_text}\n")
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/victor_input/transcribe_polished':
            # gpt-4o-transcribe + GPT-4o-mini Bügelung → {raw, polished}
            try:
                import tempfile as _tf2
                audio_data = body
                if not audio_data:
                    self._json(400, {'ok': False, 'error': 'Kein Audio'}); return
                openai_key = _load_openai_key()
                if not openai_key:
                    self._json(400, {'ok': False, 'error': 'Kein OPENAI_API_KEY'}); return
                from openai import OpenAI as _OAI2
                client2 = _OAI2(api_key=openai_key)
                ext = self.headers.get('X-Audio-Ext', 'webm')
                vocab = self.headers.get('X-Vocabulary', '').strip()
                mime = 'audio/mp4' if ext == 'mp4' else 'audio/webm'
                with _tf2.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as f2:
                    f2.write(audio_data)
                    tmp2 = f2.name
                try:
                    # Schritt 1: Transkription
                    with open(tmp2, 'rb') as af2:
                        kwargs2 = dict(model='gpt-4o-transcribe', file=(f'audio.{ext}', af2, mime),
                                       response_format='text', language='de')
                        if vocab:
                            kwargs2['prompt'] = vocab
                        raw_result = client2.audio.transcriptions.create(**kwargs2)
                    raw_text = raw_result.strip() if isinstance(raw_result, str) else raw_result.text.strip()
                    # Schritt 2: GPT-4o-mini Bügelung
                    polish_resp = client2.chat.completions.create(
                        model='gpt-4o-mini',
                        max_tokens=500,
                        messages=[
                            {'role': 'system', 'content': (
                                'Du korrigierst Spracheingaben auf Deutsch. Regeln:\n'
                                '- Schreibe Zahlen als Wörter (1 → eins, 7 → sieben)\n'
                                '- Korrigiere offensichtliche Spracherkennungsfehler\n'
                                '- Eigennamen unveränderlich: Watson, Sancho, ChatGPT, Magnific, '
                                'ElevenLabs, Dispatcher, Vibe Coding, Victor, Melanie\n'
                                '- Füge sinnvolle Satzzeichen ein\n'
                                '- Gib NUR den korrigierten Text zurück, keine Erklärungen'
                            )},
                            {'role': 'user', 'content': raw_text}
                        ]
                    )
                    polished_text = polish_resp.choices[0].message.content.strip()
                    self._json(200, {'ok': True, 'raw': raw_text, 'polished': polished_text})
                finally:
                    try: os.unlink(tmp2)
                    except Exception: pass
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/victor_input/save_audio':
            # Simpel: rohe WebM-Audiodatei → victor_input/audio/YYYY-MM-DD_HH-MM-SS.webm
            try:
                import datetime as _dt_via
                audio_data = body
                if not audio_data:
                    self._json(400, {'ok': False, 'error': 'Kein Audio'}); return
                ext = self.headers.get('X-Audio-Ext', 'webm')
                audio_dir = Path(__file__).parent.parent / 'victor_input' / 'audio'
                audio_dir.mkdir(parents=True, exist_ok=True)
                ts = _dt_via.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
                filename = f'{ts}.{ext}'
                (audio_dir / filename).write_bytes(audio_data)
                self._json(200, {'ok': True, 'filename': filename})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/victor_input/glossary':
            # Glossar aktualisieren (Begriffe + Korrekturen)
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                glossary_file = Path(__file__).parent.parent / 'victor_input' / 'glossary' / 'glossary.json'
                glossary_file.parent.mkdir(parents=True, exist_ok=True)
                if glossary_file.exists():
                    existing = json.loads(glossary_file.read_text(encoding='utf-8'))
                else:
                    existing = {'version': 1, 'terms': [], 'corrections': {}, 'prompt': ''}
                if 'terms' in data:
                    existing['terms'] = data['terms']
                if 'corrections' in data:
                    existing['corrections'].update(data['corrections'])
                if 'prompt' in data:
                    existing['prompt'] = data['prompt']
                import datetime as _dt_gl
                existing['updated'] = _dt_gl.date.today().isoformat()
                glossary_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/voice/save_audio':
            try:
                import datetime as _dt_sa
                import email as _email
                content_type = self.headers.get('Content-Type', '')
                # Multipart-Boundary aus Content-Type extrahieren
                boundary = None
                for part in content_type.split(';'):
                    part = part.strip()
                    if part.startswith('boundary='):
                        boundary = part[len('boundary='):].strip('"')
                        break
                if boundary and body:
                    archive_dir = Path(__file__).parent.parent / 'victor_input' / 'audio'
                    archive_dir.mkdir(parents=True, exist_ok=True)
                    ts = _dt_sa.datetime.now().strftime('%Y-%m-%d_%H-%M-%S-%f')
                    # Rohen Multipart-Body parsen via email.parser
                    raw = b'Content-Type: ' + content_type.encode() + b'\r\n\r\n' + body
                    msg = _email.message_from_bytes(raw)
                    saved = False
                    for part in msg.walk():
                        if part.get_param('name', header='content-disposition') == 'audio':
                            filename = part.get_filename() or f'chunk_{ts}.webm'
                            out_path = archive_dir / f'chunk_{ts}_{filename}'
                            payload = part.get_payload(decode=True)
                            if payload:
                                with open(out_path, 'wb') as f:
                                    f.write(payload)
                                saved = True
                            break
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        # ── Signalraum (mikrofon-v2) ────────────────────────────────────────────
        elif self.path == '/api/mikrofon-v2/session/start':
            try:
                import datetime as _dt_sv2
                data = json.loads(body.decode('utf-8')) if body else {}
                sid = str(data.get('session_id', '')).strip()
                ext = str(data.get('ext', 'webm')).strip()
                if sid:
                    sess_dir = Path(__file__).parent.parent / 'recordings' / 'signalraum' / sid
                    sess_dir.mkdir(parents=True, exist_ok=True)
                    meta = {
                        'session_id': sid,
                        'app': 'Voice Input Final',
                        'ext': ext,
                        'started': _dt_sv2.datetime.now().isoformat(),
                        'status': 'recording',
                    }
                    (sess_dir / 'meta.json').write_text(
                        json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8'
                    )
                self._json(200, {'ok': True, 'session_id': sid})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path.startswith('/api/mikrofon-v2/chunk'):
            try:
                qs = parse_qs(urlparse(self.path).query)
                sid = str(qs.get('session', [''])[0]).strip()
                idx = str(qs.get('idx', ['000'])[0]).strip()
                ext = str(qs.get('ext', ['webm'])[0]).strip()
                if sid and body:
                    sess_dir = Path(__file__).parent.parent / 'recordings' / 'signalraum' / sid
                    sess_dir.mkdir(parents=True, exist_ok=True)
                    chunk_path = sess_dir / f'chunk_{idx}.{ext}'
                    chunk_path.write_bytes(body)
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/mikrofon-v2/save':
            try:
                import datetime as _dt_sv2s
                data = json.loads(body.decode('utf-8')) if body else {}
                sid = str(data.get('session_id', '')).strip()
                text = str(data.get('text', '')).strip()
                raw_text = str(data.get('raw_text', '') or '').strip()
                polished_text = str(data.get('polished_text', '') or '').strip()
                edited_text = str(data.get('edited_text', '') or '').strip()
                active_view = str(data.get('active_view', '') or '').strip()
                route_status = data.get('route_status', {})
                if sid and (text or raw_text or polished_text or edited_text):
                    sess_dir = Path(__file__).parent.parent / 'recordings' / 'signalraum' / sid
                    sess_dir.mkdir(parents=True, exist_ok=True)
                    final_text = edited_text or text or polished_text or raw_text
                    (sess_dir / 'transcript.txt').write_text(final_text, encoding='utf-8')
                    if raw_text:
                        (sess_dir / 'raw.txt').write_text(raw_text, encoding='utf-8')
                    if polished_text:
                        (sess_dir / 'polished.txt').write_text(polished_text, encoding='utf-8')
                    if edited_text:
                        (sess_dir / 'edited.txt').write_text(edited_text, encoding='utf-8')
                    meta_path = sess_dir / 'meta.json'
                    meta = {}
                    if meta_path.exists():
                        try:
                            meta = json.loads(meta_path.read_text(encoding='utf-8'))
                        except Exception:
                            pass
                    now_iso = _dt_sv2s.datetime.now().isoformat()
                    meta['app'] = 'Voice Input Final'
                    meta['last_save'] = now_iso
                    meta['status'] = 'saved'
                    meta['active_view'] = active_view or meta.get('active_view', '')
                    if route_status:
                        meta['route_status'] = route_status
                    meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
                    session_doc = {
                        'session_id': sid,
                        'app': 'Voice Input Final',
                        'updated': now_iso,
                        'save_status': 'saved',
                        'active_view': active_view or 'edited',
                        'raw_text': raw_text,
                        'polished_text': polished_text,
                        'edited_text': edited_text or final_text,
                        'final_text': final_text,
                        'route_status': route_status if isinstance(route_status, dict) else {},
                        'audio_files': sorted([p.name for p in sess_dir.glob('chunk_*')]),
                    }
                    (sess_dir / 'session.json').write_text(
                        json.dumps(session_doc, ensure_ascii=False, indent=2), encoding='utf-8'
                    )
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/mikrofon-v2/polish':
            try:
                import difflib as _diff
                data = json.loads(body.decode('utf-8')) if body else {}
                raw_text = str(data.get('text', '')).strip()
                if not raw_text:
                    self._json(400, {'ok': False, 'error': 'Kein Text'})
                    return
                openai_key = _load_openai_key()
                if not openai_key:
                    self._json(400, {'ok': False, 'error': 'Kein OPENAI_API_KEY'})
                    return
                from openai import OpenAI as _OAI_v2
                _client_v2 = _OAI_v2(api_key=openai_key)
                glossary_prompt = ''
                try:
                    glossary_file = Path(__file__).parent.parent / 'victor_input' / 'glossary' / 'glossary.json'
                    if glossary_file.exists():
                        glossary = json.loads(glossary_file.read_text(encoding='utf-8'))
                        glossary_prompt = str(glossary.get('prompt', '')).strip()
                except Exception:
                    glossary_prompt = ''
                polish_prompt = (
                    'Du bekommst einen Spracherkennungstext von Victor. '
                    'Korrigiere NUR klare Diktierfehler (falsch erkannte Wörter, Buchstabendreher) '
                    'und setze sinnvolle Absätze. '
                    'Verändere NICHT Victors Sprache, Stil oder Formulierungen. '
                    'Füge KEINE Sätze hinzu. Kürze NICHT. '
                    'Antworte nur mit dem korrigierten Text, nichts sonst.\n\n' +
                    (('Wichtige Namen und Begriffe: ' + glossary_prompt + '\n\n') if glossary_prompt else '') +
                    'Text:\n' + raw_text
                )
                resp_v2 = _client_v2.chat.completions.create(
                    model='gpt-4o',
                    messages=[{'role': 'user', 'content': polish_prompt}],
                    max_tokens=len(raw_text) * 2 + 200,
                    temperature=0.1,
                )
                polished = resp_v2.choices[0].message.content.strip()
                # Build simple diff (word level for markers)
                raw_words = raw_text.split()
                pol_words = polished.split()
                sm = _diff.SequenceMatcher(None, raw_words, pol_words)
                diff_parts = []
                for tag, i1, i2, j1, j2 in sm.get_opcodes():
                    if tag == 'equal':
                        diff_parts.append({'type': 'same', 'text': ' '.join(raw_words[i1:i2])})
                    elif tag in ('replace', 'insert'):
                        diff_parts.append({
                            'type': 'change',
                            'orig': ' '.join(raw_words[i1:i2]),
                            'polished': ' '.join(pol_words[j1:j2])
                        })
                    elif tag == 'delete':
                        pass  # deleted words not shown
                # Add spaces back between parts
                spaced = []
                for i, p in enumerate(diff_parts):
                    if i > 0:
                        spaced.append({'type': 'same', 'text': ' '})
                    spaced.append(p)
                self._json(200, {'ok': True, 'polished': polished, 'diff': spaced})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/mikrofon-v2/route':
            try:
                import datetime as _dt_sv2r
                data = json.loads(body.decode('utf-8')) if body else {}
                target = str(data.get('target', '')).strip().lower()
                text   = str(data.get('text', '')).strip()
                sid    = str(data.get('session_id', '')).strip()
                if not text:
                    self._json(400, {'ok': False, 'error': 'Kein Text'}); return
                ts = _dt_sv2r.datetime.now().strftime('%Y-%m-%d %H:%M')
                ts_file = _dt_sv2r.datetime.now().strftime('%Y%m%d_%H%M%S')
                base = Path(__file__).parent.parent
                if target == 'cherry':
                    out_dir = base / 'CHERRY_WATSON' / 'OUTBOX'
                    out_dir.mkdir(parents=True, exist_ok=True)
                    fname = f'VOICE_INPUT_FINAL_{ts_file}_{(sid or "nosid")[:12]}.md'
                    content = (
                        f'# Voice Input Final → Cherry / Codex\n\n'
                        f'ts: {ts}\nsession: {sid or "—"}\n\n---\n\n{text}\n'
                    )
                    (out_dir / fname).write_text(content, encoding='utf-8')
                elif target == 'watson':
                    # Use voice intake — write to WATSON_AUFGABEN.md
                    import re as _re_v2r
                    tasks_file = base / 'WATSON_AUFGABEN.md'
                    task_id = 'SR1'
                    if tasks_file.exists():
                        existing = tasks_file.read_text(encoding='utf-8')
                        sr_nums = [int(n) for n in _re_v2r.findall(r'\bSR(\d+)\b', existing)]
                        task_id = 'SR' + str(max(sr_nums) + 1 if sr_nums else 1)
                    preview = (text[:120] + '…') if len(text) > 120 else text
                    entry = f'| {task_id} | **Voice Input Final** — {preview} | Diktat {ts} | — |\n'
                    if tasks_file.exists():
                        content = tasks_file.read_text(encoding='utf-8')
                        if 'Voice-Entwürfe' in content:
                            m = _re_v2r.search(r'(## Voice-Entwürfe[^\n]*\n\|[^\n]+\n\|[-| ]+\n)', content)
                            if m:
                                content = content[:m.end()] + entry + content[m.end():]
                            else:
                                content += entry
                        else:
                            content += '\n## Voice-Entwürfe (Diktat — unklassifiziert)\n| # | Aufgabe | Kontext | Deadline |\n|---|---|---|---|\n' + entry
                        tasks_file.write_text(content, encoding='utf-8')
                elif target == 'notiz':
                    notes_dir = base / 'victor_input' / 'notizen'
                    notes_dir.mkdir(parents=True, exist_ok=True)
                    fname = f'notiz_{ts_file}.txt'
                    (notes_dir / fname).write_text(f'[{ts}]\n{text}\n', encoding='utf-8')
                else:
                    self._json(400, {'ok': False, 'error': f'Unbekanntes Ziel: {target}'}); return
                routed_file = ''
                if target == 'cherry':
                    routed_file = str(out_dir / fname)
                elif target == 'notiz':
                    routed_file = str(notes_dir / fname)
                elif target == 'watson':
                    routed_file = str(tasks_file)
                # Mark in session
                sess_dir = base / 'recordings' / 'signalraum' / (sid or '_no_session')
                if sess_dir.exists():
                    try:
                        meta_p = sess_dir / 'meta.json'
                        meta = json.loads(meta_p.read_text()) if meta_p.exists() else {}
                        meta.setdefault('routed_to', []).append(target)
                        meta.setdefault('route_status', {})[target] = {
                            'status': 'sent',
                            'filename': routed_file,
                            'ts': _dt_sv2r.datetime.now().isoformat(),
                        }
                        meta_p.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
                        session_p = sess_dir / 'session.json'
                        if session_p.exists():
                            session_doc = json.loads(session_p.read_text(encoding='utf-8'))
                        else:
                            session_doc = {'session_id': sid, 'app': 'Voice Input Final'}
                        session_doc.setdefault('route_status', {})[target] = {
                            'status': 'sent',
                            'filename': routed_file,
                            'ts': _dt_sv2r.datetime.now().isoformat(),
                        }
                        session_doc['updated'] = _dt_sv2r.datetime.now().isoformat()
                        session_p.write_text(json.dumps(session_doc, ensure_ascii=False, indent=2), encoding='utf-8')
                    except Exception:
                        pass
                self._json(200, {'ok': True, 'target': target, 'filename': routed_file})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/mikrofon-v2/suspects':
            # C17b: Verdächtige Begriffe im Diktat-Transkript erkennen
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                text = str(data.get('text', '')).strip()
                if not text:
                    self._json(400, {'ok': False, 'error': 'Kein Text'})
                    return
                openai_key = _load_openai_key()
                if not openai_key:
                    self._json(400, {'ok': False, 'error': 'Kein OPENAI_API_KEY'})
                    return
                from openai import OpenAI as _OAI_sus
                _client_sus = _OAI_sus(api_key=openai_key)
                suspects_prompt = (
                    'Du bekommst einen deutschen Spracherkennungstext. '
                    'Finde Wörter oder Ausdrücke, die wahrscheinlich falsch erkannt wurden '
                    '(Verhörer, kaputte Fachbegriffe, unsinnige Einschübe, Homophone die nicht passen). '
                    'Antworte NUR mit validem JSON-Array. Format: '
                    '[{"begriff": "das erkannte Wort", "alternativen": ["Kandidat1", "Kandidat2", "Kandidat3"]}] '
                    'Maximal 8 Einträge. Wenn keine verdächtigen Wörter gefunden wurden, antworte mit: [] '
                    '\n\nText:\n' + text
                )
                resp_sus = _client_sus.chat.completions.create(
                    model='gpt-4o-mini',
                    messages=[{'role': 'user', 'content': suspects_prompt}],
                    max_tokens=800,
                    temperature=0.1,
                    response_format={'type': 'json_object'},
                )
                raw_json = resp_sus.choices[0].message.content.strip()
                # Antwort kann {"suspects": [...]} oder direkt [...] sein
                try:
                    parsed = json.loads(raw_json)
                    if isinstance(parsed, list):
                        suspects = parsed
                    elif isinstance(parsed, dict):
                        suspects = parsed.get('suspects', parsed.get('items', []))
                        if not suspects:
                            # Versuche erstes Array-Value
                            for v in parsed.values():
                                if isinstance(v, list):
                                    suspects = v
                                    break
                    else:
                        suspects = []
                except Exception:
                    suspects = []
                # Validierung: jeder Eintrag muss 'begriff' und 'alternativen' haben
                valid = []
                for s in suspects:
                    if isinstance(s, dict) and s.get('begriff') and isinstance(s.get('alternativen'), list):
                        valid.append({
                            'begriff': str(s['begriff']),
                            'alternativen': [str(a) for a in s['alternativen'][:3]]
                        })
                self._json(200, {'ok': True, 'suspects': valid})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e), 'suspects': []})

        elif self.path == '/api/mikrofon-v2/glossary/add':
            # C17b: Begriff ins Glossar eintragen
            try:
                import datetime as _dt_glos
                data = json.loads(body.decode('utf-8')) if body else {}
                term = str(data.get('term', '')).strip()
                if not term:
                    self._json(400, {'ok': False, 'error': 'Kein Begriff'})
                    return
                glossary_dir = Path(__file__).parent.parent / 'victor_input' / 'glossary'
                glossary_dir.mkdir(parents=True, exist_ok=True)
                glossary_file = glossary_dir / 'glossary.json'
                # Atomar lesen + schreiben via tmp+rename
                if glossary_file.exists():
                    try:
                        glossary = json.loads(glossary_file.read_text(encoding='utf-8'))
                    except Exception:
                        glossary = {}
                else:
                    glossary = {}
                terms = glossary.get('terms', [])
                if term not in terms:
                    terms.append(term)
                glossary['terms'] = terms
                glossary['updated'] = _dt_glos.datetime.now().isoformat()
                # Prompt neu aufbauen
                glossary['prompt'] = ', '.join(terms)
                tmp_file = glossary_file.with_suffix('.tmp')
                tmp_file.write_text(json.dumps(glossary, ensure_ascii=False, indent=2), encoding='utf-8')
                tmp_file.replace(glossary_file)
                self._json(200, {'ok': True, 'term': term, 'total': len(terms)})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/mikrofon-v2/verify-route':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                target   = str(data.get('target', '')).strip().lower()
                filename = str(data.get('filename', '')).strip()
                sid      = str(data.get('session_id', '')).strip()
                base     = Path(__file__).parent.parent
                # Find the file
                path_to_check = None
                if filename:
                    path_to_check = Path(filename)
                elif target == 'cherry':
                    # Find most recent SIGNALRAUM file matching session
                    d = base / 'CHERRY_WATSON' / 'OUTBOX'
                    if d.exists():
                        matches = sorted(d.glob('SIGNALRAUM_*.md'), key=lambda p: p.stat().st_mtime, reverse=True)
                        if matches:
                            path_to_check = matches[0]
                elif target == 'notiz':
                    d = base / 'victor_input' / 'notizen'
                    if d.exists():
                        matches = sorted(d.glob('notiz_*.txt'), key=lambda p: p.stat().st_mtime, reverse=True)
                        if matches:
                            path_to_check = matches[0]
                elif target == 'watson':
                    path_to_check = base / 'WATSON_AUFGABEN.md'
                if path_to_check and path_to_check.exists():
                    preview = path_to_check.read_text(encoding='utf-8', errors='replace')[:120]
                    self._json(200, {'ok': True, 'found': True,
                                     'path': str(path_to_check).replace(str(base), ''),
                                     'preview': preview})
                else:
                    self._json(200, {'ok': True, 'found': False,
                                     'error': 'Datei nicht gefunden'})
            except Exception as e:
                self._json(200, {'ok': False, 'found': False, 'error': str(e)})

        elif self.path == '/api/mikrofon-v2/route-to-claude':
            # C18: Text an eine laufende Claude tmux-Session senden
            try:
                import re as _re_r2c
                data = json.loads(body.decode('utf-8')) if body else {}
                target_session = str(data.get('target_session', '')).strip()
                text           = str(data.get('text', '')).strip()
                if not target_session:
                    self._json(400, {'ok': False, 'error': 'target_session erforderlich'}); return
                if not text:
                    self._json(400, {'ok': False, 'error': 'Kein Text'}); return
                # Session-Existenz prüfen
                if not _tmux_session_alive(target_session):
                    self._json(200, {'ok': False, 'error': f'Session {target_session!r} nicht gefunden oder schläft'}); return
                # Präfix voranstellen
                full_text = '[Voice Input] ' + text
                try:
                    subprocess.run(
                        _tmux_cmd() + ['send-keys', '-l', '-t', target_session, full_text],
                        check=True, timeout=5
                    )
                    subprocess.run(
                        _tmux_cmd() + ['send-keys', '-t', target_session, '', 'Enter'],
                        check=True, timeout=5
                    )
                    self._json(200, {'ok': True, 'session': target_session})
                except subprocess.TimeoutExpired:
                    self._json(200, {'ok': False, 'error': 'tmux Timeout'})
                except subprocess.CalledProcessError as e:
                    self._json(200, {'ok': False, 'error': f'tmux Fehler: {e}'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})

        elif self.path == '/api/deepgram/transcribe':
            try:
                audio_data = body
                if not audio_data:
                    self._json(400, {'ok': False, 'error': 'Kein Audio'})
                    return
                dg_key = _load_deepgram_key()
                if not dg_key:
                    self._json(500, {'ok': False, 'error': 'Kein DEEPGRAM_API_KEY in credentials.env'})
                    return
                ext = self.headers.get('X-Audio-Ext', 'webm')
                content_type = self.headers.get('Content-Type', 'audio/webm')
                import urllib.request as _ur3
                dg_url = 'https://api.deepgram.com/v1/listen?model=nova-3&language=de&smart_format=true&punctuate=true'
                req_dg = _ur3.Request(
                    dg_url,
                    data=audio_data,
                    headers={
                        'Authorization': f'Token {dg_key}',
                        'Content-Type': content_type,
                    },
                    method='POST'
                )
                with _ur3.urlopen(req_dg, timeout=15) as resp_dg:
                    result_dg = json.loads(resp_dg.read().decode('utf-8'))
                transcript = result_dg['results']['channels'][0]['alternatives'][0]['transcript']
                self._json(200, {'ok': True, 'text': transcript})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/transkript/save':
            try:
                import datetime as _dt_tr
                _data = json.loads(body.decode('utf-8')) if body else {}
                _state_file = COCKPIT_DIR / 'transkript_state.json'
                _data['saved_at'] = _dt_tr.datetime.now().isoformat()
                _state_file.write_text(json.dumps(_data, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/zotify/download':
            # Uses spotdl (YouTube-matching) since Zotify/librespot is broken
            try:
                data = json.loads(body) if body else {}
                url = str(data.get('url', '')).strip()
                dest = str(data.get('dest', 'spotify')).strip()
                if not url or not url.startswith('https://open.spotify.com/'):
                    self._json(400, {'ok': False, 'error': 'Spotify-URL erforderlich'})
                    return
                job_id = str(uuid.uuid4())[:8]
                ZOTIFY_JOBS[job_id] = {'status': 'running', 'url': url, 'log': ''}
                out_dir = WATSON_VOICES_DIR / 'raw_sources' / dest
                out_dir.mkdir(parents=True, exist_ok=True)

                def run_spotdl(jid, spotify_url, output_dir):
                    try:
                        proc = subprocess.run(
                            ['spotdl', '--output', str(output_dir / '{list-name}/{title}.{ext}'),
                             '--format', 'mp3',
                             '--bitrate', '320k',
                             spotify_url],
                            capture_output=True, text=True, timeout=7200,
                            env={**os.environ, 'PATH': '/opt/homebrew/bin:/usr/bin:/bin'},
                            cwd=str(output_dir)
                        )
                        ZOTIFY_JOBS[jid]['status'] = 'done' if proc.returncode == 0 else 'error'
                        ZOTIFY_JOBS[jid]['log'] = (proc.stdout + proc.stderr)[-2000:]
                    except Exception as ex:
                        ZOTIFY_JOBS[jid]['status'] = 'error'
                        ZOTIFY_JOBS[jid]['log'] = str(ex)

                t = threading.Thread(target=run_spotdl, args=(job_id, url, out_dir), daemon=True)
                t.start()
                self._json(200, {'ok': True, 'job_id': job_id})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        # ── Song-Erkennung (M-029) ────────────────────────────────────
        elif self.path == '/api/song/recognize':
            if not _SONG_MOD_OK:
                self._json(500, {'ok': False, 'error': 'song_erkennung-Modul nicht geladen'})
                return
            try:
                data = json.loads(body) if body else {}
                source = str(data.get('source', 'mikrofon'))
                duration = int(data.get('duration', 10))
                if source not in ('mikrofon', 'blackhole'):
                    source = 'mikrofon'
                duration = max(5, min(30, duration))
                _song_mod.run_recognition_flow(source=source, duration=duration)
                self._json(200, {'ok': True, 'state': 'recording', 'duration': duration, 'source': source})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/song/save':
            if not _SONG_MOD_OK:
                self._json(500, {'ok': False, 'error': 'song_erkennung-Modul nicht geladen'})
                return
            try:
                data = json.loads(body) if body else {}
                title = str(data.get('title', '')).strip()
                artist = str(data.get('artist', '')).strip()
                isrc = str(data.get('isrc', '')).strip()
                if not title:
                    self._json(400, {'ok': False, 'error': 'title erforderlich'})
                    return
                song_info = {'title': title, 'artist': artist, 'isrc': isrc}
                _song_mod.run_save_flow(song_info)
                self._json(200, {'ok': True, 'state': 'saving'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

# INSTRUMENT-AUSNAHME: Server-seitiger API-Proxy für ElevenLabs ConvAI, keine Sprachausgabe, Victor-Go erteilt im Auftragstext
        elif self.path == '/api/watson/save_hf_token':
            try:
                data = json.loads(body)
                token = str(data.get('token', '')).strip()
                if not token:
                    self._json(400, {'ok': False, 'error': 'Token leer'})
                    return
                import subprocess as _sp
                # Delete existing entry first (ignore errors), then add new
                _sp.run(['security', 'delete-generic-password', '-s', 'huggingface'], capture_output=True)
                result = _sp.run(
                    ['security', 'add-generic-password', '-s', 'huggingface', '-a', 'watson', '-w', token],
                    capture_output=True, text=True
                )
                if result.returncode == 0:
                    self._json(200, {'ok': True, 'message': 'Token im Schlüsselbund gespeichert'})
                else:
                    self._json(500, {'ok': False, 'error': result.stderr.strip()})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

# INSTRUMENT-AUSNAHME: Speichert nur agent_id, kein TTS-Aufruf, Victor-Go erteilt im Auftragstext
        elif self.path == '/api/elevenlabs/agent_config':
            # Speichert agent_id für Watson ConvAI
            try:
                data = json.loads(body)
                agent_id = str(data.get('agent_id', '')).strip()
                if not agent_id:
                    self._json(400, {'ok': False, 'error': 'agent_id leer'})
                    return
                config_file = COCKPIT_DIR / 'elevenlabs_agent_config.json'
                config_file.write_text(json.dumps({'agent_id': agent_id}, ensure_ascii=False))
                self._json(200, {'ok': True, 'agent_id': agent_id})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/pi/wifi':
            try:
                import re as _re
                data = json.loads(body) if body else {}
                ssid = str(data.get('ssid', '')).strip()
                password = str(data.get('password', '')).strip()

                # Schlüsselbund als Quelle wenn Formular leer
                if not ssid or not password:
                    kc_pw = subprocess.run(
                        ['security', 'find-generic-password', '-s', 'Victors WLAN', '-w'],
                        capture_output=True, text=True)
                    kc_acct = subprocess.run(
                        ['security', 'find-generic-password', '-s', 'Victors WLAN'],
                        capture_output=True, text=True)
                    if kc_pw.returncode == 0:
                        password = kc_pw.stdout.strip()
                        import re as _re2
                        m = _re2.search(r'"acct".*?"(.+?)"', kc_acct.stdout)
                        ssid = m.group(1) if m else ssid

                if not ssid or not password:
                    self._json(400, {'ok': False, 'error': 'Kein Eintrag "Pi WLAN" im Schlüsselbund gefunden'})
                    return
                if _re.search(r'["\\\$`]', ssid) or _re.search(r'["\\\$`]', password):
                    self._json(400, {'ok': False, 'error': 'Ungültige Zeichen in SSID oder Passwort'})
                    return

                # sudo nmcli — pi hat passwordless sudo auf Raspberry Pi OS
                cmd = [
                    'ssh', '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes',
                    '-o', 'StrictHostKeyChecking=no',
                    'pi@raspberrypi.local',
                    f'sudo nmcli dev wifi connect "{ssid}" password "{password}"'
                ]
                result = subprocess.run(cmd, capture_output=True, timeout=30)
                stdout = result.stdout.decode('utf-8', errors='replace').strip()
                stderr = result.stderr.decode('utf-8', errors='replace').strip()
                if result.returncode == 0:
                    self._json(200, {'ok': True, 'message': f'Pi ist jetzt mit "{ssid}" verbunden. Ethernet-Kabel kann raus.'})
                else:
                    err = stderr or stdout or 'Unbekannter Fehler'
                    self._json(200, {'ok': False, 'error': err})
            except subprocess.TimeoutExpired:
                self._json(200, {'ok': False, 'error': 'Timeout — Pi nicht erreichbar'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/openai_realtime_session':
            # DEPRECATED: ephemeral-token Weg (/v1/realtime/sessions) ist abgeschaltet.
            # Jetzt SDP-Proxy: Browser gibt SDP, Server leitet zu OpenAI weiter.
            self._json(410, {'ok': False, 'error': 'Veraltet — bitte /api/openai_realtime_sdp nutzen'})

        elif self.path == '/api/openai_realtime_sdp':
            # SDP-Proxy für OpenAI Realtime GA API.
            # Browser sendet {sdp: "...", model: "...(optional)"}, Server relay zu OpenAI,
            # gibt {ok: true, sdp: "answer SDP"} zurück.
            # Der API-Key bleibt serverseitig — niemals an den Browser.
            try:
                req_data = json.loads(body) if body else {}
                offer_sdp = str(req_data.get('sdp', '')).strip()
                model = str(req_data.get('model', 'gpt-4o-realtime-preview')).strip()
                if not offer_sdp:
                    self._json(400, {'ok': False, 'error': 'sdp fehlt'})
                    return
                openai_key = _load_openai_key()
                if not openai_key:
                    import subprocess as _sp2
                    kc = _sp2.run(['security', 'find-generic-password', '-s', 'openai', '-w'],
                                  capture_output=True, text=True)
                    openai_key = kc.stdout.strip() if kc.returncode == 0 else ''
                if not openai_key:
                    self._json(500, {'ok': False, 'error': 'Kein OpenAI API Key gefunden'})
                    return
                import urllib.request as _ur2
                url = f'https://api.openai.com/v1/realtime?model={model}'
                req2 = _ur2.Request(
                    url,
                    data=offer_sdp.encode('utf-8'),
                    headers={
                        'Authorization': f'Bearer {openai_key}',
                        'Content-Type': 'application/sdp',
                    },
                    method='POST'
                )
                with _ur2.urlopen(req2, timeout=15) as resp2:
                    answer_sdp = resp2.read().decode('utf-8')
                self._json(200, {'ok': True, 'sdp': answer_sdp})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/deepgram/status':
            # Prüft ob ein Deepgram Key konfiguriert ist.
            try:
                dg_key = ''
                for line in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
                    line = line.strip()
                    if line.startswith('DEEPGRAM_API_KEY='):
                        dg_key = line.split('=', 1)[1].strip()
                        break
                self._json(200, {'ok': True, 'key_set': bool(dg_key)})
            except Exception as e:
                self._json(200, {'ok': True, 'key_set': False})

        elif self.path == '/api/deepgram/proxy_url':
            # Gibt eine Deepgram WebSocket-URL mit eingebettetem Token zurück.
            # Der Key bleibt serverseitig, der Browser bekommt nur die signierte URL.
            try:
                dg_key = ''
                for line in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
                    line = line.strip()
                    if line.startswith('DEEPGRAM_API_KEY='):
                        dg_key = line.split('=', 1)[1].strip()
                        break
                if not dg_key:
                    self._json(400, {'ok': False, 'error': 'Kein Deepgram Key'})
                    return
                ws_url = (
                    f'wss://api.deepgram.com/v1/listen'
                    f'?model=nova-2&language=de'
                    f'&encoding=linear16&sample_rate=16000&channels=1'
                    f'&interim_results=true'
                    f'&endpointing=2000&utterance_end_ms=2000'
                    f'&smart_format=true&punctuate=true&diarize=false'
                    f'&token={dg_key}'
                )
                self._json(200, {'ok': True, 'ws_url': ws_url})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:200]})

        # ── Candy — private Claude Code Session ──────────────────────
        elif self.path == '/api/candy/send':
            try:
                data = json.loads(body) if body else {}
                text = str(data.get('text', '')).strip()
                if not text:
                    self._json(400, {'ok': False, 'error': 'text erforderlich'})
                    return
                if not _tmux_session_alive('candy'):
                    self._json(200, {'ok': False, 'error': 'Candy schläft'})
                    return
                result = subprocess.run(
                    _tmux_cmd() + ['send-keys', '-t', 'candy', text, 'Enter'],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.PIPE,
                    timeout=5
                )
                if result.returncode != 0:
                    err = result.stderr.decode(errors='replace').strip()
                    self._json(200, {'ok': False, 'error': f'tmux Fehler: {err}'})
                else:
                    self._json(200, {'ok': True})
            except subprocess.TimeoutExpired:
                self._json(200, {'ok': False, 'error': 'tmux Timeout'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

# INSTRUMENT-AUSNAHME: ElevenLabs TTS Proxy — Victor-Go erteilt im Subagenten-Auftragstext (candy_voice Gesprächs-Loop), kein autonomer TTS-Aufruf, nur Server-seitiger Proxy auf Anfrage des Browsers
        elif self.path == '/api/candy/interrupt':
            try:
                if not _tmux_session_alive('candy'):
                    self._json(200, {'ok': False, 'error': 'Candy schläft'})
                    return
                subprocess.run(
                    _tmux_cmd() + ['send-keys', '-t', 'candy', 'C-c', ''],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                    timeout=5
                )
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/tts':
            # ElevenLabs TTS Streaming Proxy
            # Body: {"text": "...", "voice_id": "optional"}
            # Response: audio/mpeg stream from ElevenLabs
            try:
                import urllib.request as _ur_tts
                data = json.loads(body) if body else {}
                text = str(data.get('text', '')).strip()
                if not text:
                    self.send_response(400)
                    self.send_header('Content-Type', 'text/plain')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(b'{"ok": false, "error": "text erforderlich"}')
                    return
                # Load ElevenLabs credentials
                CANDY_VOICE_ID = 'Eklc9nt4PIDiT8PWGy1h'  # Candy — hardcoded, nicht aus credentials.env
                el_key = ''
                try:
                    for line in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
                        line = line.strip()
                        if line.startswith('ELEVENLABS_API_KEY='):
                            el_key = line.split('=', 1)[1].strip()
                except Exception:
                    pass
                voice_id = str(data.get('voice_id', '')).strip() or CANDY_VOICE_ID
                if not el_key:
                    self.send_response(500)
                    self.send_header('Content-Type', 'text/plain')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(b'{"ok": false, "error": "Kein ELEVENLABS_API_KEY"}')
                    return
                el_url = f'https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream'
                el_payload = json.dumps({
                    'text': text,
                    'model_id': 'eleven_multilingual_v2',
                    'voice_settings': {
                        'stability': 0.5,
                        'similarity_boost': 0.75
                    }
                }).encode('utf-8')
                el_req = _ur_tts.Request(
                    el_url,
                    data=el_payload,
                    headers={
                        'xi-api-key': el_key,
                        'Content-Type': 'application/json',
                        'Accept': 'audio/mpeg',
                    },
                    method='POST'
                )
                with _ur_tts.urlopen(el_req, timeout=30) as el_resp:
                    audio_data = el_resp.read()
                self.send_response(200)
                self.send_header('Content-Type', 'audio/mpeg')
                self.send_header('Content-Length', len(audio_data))
                self._cors()
                self.end_headers()
                self.wfile.write(audio_data)
            except Exception as e:
                try:
                    self.send_response(500)
                    self.send_header('Content-Type', 'text/plain')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(json.dumps({'ok': False, 'error': str(e)[:300]}).encode())
                except Exception:
                    pass

        elif self.path == '/api/anruf/upload':
            # Empfängt Audio-Blob (raw body, Content-Type audio/webm oder audio/mp4)
            try:
                audio_data = body
                if not audio_data or len(audio_data) < 500:
                    self._json(400, {'ok': False, 'error': 'Aufnahme leer oder zu kurz'})
                    return
                content_type = self.headers.get('Content-Type', 'audio/webm') or 'audio/webm'
                ext_hdr = (self.headers.get('X-Audio-Ext', '') or '').strip().lower()
                if ext_hdr not in ('webm', 'mp4', 'm4a', 'ogg'):
                    ext_hdr = 'mp4' if 'mp4' in content_type else 'webm'

                import datetime as _dt_a
                ts = _dt_a.datetime.now()
                anruf_id = ts.strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:4]

                anruf_dir = ANRUFE_DIR / anruf_id
                anruf_dir.mkdir(parents=True, exist_ok=True)
                audio_path = anruf_dir / f'{anruf_id}.{ext_hdr}'
                audio_path.write_bytes(audio_data)

                _anruf_set(anruf_id, status='processing', result=None, error=None,
                           audio_path=str(audio_path), mime=content_type, ts=ts.isoformat())

                t = threading.Thread(
                    target=_process_anruf,
                    args=(anruf_id, audio_path, content_type),
                    daemon=True,
                    name=f'anruf-{anruf_id}',
                )
                t.start()

                self._json(200, {'ok': True, 'id': anruf_id})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/jobs/dispatch':
            try:
                length = int(self.headers.get('Content-Length', 0))
                body = json.loads(self.rfile.read(length).decode('utf-8')) if length else {}
                entry = {
                    'job_id': str(body.get('job_id', '')),
                    'job_title': str(body.get('job_title', ''))[:500],
                    'comment': str(body.get('comment', ''))[:2000],
                    'ts': str(body.get('ts', ''))[:50],
                    'status': 'pending',
                }
                with open(JOBS_QUEUE_FILE, 'a', encoding='utf-8') as f:
                    f.write(json.dumps(entry, ensure_ascii=False) + '\n')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/rode_clip_tag':
            # Setzt/löscht Tag für einen Rode-Clip
            # Body: {"clip_id": "clip_001", "tag": "erzaehler"|"action"|"weg"|""}
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                tag = str(data.get('tag', '')).strip()
                if not clip_id:
                    self._json(400, {'ok': False, 'error': 'clip_id required'})
                    return
                import re as _re
                if not _re.match(r'^(f\d+_)?clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'})
                    return
                if tag and tag not in ('erzaehler', 'action', 'weg'):
                    self._json(400, {'ok': False, 'error': 'tag muss erzaehler/action/weg/"" sein'})
                    return
                tags_file = WATSON_VOICES_DIR / 'rode_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try:
                        tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception:
                        tags = {}
                if tag:
                    tags[clip_id] = tag
                else:
                    tags.pop(clip_id, None)
                tags_file.write_text(json.dumps(tags, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/rode_emotion_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                emotion = str(data.get('emotion', '')).strip()
                active = data.get('active', None)  # None = toggle, True = add, False = remove
                VALID_EMOTIONS = ('ruhig','neugierig','ironisch','ernst','warm','aufgeregt','wütend','belustigt','überheblich','genervt','verwundert','verschwörer','besorgt','entschlossen','misstrauisch')
                if not clip_id or not _re.match(r'^(f\d+_)?clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                if emotion and emotion not in VALID_EMOTIONS:
                    self._json(400, {'ok': False, 'error': 'ungültige emotion'}); return
                ef = WATSON_VOICES_DIR / 'rode_clips' / 'emotion_tags.json'
                etags = json.loads(ef.read_text('utf-8')) if ef.exists() else {}
                # Migrate string → array
                cur = etags.get(clip_id, [])
                if isinstance(cur, str):
                    cur = [cur] if cur else []
                if emotion:
                    if active is None:
                        # toggle
                        if emotion in cur: cur = [e for e in cur if e != emotion]
                        else: cur = cur + [emotion]
                    elif active:
                        if emotion not in cur: cur = cur + [emotion]
                    else:
                        cur = [e for e in cur if e != emotion]
                if cur: etags[clip_id] = cur
                else: etags.pop(clip_id, None)
                ef.write_text(json.dumps(etags, ensure_ascii=False, indent=2), 'utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/rode_not_clean_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                dirty = bool(data.get('dirty', False))
                if not clip_id or not _re.match(r'^(f\d+_)?clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                nf = WATSON_VOICES_DIR / 'rode_clips' / 'not_clean_tags.json'
                ntags = json.loads(nf.read_text('utf-8')) if nf.exists() else {}
                if dirty: ntags[clip_id] = True
                else: ntags.pop(clip_id, None)
                nf.write_text(json.dumps(ntags, ensure_ascii=False, indent=2), 'utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/holmes_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                tag = str(data.get('tag', '')).strip()
                if not clip_id or not _re.match(r'^fall\d+_clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                if tag and tag not in ('dialog', 'monolog', 'weg', ''):
                    self._json(400, {'ok': False, 'error': 'tag muss dialog/monolog/weg/"" sein'}); return
                tags_file = WATSON_VOICES_DIR / 'holmes_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                if tag: tags[clip_id] = tag
                else: tags.pop(clip_id, None)
                tags_file.write_text(json.dumps(tags, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/holmes_emotion_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                emotion = str(data.get('emotion', '')).strip()
                active = data.get('active', None)
                HOLMES_EMOTIONS = ('analytisch','entschlossen','überheblich','ironisch','misstrauisch','gespannt','ernst','ruhig','verschwörerisch','neugierig','aufgeregt','genervt','warm','besorgt','kalt')
                if not clip_id or not _re.match(r'^fall\d+_clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                if emotion and emotion not in HOLMES_EMOTIONS:
                    self._json(400, {'ok': False, 'error': 'ungültige emotion'}); return
                ef = WATSON_VOICES_DIR / 'holmes_clips' / 'emotion_tags.json'
                etags = json.loads(ef.read_text('utf-8')) if ef.exists() else {}
                cur = etags.get(clip_id, [])
                if isinstance(cur, str):
                    cur = [cur] if cur else []
                if emotion:
                    if active is None:
                        if emotion in cur: cur = [e for e in cur if e != emotion]
                        else: cur = cur + [emotion]
                    elif active:
                        if emotion not in cur: cur = cur + [emotion]
                    else:
                        cur = [e for e in cur if e != emotion]
                if cur: etags[clip_id] = cur
                else: etags.pop(clip_id, None)
                ef.write_text(json.dumps(etags, ensure_ascii=False, indent=2), 'utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/holmes_not_clean_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                dirty = bool(data.get('dirty', False))
                if not clip_id or not _re.match(r'^fall\d+_clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                nf = WATSON_VOICES_DIR / 'holmes_clips' / 'not_clean_tags.json'
                ntags = json.loads(nf.read_text('utf-8')) if nf.exists() else {}
                if dirty: ntags[clip_id] = True
                else: ntags.pop(clip_id, None)
                nf.write_text(json.dumps(ntags, ensure_ascii=False, indent=2), 'utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/brueckner_clip_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                tag = str(data.get('tag', '')).strip()
                if not clip_id or not _re.match(r'^clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                if tag and tag not in ('gut', 'musik', 'weg'):
                    self._json(400, {'ok': False, 'error': 'tag muss gut/musik/weg sein'}); return
                tags_file = WATSON_VOICES_DIR / 'brueckner_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                if tag: tags[clip_id] = tag
                else: tags.pop(clip_id, None)
                tags_file.write_text(json.dumps(tags, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/brueckner_export_selection':
            try:
                import subprocess as _sp, tempfile as _tf
                tags_file = WATSON_VOICES_DIR / 'brueckner_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                gut_clips = sorted([k for k, v in tags.items() if v == 'gut'])
                if not gut_clips:
                    self._json(200, {'ok': False, 'error': 'Keine Gut-Clips markiert'}); return
                lst = _tf.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
                for cid in gut_clips:
                    p = WATSON_VOICES_DIR / 'brueckner_clips' / f'{cid}.mp3'
                    if p.exists(): lst.write(f"file '{str(p)}'\n")
                lst.close()
                out = WATSON_VOICES_DIR / 'christian_brueckner' / 'source_best.mp3'
                _sp.run(['/opt/homebrew/bin/ffmpeg', '-y', '-f', 'concat', '-safe', '0',
                         '-i', lst.name, '-acodec', 'libmp3lame', '-q:a', '2', str(out)],
                        capture_output=True)
                import os as _os; _os.unlink(lst.name)
                self._json(200, {'ok': True, 'gut': len(gut_clips), 'path': str(out)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/brueckner_gf_clip_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                tag = str(data.get('tag', '')).strip()
                if not clip_id or not _re.match(r'^clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                if tag and tag not in ('gut', 'musik', 'weg'):
                    self._json(400, {'ok': False, 'error': 'tag muss gut/musik/weg sein'}); return
                tags_file = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                if tag: tags[clip_id] = tag
                else: tags.pop(clip_id, None)
                tags_file.write_text(json.dumps(tags, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/brueckner_gf_emotion_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                emotion = str(data.get('emotion', '')).strip()
                active = data.get('active', None)  # None = toggle, True = add, False = remove
                GF_EMOTIONS = ('ruhig','bedrohlich','kalt','wütend','amüsiert','sarkastisch',
                               'nachdenklich','entschlossen','warm','flüsternd','dominant',
                               'überzeugend','nervös','ironisch','verärgert')
                if not clip_id or not _re.match(r'^clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                if emotion and emotion not in GF_EMOTIONS:
                    self._json(400, {'ok': False, 'error': 'ungültige emotion'}); return
                ef = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'emotion_tags.json'
                etags = json.loads(ef.read_text('utf-8')) if ef.exists() else {}
                cur = etags.get(clip_id, [])
                if isinstance(cur, str):
                    cur = [cur] if cur else []
                if emotion:
                    if active is None:
                        if emotion in cur: cur = [e for e in cur if e != emotion]
                        else: cur = cur + [emotion]
                    elif active:
                        if emotion not in cur: cur = cur + [emotion]
                    else:
                        cur = [e for e in cur if e != emotion]
                if cur: etags[clip_id] = cur
                else: etags.pop(clip_id, None)
                ef.write_text(json.dumps(etags, ensure_ascii=False, indent=2), 'utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/brueckner_gf_not_clean_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                dirty = bool(data.get('dirty', False))
                if not clip_id or not _re.match(r'^clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                nf = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'not_clean_tags.json'
                ntags = json.loads(nf.read_text('utf-8')) if nf.exists() else {}
                if dirty: ntags[clip_id] = True
                else: ntags.pop(clip_id, None)
                nf.write_text(json.dumps(ntags, ensure_ascii=False, indent=2), 'utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/brueckner_gf_export_selection':
            try:
                import subprocess as _sp, tempfile as _tf
                tags_file = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                gut_clips = sorted([k for k, v in tags.items() if v == 'gut'])
                if not gut_clips:
                    self._json(200, {'ok': False, 'error': 'Keine Gut-Clips markiert'}); return
                lst = _tf.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
                for cid in gut_clips:
                    p = WATSON_VOICES_DIR / 'brueckner_gf_clips' / f'{cid}.mp3'
                    if p.exists(): lst.write(f"file '{str(p)}'\n")
                lst.close()
                out = WATSON_VOICES_DIR / 'christian_brueckner' / 'source_gf_best.mp3'
                out.parent.mkdir(parents=True, exist_ok=True)
                _sp.run(['/opt/homebrew/bin/ffmpeg', '-y', '-f', 'concat', '-safe', '0',
                         '-i', lst.name, '-acodec', 'libmp3lame', '-q:a', '2', str(out)],
                        capture_output=True)
                import os as _os; _os.unlink(lst.name)
                self._json(200, {'ok': True, 'gut': len(gut_clips), 'path': str(out)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/elsholtz_clip_tag':
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                clip_id = str(data.get('clip_id', '')).strip()
                tag = str(data.get('tag', '')).strip()
                if not clip_id or not _re.match(r'^[a-z0-9_]+_clip_\d+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                if tag and tag not in ('gut', 'musik', 'weg'):
                    self._json(400, {'ok': False, 'error': 'tag muss gut/musik/weg sein'}); return
                tags_file = WATSON_VOICES_DIR / 'elsholtz_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                if tag: tags[clip_id] = tag
                else: tags.pop(clip_id, None)
                tags_file.write_text(json.dumps(tags, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/elsholtz_export_selection':
            try:
                import subprocess as _sp, tempfile as _tf
                tags_file = WATSON_VOICES_DIR / 'elsholtz_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                gut_clips = sorted([k for k, v in tags.items() if v == 'gut'])
                if not gut_clips:
                    self._json(200, {'ok': False, 'error': 'Keine Gut-Clips markiert'}); return
                lst = _tf.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
                for cid in gut_clips:
                    p = WATSON_VOICES_DIR / 'elsholtz_clips' / f'{cid}.mp3'
                    if p.exists(): lst.write(f"file '{str(p)}'\n")
                lst.close()
                out = WATSON_VOICES_DIR / 'arne_elsholtz' / 'source_best.mp3'
                _sp.run(['/opt/homebrew/bin/ffmpeg', '-y', '-f', 'concat', '-safe', '0',
                         '-i', lst.name, '-acodec', 'libmp3lame', '-q:a', '2', str(out)],
                        capture_output=True)
                import os as _os; _os.unlink(lst.name)
                self._json(200, {'ok': True, 'gut': len(gut_clips), 'path': str(out)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        elif self.path == '/api/rode_export_selection':
            # Kopiert Clips nach selected/erzaehler/ und selected/action/
            try:
                import shutil as _shutil
                tags_file = WATSON_VOICES_DIR / 'rode_clips' / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try:
                        tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception:
                        tags = {}
                erzaehler_dir = WATSON_VOICES_DIR / 'rode_clips' / 'selected' / 'erzaehler'
                action_dir = WATSON_VOICES_DIR / 'rode_clips' / 'selected' / 'action'
                erzaehler_dir.mkdir(parents=True, exist_ok=True)
                action_dir.mkdir(parents=True, exist_ok=True)
                erzaehler_count = 0
                action_count = 0
                for clip_id, tag in tags.items():
                    src = WATSON_VOICES_DIR / 'rode_clips' / f'{clip_id}.mp3'
                    if not src.exists():
                        continue
                    if tag == 'erzaehler':
                        _shutil.copy2(str(src), str(erzaehler_dir / f'{clip_id}.mp3'))
                        erzaehler_count += 1
                    elif tag == 'action':
                        _shutil.copy2(str(src), str(action_dir / f'{clip_id}.mp3'))
                        action_count += 1
                self._json(200, {'ok': True, 'erzaehler': erzaehler_count, 'action': action_count})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        # ── Generische Voice-Clip-Tag (POST) ─────────────────────────
        elif self.path == '/api/voice_clip_tag':
            # Body: {voice: str, clip_id: str, tag: "gut"|"musik"|"weg"|""}
            try:
                import re as _re
                data = json.loads(body.decode('utf-8')) if body else {}
                voice   = str(data.get('voice',   '')).strip()
                clip_id = str(data.get('clip_id', '')).strip()
                tag     = str(data.get('tag',     '')).strip()
                if not voice or not _re.match(r'^[a-z0-9_]+$', voice):
                    self._json(400, {'ok': False, 'error': 'ungültiger voice-Name'}); return
                if not clip_id or not _re.match(r'^[a-z0-9_]+$', clip_id):
                    self._json(400, {'ok': False, 'error': 'ungültige clip_id'}); return
                if tag and tag not in ('gut', 'musik', 'weg'):
                    self._json(400, {'ok': False, 'error': 'tag muss gut/musik/weg/"" sein'}); return
                clips_dir = WATSON_VOICES_DIR / f'{voice}_clips'
                clips_dir.mkdir(parents=True, exist_ok=True)
                tags_file = clips_dir / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                if tag: tags[clip_id] = tag
                else:   tags.pop(clip_id, None)
                tags_file.write_text(json.dumps(tags, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        # ── Generischer Voice-Export (POST) ──────────────────────────
        elif self.path == '/api/voice_export_selection':
            # Body: {voice: str}
            # Concat alle "gut"-Clips → watson_voices/<voice>/source_best.mp3
            try:
                import subprocess as _sp, tempfile as _tf, re as _re
                data  = json.loads(body.decode('utf-8')) if body else {}
                voice = str(data.get('voice', '')).strip()
                if not voice or not _re.match(r'^[a-z0-9_]+$', voice):
                    self._json(400, {'ok': False, 'error': 'ungültiger voice-Name'}); return
                clips_dir = WATSON_VOICES_DIR / f'{voice}_clips'
                tags_file = clips_dir / 'tags.json'
                tags = {}
                if tags_file.exists():
                    try: tags = json.loads(tags_file.read_text(encoding='utf-8'))
                    except Exception: pass
                gut_clips = sorted([k for k, v in tags.items() if v == 'gut'])
                if not gut_clips:
                    self._json(200, {'ok': False, 'error': 'Keine Gut-Clips markiert'}); return
                lst = _tf.NamedTemporaryFile(mode='w', suffix='.txt', delete=False)
                for cid in gut_clips:
                    p = clips_dir / f'{cid}.mp3'
                    if p.exists(): lst.write(f"file '{str(p)}'\n")
                lst.close()
                out_dir = WATSON_VOICES_DIR / voice
                out_dir.mkdir(parents=True, exist_ok=True)
                out = out_dir / 'source_best.mp3'
                _sp.run(['/opt/homebrew/bin/ffmpeg', '-y', '-f', 'concat', '-safe', '0',
                         '-i', lst.name, '-acodec', 'libmp3lame', '-q:a', '2', str(out)],
                        capture_output=True)
                import os as _os; _os.unlink(lst.name)
                self._json(200, {'ok': True, 'gut': len(gut_clips), 'path': str(out)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})

        # ── Lebensstationen JSON (POST) ──────────────────────────────
        elif self.path == '/api/lebensstationen':
            try:
                data = json.loads(body)
                out = COCKPIT_DIR / 'lebensstationen.json'
                out.write_text(json.dumps(data, indent=2, ensure_ascii=False))
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        # ── Glossar-Feedback speichern ───────────────────────────────
        elif self.path == '/api/glossar/feedback':
            try:
                data = json.loads(body) if body else {}
                feedback_file = Path(__file__).parent.parent / 'glossar_feedback.jsonl'
                line = json.dumps(data, ensure_ascii=False)
                with open(feedback_file, 'a', encoding='utf-8') as f:
                    f.write(line + '\n')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        # ── Quellen-Bewertung speichern ──────────────────────────────
        elif self.path == '/api/quellen_bewertung_save':
            try:
                data = json.loads(body)
                save_path = Path(__file__).parent / 'quellen_bewertung_bsl.json'
                save_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True, 'saved': str(save_path)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/reisebericht/quellen_bewertung_save':
            try:
                data = json.loads(body)
                stem = data.get('stem', '')
                if not stem:
                    self._json(400, {'ok': False, 'error': 'stem fehlt'}); return
                # Per-Report-Datei neben dem MP3/Debug-HTML
                safe_stem = stem.replace('_debug', '').replace('/', '_').replace('..', '')
                out_path = RB_OUTPUT_DIR / f'{safe_stem}_quellen_bewertung.json'
                out_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        # ── Server-Neustart via launchctl ────────────────────────────
        elif self.path == '/api/keychain/set':
            try:
                data = json.loads(body)
                service = data.get('service', '')
                account = data.get('account', '')
                token = data.get('token', '')
                if not service or not token:
                    self._json(400, {'ok': False, 'error': 'service und token erforderlich'})
                    return
                if not all(c.isalnum() or c in '-_.' for c in service):
                    self._json(400, {'ok': False, 'error': 'ungültiger service-name'})
                    return
                # Delete old entry first (ignore errors), then add new
                subprocess.run(
                    ['security', 'delete-generic-password', '-s', service],
                    capture_output=True
                )
                result = subprocess.run(
                    ['security', 'add-generic-password', '-s', service,
                     '-a', account or service, '-w', token],
                    capture_output=True, text=True
                )
                if result.returncode == 0:
                    self._json(200, {'ok': True})
                else:
                    self._json(500, {'ok': False, 'error': result.stderr.strip()})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/cloudflare/add-hostname':
            try:
                import urllib.request as _ureq
                data = json.loads(body)
                subdomain = data.get('subdomain', '').strip().lower()
                service   = data.get('service', 'http://localhost:8089').strip()
                if not subdomain or not subdomain.replace('-','').isalnum():
                    self._json(400, {'ok': False, 'error': 'Ungültiger Subdomain-Name'}); return
                token = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'cloudflare-api', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not token:
                    self._json(200, {'ok': False, 'error': 'Kein Token im Schlüsselbund'}); return
                CF_ACCOUNT  = '4063400905adb9cae4a6de2a32841545'
                CF_TUNNEL   = '75d3bc4e-18b5-442e-ac28-ff4850efbd4f'
                CF_ZONE_NAME = 'beachorchestra.com'
                hostname = f'{subdomain}.{CF_ZONE_NAME}'
                headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
                # 1. Zone ID holen
                req = _ureq.Request(f'https://api.cloudflare.com/client/v4/zones?name={CF_ZONE_NAME}', headers=headers)
                with _ureq.urlopen(req, timeout=10) as resp:
                    zdata = json.loads(resp.read())
                if not zdata.get('success') or not zdata.get('result'):
                    self._json(200, {'ok': False, 'error': 'Zone nicht gefunden'}); return
                zone_id = zdata['result'][0]['id']
                # 2. Tunnel-Config laden
                cfg_url = f'https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT}/cfd_tunnel/{CF_TUNNEL}/configurations'
                req = _ureq.Request(cfg_url, headers=headers)
                with _ureq.urlopen(req, timeout=10) as resp:
                    cfg_data = json.loads(resp.read())
                if not cfg_data.get('success'):
                    self._json(200, {'ok': False, 'error': 'Tunnel-Config nicht lesbar'}); return
                ingress = cfg_data.get('result', {}).get('config', {}).get('ingress', [])
                # Catchall sichern, neue Regel vorne einfügen
                catchall = [r for r in ingress if not r.get('hostname')]
                rules = [r for r in ingress if r.get('hostname') and r['hostname'] != hostname]
                rules.insert(0, {'hostname': hostname, 'service': service})
                rules.extend(catchall)
                # 3. Tunnel-Config speichern
                put_body = json.dumps({'config': {'ingress': rules}}).encode()
                req = _ureq.Request(cfg_url, data=put_body, headers=headers, method='PUT')
                with _ureq.urlopen(req, timeout=10) as resp:
                    put_data = json.loads(resp.read())
                if not put_data.get('success'):
                    errs = put_data.get('errors', [])
                    self._json(200, {'ok': False, 'error': errs[0].get('message') if errs else 'PUT fehlgeschlagen'}); return
                # 4. CNAME DNS-Eintrag anlegen
                dns_body = json.dumps({
                    'type': 'CNAME', 'name': hostname,
                    'content': f'{CF_TUNNEL}.cfargotunnel.com',
                    'proxied': True, 'ttl': 1
                }).encode()
                dns_url = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records'
                req = _ureq.Request(dns_url, data=dns_body, headers=headers, method='POST')
                try:
                    with _ureq.urlopen(req, timeout=10) as resp:
                        dns_data = json.loads(resp.read())
                    dns_ok = dns_data.get('success', False)
                except Exception as _e:
                    dns_ok = False
                self._json(200, {'ok': True, 'hostname': hostname, 'dns_created': dns_ok})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Token im Schlüsselbund'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/time-camera/jobs':
            try:
                import base64 as _ttc_b64, time as _ttc_time
                data = json.loads(body.decode('utf-8')) if body else {}
                request = data.get('request') or data
                job_id = _ttc_safe_job_id(request.get('id') or data.get('job_id') or f'ttc_{uuid.uuid4().hex}')
                job_dir = _ttc_job_dir(job_id)
                assets_dir = job_dir / 'assets'
                assets_dir.mkdir(parents=True, exist_ok=True)

                image_b64 = data.get('image_base64') or data.get('imageBase64') or ''
                if not image_b64:
                    self._json(400, {'ok': False, 'error': 'image_base64 fehlt'})
                    return
                image_bytes = _ttc_b64.b64decode(image_b64)
                image_path = assets_dir / 'original.jpg'
                image_path.write_bytes(image_bytes)

                prompt_text = data.get('prompt_text') or data.get('promptText') or ''
                if not prompt_text:
                    prompt_text = 'Time Travel - Past. Transform this exact image 100 years backward while preserving viewpoint, composition, people, faces, light and mood.'
                prompt_path = job_dir / 'prompt.txt'
                prompt_path.write_text(prompt_text, encoding='utf-8')

                thumb_path = assets_dir / 'thumb.jpg'
                _ttc_make_thumbnail(image_path, thumb_path)
                submit = _ttc_submit_to_fotolabor(job_id, request, image_path, prompt_path)
                now_ms = int(_ttc_time.time() * 1000)
                meta = {
                    'ok': True,
                    'job_id': job_id,
                    'created_at': now_ms,
                    'updated_at': now_ms,
                    'status': 'queued',
                    'submitted_to_fotolabor': True,
                    'request': {**request, 'id': submit['kameramotor_job_id'], 'image': str(image_path), 'prompt_file': str(prompt_path), 'output_dir': submit['output_dir']},
                    'kameramotor_job_id': submit['kameramotor_job_id'],
                    'queue': submit['queue'],
                    'assets': {'original': str(image_path), 'thumbnail': str(thumb_path), 'prompt': str(prompt_path)},
                }
                _atomic_json_write(_ttc_meta_path(job_id), meta)
                self._json(200, {**_ttc_status_for(job_id, meta), 'manifest_url': f'/api/time-camera/jobs/{job_id}/manifest'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/reisebericht/start':
            try:
                data = json.loads(body)
                name = str(data.get('name', '')).strip()
                lat  = float(data.get('lat', 0))
                lng  = float(data.get('lng', 0))
                address = str(data.get('address', '')).strip()
                # Name optional — aus Adresse ableiten wenn leer
                if not name and address:
                    name = address.split(',')[0].strip()
                if not name:
                    name = 'Reisebericht'
                if lat == 0 or lng == 0:
                    self._json(400, {'ok': False, 'error': 'lat und lng erforderlich'})
                    return
                job_id = str(uuid.uuid4())[:8]
                with RB_LOCK:
                    RB_JOBS[job_id] = {
                        'status': 'queued', 'step': 'geocode',
                        'detail': 'Wartet in der Queue…', 'name': name,
                        'lat': lat, 'lng': lng,
                        'address': address, 'filename': None, 'error': None,
                        'debug_log': [],
                    }
                with RB_QUEUE_LOCK:
                    RB_QUEUE.append(job_id)
                self._json(200, {'ok': True, 'job_id': job_id})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/reisebericht/bewertung':
            try:
                data = json.loads(body)
                fname = data.get('filename', '')
                bewertung = data.get('bewertung', {})
                if fname and bewertung:
                    RB_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
                    bew_file = RB_OUTPUT_DIR / (Path(fname).stem + '_bewertung.json')
                    import datetime as _dt
                    bew_file.write_text(json.dumps({
                        'filename': fname,
                        'bewertung': bewertung,
                        'saved_at': _dt.datetime.now().isoformat(),
                    }, ensure_ascii=False, indent=2))
                    self._json(200, {'ok': True})
                else:
                    self._json(400, {'ok': False, 'error': 'missing fields'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/sommerurlaub/save':
            try:
                import datetime as _dt_su
                data = json.loads(body)
                out = Path(COCKPIT_DIR) / 'sommerurlaub_feedback.json'
                data['_saved_at'] = _dt_su.datetime.now().isoformat()
                out.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding='utf-8')
                if data.get('final'):
                    try:
                        from lib.notify_send import send_apple_mail
                        summary = data.get('_summary', '')
                        mail_body = f"Mathias hat den Sommerurlaub-Fragebogen ausgefüllt.\n\n{summary}\n\n---\nGespeichert: {data['_saved_at']}"
                        send_apple_mail('victor@hotelgalaxy.de', 'Sommerurlaub 2025 — Mathias hat geantwortet ✓', mail_body)
                    except Exception as _me:
                        pass
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/sommerurlaub/build':
            try:
                with _SU_BUILD_LOCK:
                    if _SU_BUILD.get('status') == 'building':
                        self._json(200, {'ok': True, 'status': 'already_running'})
                        return
                    _SU_BUILD['status'] = 'building'
                    _SU_BUILD['step'] = 'Wird gestartet…'
                    _SU_BUILD['progress'] = 2
                    _SU_BUILD['error'] = ''
                t = threading.Thread(target=_su_build_thread, args=(COCKPIT_DIR,), daemon=True)
                t.start()
                self._json(200, {'ok': True, 'status': 'started'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/sommerurlaub/deeper':
            try:
                data = json.loads(body) if body else {}
                out = Path(COCKPIT_DIR) / 'sommerurlaub_deeper_requests.jsonl'
                import datetime as _dtd
                data['_saved_at'] = _dtd.datetime.now().isoformat()
                with open(out, 'a', encoding='utf-8') as f:
                    f.write(json.dumps(data, ensure_ascii=False) + '\n')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/sommerurlaub/deeper_build':
            try:
                data = json.loads(body) if body else {}
                option = data.get('option', '')
                with _SU_DEEPER_LOCK:
                    if _SU_DEEPER.get('status') == 'building':
                        self._json(200, {'ok': True, 'status': 'already_running'})
                        return
                    _SU_DEEPER['status'] = 'building'
                    _SU_DEEPER['step'] = 'Wird gestartet…'
                    _SU_DEEPER['progress'] = 2
                    _SU_DEEPER['error'] = ''
                    _SU_DEEPER['option'] = option
                t = threading.Thread(target=_su_deeper_thread, args=(COCKPIT_DIR, option), daemon=True)
                t.start()
                self._json(200, {'ok': True, 'status': 'started'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/server/restart':
            try:
                uid = os.getuid()
                subprocess.Popen(
                    ['launchctl', 'kickstart', '-k',
                     f'gui/{uid}/dev.beachorchestra.sancho-cockpit-server'],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL
                )
                self._json(200, {'ok': True, 'msg': 'Neustart eingeleitet'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/ftp/upload':
            try:
                import paramiko, pathlib as _pl
                data = json.loads(body.decode('utf-8')) if body else {}
                host   = str(data.get('host', '')).strip()
                port   = int(data.get('port', 22))
                user   = str(data.get('user', '')).strip()
                pw     = str(data.get('password', '')).strip()
                remote = str(data.get('remote_path', '/')).strip() or '/'
                if not host or not user or not pw:
                    self._json(400, {'ok': False, 'error': 'host, user und password erforderlich'})
                    return
                files_to_upload = [
                    _pl.Path(COCKPIT_DIR) / 'dispo2026.html',
                    _pl.Path(COCKPIT_DIR) / 'dispo_save.php',
                ]
                missing = [str(f) for f in files_to_upload if not f.exists()]
                if missing:
                    self._json(404, {'ok': False, 'error': f'Dateien fehlen: {missing}'})
                    return
                ssh = paramiko.SSHClient()
                ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
                ssh.connect(host, port=port, username=user, password=pw, timeout=15)
                sftp = ssh.open_sftp()
                try:
                    sftp.chdir(remote)
                except IOError:
                    try:
                        sftp.mkdir(remote)
                        sftp.chdir(remote)
                    except Exception as mkdir_err:
                        self._json(400, {'ok': False, 'error': f'Ordner konnte nicht erstellt werden: {remote} — {mkdir_err}'})
                        sftp.close(); ssh.close()
                        return
                log = []
                for f in files_to_upload:
                    sftp.put(str(f), f.name)
                    log.append(f.name)
                sftp.close(); ssh.close()
                self._json(200, {'ok': True, 'uploaded': log})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/stimmen/save':
            # Speichert Stimmen-Auswahl aus stimmen.html geräteübergreifend
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                choices_file = COCKPIT_DIR.parent / 'stimmen_choices.json'
                import time as _time
                payload = {
                    'choices':   data.get('choices',   {}),
                    'confirmed': data.get('confirmed', {}),
                    '_ts':       int(_time.time() * 1000),
                }
                choices_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/narration/control':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                action = data.get('action', '')  # 'start' oder 'stop'
                import time as _t
                ctrl_file = COCKPIT_DIR / 'narration_control.json'
                ctrl_file.write_text(json.dumps({
                    'action': action,
                    'ts': int(_t.time() * 1000),
                }, ensure_ascii=False))
                self._json(200, {'ok': True, 'action': action})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/flaneur/control':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                if not self._flaneur_token_ok():
                    self._json(401, {'ok': False, 'error': 'unauthorized'})
                    return
                action = data.get('action', '')
                import time as _t
                ctrl_file = COCKPIT_DIR / 'narration_control.json'
                event_ts = int(_t.time() * 1000)
                ctrl_file.write_text(json.dumps({
                    'action': action,
                    'ts': event_ts,
                    'source': 'flaneur_public_app',
                }, ensure_ascii=False))
                if action in ('start', 'stop', 'pause', 'resume', 'tell_now'):
                    route_event = {
                        'event': 'app_' + action,
                        'action': action,
                        'route_ts': event_ts,
                        'timestamp': int(_t.time()),
                        'source': 'flaneur_public_app',
                    }
                    try:
                        if FLANEUR_LOCATION_FILE.exists():
                            loc_raw = FLANEUR_LOCATION_FILE.read_text(encoding='utf-8')
                            loc, _ = json.JSONDecoder().raw_decode(loc_raw)
                            for key in ('lat', 'lng', 'accuracy', 'speed', 'course'):
                                if key in loc:
                                    route_event[key] = loc.get(key)
                            route_event['mapsUrl'] = loc.get('mapsUrl')
                    except Exception:
                        pass
                    with FLANEUR_ROUTE_LOG_FILE.open('a', encoding='utf-8') as f:
                        f.write(json.dumps(route_event, ensure_ascii=False) + '\n')
                self._json(200, {'ok': True, 'action': action})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/flaneur/location/push':
            try:
                if not self._flaneur_token_ok():
                    self._json(401, {'ok': False, 'error': 'unauthorized'})
                    return
                data = json.loads(body.decode('utf-8')) if body else {}
                lat = float(data.get('lat'))
                lng = float(data.get('lng'))
                import time as _t
                entry = {
                    'type': 'live_location',
                    'from': 'flaneur_ios_app',
                    'jid': 'flaneur_ios_app',
                    'fromMe': True,
                    'timestamp': int(_t.time()),
                    'lat': lat,
                    'lng': lng,
                    'accuracy': data.get('accuracy'),
                    'speed': data.get('speed'),
                    'course': data.get('course'),
                    'source': data.get('source') or 'flaneur_ios_public',
                    'received_at': int(_t.time() * 1000),
                    'mapsUrl': f'https://maps.google.com/?q={lat},{lng}',
                }
                tmp_loc = FLANEUR_LOCATION_FILE.with_suffix('.tmp')
                tmp_loc.write_text(json.dumps(entry, ensure_ascii=False, indent=2), encoding='utf-8')
                tmp_loc.replace(FLANEUR_LOCATION_FILE)
                route_entry = {
                    **entry,
                    'route_ts': entry['received_at'],
                    'event': 'gps_point',
                }
                with FLANEUR_ROUTE_LOG_FILE.open('a', encoding='utf-8') as f:
                    f.write(json.dumps(route_entry, ensure_ascii=False) + '\n')
                self._json(200, {'ok': True, 'location': entry})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
        elif self.path == '/api/reise_comment':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                eid = str(data.get('id', '')).strip()
                comment = str(data.get('comment', '')).strip()
                if not eid:
                    self._json(400, {'ok': False, 'error': 'id required'})
                    return
                existing = {}
                if REISE_COMMENTS_FILE.exists():
                    try:
                        existing = json.loads(REISE_COMMENTS_FILE.read_text(encoding='utf-8'))
                    except Exception:
                        existing = {}
                existing[eid] = comment
                REISE_COMMENTS_FILE.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/reise_annotation':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                eid = str(data.get('id', '')).strip()
                if not eid:
                    self._json(400, {'ok': False, 'error': 'id required'})
                    return
                existing = {}
                if REISE_ANNOTATIONS_FILE.exists():
                    try:
                        existing = json.loads(REISE_ANNOTATIONS_FILE.read_text(encoding='utf-8'))
                    except Exception:
                        existing = {}
                existing[eid] = data
                REISE_ANNOTATIONS_FILE.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/realdebrid/config':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                token = str(data.get('token', '')).strip()
                if not token:
                    self._json(400, {'ok': False, 'error': 'token required'})
                    return
                _rd_save_token(token)
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/ytdlp/download':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                url = str(data.get('url', '')).strip()
                audio_only = bool(data.get('audio_only', True))
                if not url:
                    self._json(400, {'ok': False, 'error': 'url required'}); return
                import uuid as _uuid, threading as _thr
                jid = str(_uuid.uuid4())[:8]
                with _YTDLP_LOCK:
                    _YTDLP_JOBS[jid] = {'id': jid, 'url': url, 'status': 'queued', 'progress': ''}
                _thr.Thread(target=_ytdlp_worker, args=(jid, url, audio_only, STIMMEN_YTDLP), daemon=True).start()
                self._json(200, {'ok': True, 'job_id': jid})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/sabnzbd/add_nzb':
            try:
                import urllib.request as _ur, urllib.parse as _up2
                data = json.loads(body.decode('utf-8')) if body else {}
                nzb_url = str(data.get('nzb_url', '')).strip()
                if not nzb_url:
                    self._json(400, {'ok': False, 'error': 'nzb_url required'}); return
                params = {'mode': 'addurl', 'name': nzb_url, 'apikey': SABNZBD_KEY, 'output': 'json'}
                url = f'{SABNZBD_URL}/api?{_up2.urlencode(params)}'
                with _ur.urlopen(url, timeout=10) as r:
                    result = json.loads(r.read())
                self._json(200, {'ok': True, 'result': result})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/sabnzbd/config_save':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                # Save NZBGeek key — only overwrite if non-empty
                if data.get('nzbgeek_key'):
                    NZBGEEK_CFG.parent.mkdir(parents=True, exist_ok=True)
                    NZBGEEK_CFG.write_text(json.dumps({'api_key': data['nzbgeek_key']}))
                # Save Usenet server directly into SABnzbd ini (API set_config not supported)
                if data.get('usenet_host') and data.get('usenet_pass'):
                    import urllib.request as _ur2, time as _time2, subprocess as _sp2, re as _re2
                    _ini = Path('/Users/victorholland/Library/Application Support/SABnzbd/sabnzbd.ini')
                    # Shutdown SABnzbd gracefully
                    try:
                        _ur2.urlopen(f'{SABNZBD_URL}/api?mode=shutdown&apikey={SABNZBD_KEY}&output=json', timeout=5)
                        _time2.sleep(2)
                    except Exception:
                        pass
                    host = data['usenet_host']
                    block = (
                        f'\n[servers]\n'
                        f'    [[{host}]]\n'
                        f'        host = {host}\n'
                        f'        port = {data.get("usenet_port", 563)}\n'
                        f'        username = {data.get("usenet_user", "")}\n'
                        f'        password = {data.get("usenet_pass", "")}\n'
                        f'        connections = 8\n'
                        f'        ssl = 1\n'
                        f'        ssl_verify = 2\n'
                        f'        ssl_ciphers = ""\n'
                        f'        enable = 1\n'
                        f'        optional = 0\n'
                        f'        retention = 0\n'
                        f'        send_group = 0\n'
                        f'        timeout = 60\n'
                        f'        priority = 0\n'
                        f'        displayname = {host}\n'
                    )
                    content = _ini.read_text()
                    if '[servers]' in content:
                        content = _re2.sub(r'\[servers\].*', block.strip(), content, flags=_re2.DOTALL)
                    else:
                        content += block
                    _ini.write_text(content)
                    # Restart SABnzbd
                    _sp2.Popen(['/Applications/SABnzbd.app/Contents/MacOS/SABnzbd'])
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/audible/download':
            try:
                import uuid as _uuid2, threading as _thr2
                data = json.loads(body.decode('utf-8')) if body else {}
                asin = str(data.get('asin', '')).strip()
                if not asin:
                    self._json(400, {'ok': False, 'error': 'asin required'}); return
                jid = str(_uuid2.uuid4())[:8]
                with _YTDLP_LOCK:
                    _YTDLP_JOBS[jid] = {'id': jid, 'asin': asin, 'status': 'queued', 'progress': ''}
                _thr2.Thread(target=_audible_download_worker, args=(jid, asin), daemon=True).start()
                self._json(200, {'ok': True, 'job_id': jid})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/realdebrid/add':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                magnet      = str(data.get('magnet', '')).strip()
                torrent_url = str(data.get('torrent_url', '')).strip()
                folder      = str(data.get('folder', '')).strip()
                if not magnet and not torrent_url:
                    self._json(400, {'ok': False, 'error': 'magnet oder torrent_url required'})
                    return
                if not folder:
                    self._json(400, {'ok': False, 'error': 'folder required'})
                    return
                if not _rd_token():
                    self._json(400, {'ok': False, 'error': 'Kein API-Key — bitte zuerst konfigurieren'})
                    return
                job_id = str(uuid.uuid4())[:8]
                label = (magnet or torrent_url)[:80] + '…'
                with RD_LOCK:
                    RD_JOBS[job_id] = {
                        'id': job_id, 'status': 'queued', 'magnet': label,
                        'folder': folder, 'files': [], 'progress': 'Wird gestartet…', 'error': None
                    }
                threading.Thread(target=_rd_download_worker,
                                 args=(job_id, magnet, folder, torrent_url),
                                 daemon=True, name=f'rd-{job_id}').start()
                self._json(200, {'ok': True, 'job_id': job_id})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path.startswith('/api/realdebrid/cancel/'):
            job_id = self.path[len('/api/realdebrid/cancel/'):].strip('/')
            with RD_LOCK:
                if job_id in RD_JOBS:
                    RD_JOBS[job_id]['status'] = 'cancelled'
                    RD_DOWNLOADS_FILE.write_text(json.dumps(RD_JOBS, ensure_ascii=False, indent=2))
            self._json(200, {'ok': True})

        elif self.path == '/api/linkedin/fetch':
            try:
                script = COCKPIT_DIR / 'linkedin_foto_fetch.py'
                if not script.exists():
                    self._json(404, {'ok': False, 'error': 'Script nicht gefunden'})
                    return
                proc = subprocess.Popen(
                    [sys.executable, str(script)],
                    stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                    cwd=str(COCKPIT_DIR)
                )
                out, _ = proc.communicate(timeout=300)
                log = out.decode('utf-8', errors='replace')
                ok = proc.returncode == 0
                self._json(200, {'ok': ok, 'log': log[-3000:], 'returncode': proc.returncode})
            except subprocess.TimeoutExpired:
                self._json(200, {'ok': False, 'log': 'Timeout nach 5 Minuten', 'returncode': -1})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/linkedin/status':
            photos_dir = COCKPIT_DIR / 'photos'
            report = photos_dir / 'fetch_report.json'
            if report.exists():
                data = json.loads(report.read_text())
                ok_n = sum(1 for v in data.values() if v)
                self._json(200, {'ok': True, 'fetched': ok_n, 'total': len(data), 'report': data})
            else:
                self._json(200, {'ok': False, 'fetched': 0, 'total': 0, 'report': {}})

        elif self.path == '/api/bahn/search':
            try:
                body = json.loads(body)
                import subprocess as _sp, shutil as _sh
                node = _sh.which('node') or '/opt/homebrew/bin/node'
                script = str(COCKPIT_DIR.parent / 'bahn' / 'bahn_search.mjs')
                proc = _sp.run(
                    [node, script, json.dumps(body)],
                    capture_output=True, text=True, timeout=60,
                    env={**__import__('os').environ, 'PATH': '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'}
                )
                if proc.returncode != 0 or not proc.stdout.strip():
                    self._json(500, {'error': proc.stderr or 'Node script fehlgeschlagen'})
                else:
                    self._json(200, json.loads(proc.stdout))
            except Exception as e:
                self._json(500, {'error': str(e)})

        # INSTRUMENT-AUSNAHME: Victor-Go — voices_audit Clip-Generierung und ElevenLabs Delete
        # ── Voice Audit — Weg-Batch, Sort, Delete ────────────────────────
        elif self.path == '/api/voice_audit/weg_batch':
            try:
                _va_data = json.loads(body) if body else {}
                _va_ids = _va_data.get('voice_ids', [])
                _va_file = WATSON_VOICES_DIR / 'voice_audit_weg_batch.json'
                _va_file.write_text(json.dumps(_va_ids, ensure_ascii=False, indent=2))
                self._json(200, {'ok': True, 'count': len(_va_ids)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/voice_audit/sort':
            try:
                _va_data = json.loads(body) if body else {}
                _va_vid = str(_va_data.get('voice_id', '')).strip()
                _va_file = WATSON_VOICES_DIR / 'voice_audit_sort.json'
                _va_sort = json.loads(_va_file.read_text(encoding='utf-8')) if _va_file.exists() else {}
                # Accept new array format (tags:[]) or old string format (tag:'')
                if 'tags' in _va_data:
                    _va_tags = _va_data['tags'] if isinstance(_va_data['tags'], list) else []
                    if _va_tags:
                        _va_sort[_va_vid] = _va_tags
                    else:
                        _va_sort.pop(_va_vid, None)
                else:
                    _va_tag = str(_va_data.get('tag', '')).strip()
                    if _va_tag:
                        _va_sort[_va_vid] = [_va_tag]
                    else:
                        _va_sort.pop(_va_vid, None)
                _va_file.write_text(json.dumps(_va_sort, ensure_ascii=False, indent=2))
                # Weg-Batch synchron aktualisieren
                _va_weg_file = WATSON_VOICES_DIR / 'voice_audit_weg_batch.json'
                _va_weg = json.loads(_va_weg_file.read_text(encoding='utf-8')) if _va_weg_file.exists() else []
                _va_cur_tags = _va_sort.get(_va_vid, [])
                _va_cur_tags = _va_cur_tags if isinstance(_va_cur_tags, list) else [_va_cur_tags]
                if 'weg' in _va_cur_tags and _va_vid not in _va_weg:
                    _va_weg.append(_va_vid)
                elif 'weg' not in _va_cur_tags and _va_vid in _va_weg:
                    _va_weg.remove(_va_vid)
                _va_weg_file.write_text(json.dumps(_va_weg, ensure_ascii=False, indent=2))
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/voice_audit/delete_weg_batch':
            import urllib.request as _va_ureq
            try:
                _va_weg_file = WATSON_VOICES_DIR / 'voice_audit_weg_batch.json'
                _va_weg = json.loads(_va_weg_file.read_text(encoding='utf-8')) if _va_weg_file.exists() else []
                _va_el_key = ''
                try:
                    for _valine in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
                        _valine = _valine.strip()
                        if _valine.startswith('ELEVENLABS_API_KEY='):
                            _va_el_key = _valine.split('=', 1)[1].strip()
                except Exception:
                    pass
                if not _va_el_key:
                    _r = subprocess.run(['op', 'read', 'op://Automation/ElevenLabs API Key/credential'],
                                        capture_output=True, text=True)
                    _va_el_key = _r.stdout.strip()
                deleted = []
                failed = []
                for _va_vid2 in _va_weg:
                    try:
                        _va_del_req = _va_ureq.Request(
                            f'https://api.elevenlabs.io/v1/voices/{_va_vid2}',
                            headers={'xi-api-key': _va_el_key},
                            method='DELETE'
                        )
                        _va_ureq.urlopen(_va_del_req, timeout=15)
                        deleted.append(_va_vid2)
                    except Exception as _ve:
                        failed.append({'id': _va_vid2, 'error': str(_ve)})
                _va_weg_file.write_text(json.dumps([], ensure_ascii=False))
                _va_sort_file = WATSON_VOICES_DIR / 'voice_audit_sort.json'
                if _va_sort_file.exists():
                    _va_sort = json.loads(_va_sort_file.read_text(encoding='utf-8'))
                    for _va_vid2 in deleted:
                        _va_sort.pop(_va_vid2, None)
                    _va_sort_file.write_text(json.dumps(_va_sort, ensure_ascii=False, indent=2))
                self._json(200, {'ok': True, 'deleted': deleted, 'failed': failed})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path.startswith('/api/voice_audit/generate/'):
            import threading as _va_thread, urllib.request as _va_ureq2
            try:
                _va_vid3 = self.path[len('/api/voice_audit/generate/'):].strip('/')
                _va_el_key3 = ''
                try:
                    for _valine3 in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
                        _valine3 = _valine3.strip()
                        if _valine3.startswith('ELEVENLABS_API_KEY='):
                            _va_el_key3 = _valine3.split('=', 1)[1].strip()
                except Exception:
                    pass
                if not _va_el_key3:
                    _r3 = subprocess.run(['op', 'read', 'op://Automation/ElevenLabs API Key/credential'],
                                         capture_output=True, text=True)
                    _va_el_key3 = _r3.stdout.strip()
                _va_sentences_file3 = WATSON_VOICES_DIR / 'voice_audit_sentences.json'
                _va_sentences3 = json.loads(_va_sentences_file3.read_text(encoding='utf-8')) if _va_sentences_file3.exists() else {}
                _va_clips_dir3 = WATSON_VOICES_DIR / 'voice_audit_clips'
                _va_clip_types3 = ['typical_1', 'typical_2', 'typical_3', 'aufgeregt', 'sarkastisch', 'verwundert', 'flehend']
                _va_langs3 = ['de', 'en']

                def _va_get_text3(vid, lang, ct):
                    ld = _va_sentences3.get(vid, {}).get(lang, {})
                    if ct.startswith('typical_'):
                        idx = int(ct.split('_')[1]) - 1
                        return (ld.get('typical') or ['', '', ''])[idx]
                    return ld.get(ct, '')

                def _va_gen_worker3():
                    import time as _va_time3
                    for lang in _va_langs3:
                        for ct in _va_clip_types3:
                            op3 = _va_clips_dir3 / _va_vid3 / f'{lang}_{ct}.mp3'
                            if op3.exists() and op3.stat().st_size > 1000:
                                continue
                            txt3 = _va_get_text3(_va_vid3, lang, ct)
                            if not txt3:
                                continue
                            emotional3 = ct in ('aufgeregt', 'sarkastisch', 'verwundert', 'flehend')
                            stab3 = 0.3 if emotional3 else 0.5
                            payload3 = json.dumps({
                                'text': txt3,
                                'model_id': 'eleven_multilingual_v2',
                                'voice_settings': {'stability': stab3, 'similarity_boost': 0.8}
                            }).encode()
                            req3 = _va_ureq2.Request(
                                f'https://api.elevenlabs.io/v1/text-to-speech/{_va_vid3}',
                                data=payload3,
                                headers={'xi-api-key': _va_el_key3, 'Content-Type': 'application/json', 'Accept': 'audio/mpeg'},
                                method='POST'
                            )
                            try:
                                op3.parent.mkdir(parents=True, exist_ok=True)
                                with _va_ureq2.urlopen(req3, timeout=60) as rr3:
                                    op3.write_bytes(rr3.read())
                            except Exception:
                                pass
                            _va_time3.sleep(0.5)

                _va_thread.Thread(target=_va_gen_worker3, daemon=True).start()
                self._json(200, {'ok': True, 'voice_id': _va_vid3, 'msg': 'Generierung gestartet'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/naming/vision-key':
            try:
                import subprocess as _vk_sp
                data = json.loads(body.decode('utf-8')) if body else {}
                key = data.get('key', '').strip()
                if not key or not key.startswith('AIza'):
                    self._json(400, {'ok': False, 'error': 'Kein gültiger Google API Key (muss mit AIza beginnen)'})
                else:
                    key_file = COCKPIT_DIR.parent / 'kameramotor' / 'vision_key.txt'
                    key_file.write_text(key, encoding='utf-8')
                    # Save to macOS keychain
                    _vk_sp.run(
                        ['security', 'add-generic-password',
                         '-s', 'google-vision', '-a', 'rat-der-weisen',
                         '-w', key, '-U'],
                        capture_output=True
                    )
                    # Start Vision pass in background
                    import shutil as _vk_sh
                    node_bin = _vk_sh.which('node') or '/opt/homebrew/bin/node'
                    mjs = COCKPIT_DIR.parent / 'kameramotor' / 'naming_test.mjs'
                    log_f = open(str(COCKPIT_DIR.parent / 'kameramotor' / 'vision_pass.log'), 'w')
                    _vk_sp.Popen([node_bin, str(mjs)], cwd=str(mjs.parent),
                                  stdout=log_f, stderr=log_f,
                                  env={**__import__('os').environ, 'PATH': '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'})
                    self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/naming/analyze-one':
            # Live-Analyse eines einzelnen Bildes durch einen Generator
            # Body: {filename, generator: 'gemini'|'vision'}
            # Returns: {ok, result: {...}, elapsed_ms}
            try:
                import subprocess as _an_sp, time as _an_t, re as _an_re, uuid as _an_uuid
                data = json.loads(body.decode('utf-8')) if body else {}
                filename  = data.get('filename', '').strip()
                generator = data.get('generator', '').strip()
                img_dir   = Path('/Users/victorholland/Vibe Coding/The Camera/Testumgebung/Benennungstests')
                img_path  = img_dir / filename
                if not filename or not generator:
                    self._json(400, {'ok': False, 'error': 'filename + generator required'})
                elif not img_path.exists():
                    self._json(404, {'ok': False, 'error': f'Bild nicht gefunden: {filename}'})
                elif generator not in ('gemini', 'vision'):
                    self._json(400, {'ok': False, 'error': f'Unbekannter Generator: {generator}'})
                else:
                    import shutil as _an_sh
                    node_bin = _an_sh.which('node') or '/opt/homebrew/bin/node'
                    mjs = COCKPIT_DIR.parent / 'kameramotor' / 'naming_analyze_one.mjs'
                    t0  = _an_t.time()
                    env = {**__import__('os').environ,
                           'PATH': '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'}
                    proc = _an_sp.run(
                        [node_bin, str(mjs), str(img_path), generator],
                        capture_output=True, text=True, timeout=90, env=env,
                        cwd=str(COCKPIT_DIR.parent / 'kameramotor')
                    )
                    elapsed_ms = int((_an_t.time() - t0) * 1000)
                    if proc.returncode != 0:
                        # Error JSON is on stdout (mjs always writes JSON there)
                        try:
                            err_data = json.loads(proc.stdout.strip())
                            err_msg = err_data.get('error', proc.stderr.strip()[-300:] or 'Unbekannter Fehler')
                        except Exception:
                            err_msg = proc.stderr.strip()[-300:] or proc.stdout.strip()[-300:] or 'Unbekannter Fehler'
                        self._json(500, {'ok': False, 'error': err_msg})
                    else:
                        result = json.loads(proc.stdout.strip())
                        self._json(200, {'ok': True, 'result': result, 'elapsed_ms': elapsed_ms})
            except __import__('subprocess').TimeoutExpired:
                self._json(504, {'ok': False, 'error': 'Timeout nach 90s'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/naming/feedback':
            try:
                import threading as _fb_t
                data = json.loads(body.decode('utf-8')) if body else {}
                filename = data.get('filename', '')
                generator = data.get('generator', '')
                if not filename or not generator:
                    self._json(400, {'ok': False, 'error': 'filename + generator required'})
                else:
                    fb_file = COCKPIT_DIR.parent / 'kameramotor' / 'naming_feedback.json'
                    lock_path = str(fb_file) + '.lock'
                    # simple file-based save (single-threaded server, lock is belt+suspenders)
                    existing = {}
                    if fb_file.exists():
                        try: existing = json.loads(fb_file.read_text(encoding='utf-8'))
                        except Exception: pass
                    if filename not in existing:
                        existing[filename] = {}
                    existing[filename][generator] = {
                        'stars': int(data.get('stars', 0)),
                        'notes': str(data.get('notes', '')),
                        'savedAt': data.get('savedAt', ''),
                    }
                    fb_file.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
                    self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/kameramotor/export-to-photos':
            try:
                import subprocess as _ph_sp, threading as _ph_th, tempfile as _ph_tmp, os as _ph_os
                data = json.loads(body.decode('utf-8')) if body else {}
                job_id = data.get('job_id', '')
                if not job_id:
                    self._json(400, {'ok': False, 'error': 'job_id fehlt'})
                else:
                    km_dir = COCKPIT_DIR.parent / 'kameramotor'
                    job_file   = km_dir / 'done' / f'{job_id}.json'
                    state_file = km_dir / 'state' / f'{job_id}.json'
                    if not job_file.exists():
                        self._json(404, {'ok': False, 'error': 'Job nicht gefunden'})
                    else:
                        job   = json.loads(job_file.read_text())
                        state = json.loads(state_file.read_text()) if state_file.exists() else {}
                        paths_to_import = []
                        orig = state.get('original_path') or job.get('image', '')
                        if orig and Path(orig).exists():
                            paths_to_import.append(str(orig))
                        dl_list = state.get('downloaded', [])
                        if isinstance(dl_list, dict):
                            dl_list = list(dl_list.values())
                        for dl in sorted(dl_list, key=lambda x: x.get('outPath', '')):
                            if dl and dl.get('outPath') and Path(dl['outPath']).exists():
                                paths_to_import.append(str(dl['outPath']))
                        if not paths_to_import:
                            self._json(404, {'ok': False, 'error': 'Keine Bilddateien gefunden'})
                        else:
                            # Escape Pfade: Anführungszeichen im Pfad escapen
                            def _esc(p): return p.replace('"', '\\"')
                            posix_list = ', '.join(f'POSIX file "{_esc(p)}"' for p in paths_to_import)
                            script = (
                                'tell application "Photos"\n'
                                '  activate\n'
                                '  if not (exists album "The Camera") then\n'
                                '    make new album named "The Camera"\n'
                                '  end if\n'
                                '  set theAlbum to album "The Camera"\n'
                                f'  import {{{posix_list}}} into theAlbum skip check duplicates yes\n'
                                'end tell\n'
                            )
                            # Temp-Datei statt -e Flag (vermeidet Shell-Encoding-Probleme)
                            tmp = _ph_tmp.NamedTemporaryFile(
                                mode='w', suffix='.applescript', delete=False, encoding='utf-8')
                            tmp.write(script); tmp.close()
                            count = len(paths_to_import)
                            # Im Hintergrund starten → HTTP-Request blockiert nicht
                            def _run_import(path):
                                try:
                                    _ph_sp.run(['osascript', path], capture_output=True, timeout=180)
                                finally:
                                    try: _ph_os.unlink(path)
                                    except: pass
                            _ph_th.Thread(target=_run_import, args=(tmp.name,), daemon=True).start()
                            self._json(200, {'ok': True, 'count': count, 'status': 'importing'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/kameramotor/upload-image':
            try:
                import uuid as _up_uuid, re as _up_re
                ct = self.headers.get('Content-Type', '')
                # Base64-JSON-Upload (zuverlässiger durch Cloudflare als multipart)
                if 'application/json' in ct:
                    import base64 as _b64
                    data = json.loads(body.decode('utf-8'))
                    fname = data.get('filename', 'image.jpg')
                    b64_data = data.get('data', '')
                    field_data = _b64.b64decode(b64_data)
                    ext = Path(fname).suffix.lower() or '.jpg'
                    uid = _up_uuid.uuid4().hex[:12]
                    uploads_dir = Path.home() / '.kameramotor_uploads'
                    uploads_dir.mkdir(parents=True, exist_ok=True)
                    dest = uploads_dir / f'{uid}{ext}'
                    dest.write_bytes(field_data)
                    try:
                        if ext in ('.heic', '.heif'):
                            import subprocess as _sp_n
                            jpg_dest = dest.with_suffix('.jpg')
                            r = _sp_n.run(['sips', '--setProperty', 'format', 'jpeg',
                                           '--setProperty', 'formatOptions', '92',
                                           str(dest), '--out', str(jpg_dest)],
                                          capture_output=True)
                            if r.returncode == 0 and jpg_dest.exists():
                                dest.unlink(missing_ok=True)
                                dest = jpg_dest
                        elif ext in ('.jpg', '.jpeg', '.png', '.webp'):
                            from PIL import Image as _Im, ImageOps as _IO
                            with _Im.open(dest) as _src:
                                _rotated = _IO.exif_transpose(_src)
                                save_kwargs = {}
                                if ext in ('.jpg', '.jpeg'):
                                    save_kwargs = {'quality': 92, 'optimize': True}
                                    if _rotated.mode != 'RGB':
                                        _rotated = _rotated.convert('RGB')
                                _rotated.save(dest, **save_kwargs)
                    except Exception as _exif_err:
                        print(f'[upload-image] EXIF-Normalize failed: {_exif_err}')
                    # SHA-256 Dedup
                    import time as _up_time
                    file_hash = _sha256_of_file(dest)
                    with _originals_registry_lock:
                        registry = _load_originals_registry()
                        if file_hash in registry:
                            existing = registry[file_hash]
                            try: dest.unlink(missing_ok=True)
                            except Exception: pass
                            self._json(200, {'ok': True, 'path': existing['path'],
                                            'group_id': existing['group_id'],
                                            'is_existing': True})
                        else:
                            group_id = file_hash[:16]
                            registry[file_hash] = {
                                'path':     str(dest),
                                'group_id': group_id,
                                'filename': fname,
                                'ts':       int(_up_time.time()),
                            }
                            _save_originals_registry(registry)
                            self._json(200, {'ok': True, 'path': str(dest),
                                            'group_id': group_id,
                                            'is_existing': False})
                    return
                bm = _up_re.search(r'boundary=([^\s;]+)', ct)
                if 'multipart/form-data' not in ct or not bm:
                    self._json(400, {'ok': False, 'error': 'multipart/form-data mit boundary erwartet'})
                else:
                    boundary = bm.group(1).strip('"').encode()
                    field_data = None
                    fname = 'image.jpg'
                    # Manual multipart parse — works on Python 3.13+ (cgi removed)
                    for part in body.split(b'--' + boundary):
                        if b'\r\n\r\n' not in part:
                            continue
                        hdr_raw, content = part.split(b'\r\n\r\n', 1)
                        if content.endswith(b'\r\n'):
                            content = content[:-2]
                        if b'name="file"' in hdr_raw:
                            fn_m = _up_re.search(rb'filename="([^"]+)"', hdr_raw)
                            if fn_m:
                                fname = fn_m.group(1).decode('utf-8', errors='replace')
                            field_data = content
                            break
                    if field_data is None:
                        self._json(400, {'ok': False, 'error': 'Kein file-Feld im Upload'})
                    else:
                        ext = Path(fname).suffix.lower() or '.jpg'
                        uid = _up_uuid.uuid4().hex[:12]
                        uploads_dir = Path.home() / '.kameramotor_uploads'
                        uploads_dir.mkdir(parents=True, exist_ok=True)
                        dest = uploads_dir / f'{uid}{ext}'
                        dest.write_bytes(field_data)
                        # EXIF orientation muss in die Pixel gebrannt werden, sonst
                        # sieht der Image-Prozessor sideways pixels (kennt EXIF nicht)
                        # und liefert gedrehte Outputs zurück.
                        try:
                            if ext in ('.heic', '.heif'):
                                # sips wendet EXIF Orientation beim Format-Convert an
                                import subprocess as _sp_n
                                jpg_dest = dest.with_suffix('.jpg')
                                r = _sp_n.run(['sips', '--setProperty', 'format', 'jpeg',
                                               '--setProperty', 'formatOptions', '92',
                                               str(dest), '--out', str(jpg_dest)],
                                              capture_output=True)
                                if r.returncode == 0 and jpg_dest.exists():
                                    dest.unlink(missing_ok=True)
                                    dest = jpg_dest
                            elif ext in ('.jpg', '.jpeg', '.png', '.webp'):
                                from PIL import Image as _Im, ImageOps as _IO
                                with _Im.open(dest) as _src:
                                    _rotated = _IO.exif_transpose(_src)
                                    save_kwargs = {}
                                    if ext in ('.jpg', '.jpeg'):
                                        save_kwargs = {'quality': 92, 'optimize': True}
                                        if _rotated.mode != 'RGB':
                                            _rotated = _rotated.convert('RGB')
                                    _rotated.save(dest, **save_kwargs)
                        except Exception as _exif_err:
                            # Bei Fehler: Original bleibt erhalten, lieber kein EXIF-Fix
                            # als kaputtes Bild — wird im Log sichtbar.
                            print(f'[upload-image] EXIF-Normalize failed: {_exif_err}')
                        # SHA-256 Dedup
                        import time as _up_time_mp
                        file_hash = _sha256_of_file(dest)
                        with _originals_registry_lock:
                            registry = _load_originals_registry()
                            if file_hash in registry:
                                existing = registry[file_hash]
                                try: dest.unlink(missing_ok=True)
                                except Exception: pass
                                self._json(200, {'ok': True, 'path': existing['path'],
                                                'group_id': existing['group_id'],
                                                'is_existing': True})
                            else:
                                group_id = file_hash[:16]
                                registry[file_hash] = {
                                    'path':     str(dest),
                                    'group_id': group_id,
                                    'filename': fname,
                                    'ts':       int(_up_time_mp.time()),
                                }
                                _save_originals_registry(registry)
                                self._json(200, {'ok': True, 'path': str(dest),
                                                'group_id': group_id,
                                                'is_existing': False})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/kameramotor/job':
            try:
                import time as _km_t, uuid as _km_uuid
                data = json.loads(body.decode('utf-8')) if body else {}
                job_id = f"{int(_km_t.time())}_{_km_uuid.uuid4().hex[:6]}"
                submitted_ms = int(_km_t.time() * 1000)
                provider = _kameramotor_provider_from_payload(data)
                valid, error = _validate_kameramotor_payload(data, provider)
                if not valid:
                    self._json(400, {'ok': False, 'error': error, 'provider': provider})
                    return
                jobs_dir, state_dir = _kameramotor_target_dirs(provider)
                job = _build_kameramotor_job(data, job_id, submitted_ms, provider)
                _atomic_json_write(state_dir / f'{job_id}.json', {
                    'submitted_at': submitted_ms,
                    'provider': provider,
                    'queue': str(jobs_dir),
                })
                _atomic_json_write(jobs_dir / f'{job_id}.json', job)
                self._json(200, {
                    'ok': True,
                    'job_id': job_id,
                    'provider': provider,
                    'queue': str(jobs_dir),
                    'legacy_blocked': True,
                })
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/kameramotor/openai-job':
            try:
                import time as _oai_t, uuid as _oai_uuid
                data = json.loads(body.decode('utf-8')) if body else {}
                job_id = f"oai_{int(_oai_t.time())}_{_oai_uuid.uuid4().hex[:6]}"
                km_dir = COCKPIT_DIR.parent / 'kameramotor'
                jobs_dir = km_dir / 'openai_jobs'
                jobs_dir.mkdir(parents=True, exist_ok=True)
                submitted_ms = int(_oai_t.time() * 1000)
                seeds_in = data.get('seeds')
                seeds_clean = seeds_in if isinstance(seeds_in, list) else None
                job = {k: v for k, v in {
                    'id':             job_id,
                    'generator':      'chatgpt',
                    'provider':       'openai',
                    'openai_model':   data.get('openai_model', 'gpt-image-2'),
                    'official_api':   'openai-images',
                    'image':          data.get('image', ''),
                    'prompt':         data.get('prompt', ''),
                    'prompt_file':    data.get('prompt_file', ''),
                    'ratio':          data.get('ratio', 'auto'),
                    'num_images':     int(data.get('num_images', 1)),
                    'quality':        data.get('quality', 'high'),
                    'size':           data.get('size', 'auto'),
                    'output_format':  data.get('output_format', 'png'),
                    'output_dir':     data.get('output_dir', str(Path.home() / 'Desktop' / 'Kameramotor')),
                    'stem':           data.get('stem', ''),
                    'filter':         data.get('filter', ''),
                    'source':         data.get('source', 'The Camera'),
                    'submitted_at':   submitted_ms,
                    'status':         'waiting_for_openai_worker',
                }.items() if v}
                if seeds_clean is not None: job['seeds'] = seeds_clean
                job['group_id'] = data.get('group_id') or None
                state_dir = km_dir / 'openai_state'
                state_dir.mkdir(parents=True, exist_ok=True)
                (state_dir / f'{job_id}.json').write_text(
                    json.dumps({
                        'submitted_at': submitted_ms,
                        'status': 'waiting_for_openai_worker',
                        'note': 'Official OpenAI API route prepared; no API call made by cockpit server.',
                    }, indent=2, ensure_ascii=False),
                    encoding='utf-8')
                (jobs_dir / f'{job_id}.json').write_text(
                    json.dumps(job, indent=2, ensure_ascii=False),
                    encoding='utf-8')
                try:
                    import subprocess as _oai_sp, sys as _oai_sys
                    worker = km_dir / 'openai_image_worker.py'
                    _oai_sp.Popen([_oai_sys.executable, str(worker), '--once'],
                                  stdout=_oai_sp.DEVNULL, stderr=_oai_sp.DEVNULL)
                except Exception:
                    pass
                self._json(200, {'ok': True, 'job_id': job_id, 'status': 'queued_openai'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path.startswith('/api/kameramotor/openai-approve/'):
            # Freigabe für einen einzelnen offiziellen OpenAI-Bildjob.
            try:
                import time as _oai_t
                job_id = self.path[len('/api/kameramotor/openai-approve/'):].strip('/')
                if not job_id.startswith('oai_'):
                    self._json(400, {'ok': False, 'error': 'Kein OpenAI-Job'})
                    return
                km_dir = COCKPIT_DIR.parent / 'kameramotor'
                job_file = km_dir / 'openai_jobs' / f'{job_id}.json'
                state_file = km_dir / 'openai_state' / f'{job_id}.json'
                if not job_file.exists():
                    self._json(404, {'ok': False, 'error': 'OpenAI-Job nicht gefunden'})
                    return
                job = json.loads(job_file.read_text(encoding='utf-8'))
                data = json.loads(body.decode('utf-8')) if body else {}
                job['allow_paid_api'] = True
                job['approved_at'] = int(_oai_t.time() * 1000)
                job['approved_by'] = data.get('approved_by') or 'Victor'
                job_file.write_text(json.dumps(job, indent=2, ensure_ascii=False), encoding='utf-8')
                state = {}
                if state_file.exists():
                    try: state = json.loads(state_file.read_text(encoding='utf-8'))
                    except Exception: state = {}
                state.update({
                    'status': 'approved_for_openai_api',
                    'note': 'Victor-Go erteilt; OpenAI-Worker darf genau diesen Job ausführen.',
                    'approved_at': job['approved_at'],
                })
                state_file.parent.mkdir(parents=True, exist_ok=True)
                state_file.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding='utf-8')
                try:
                    import subprocess as _oai_sp, sys as _oai_sys
                    worker = km_dir / 'openai_image_worker.py'
                    _oai_sp.Popen([_oai_sys.executable, str(worker), '--once'],
                                  stdout=_oai_sp.DEVNULL, stderr=_oai_sp.DEVNULL)
                except Exception:
                    pass
                self._json(200, {'ok': True, 'job_id': job_id, 'status': 'approved_for_openai_api'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path.startswith('/api/kameramotor/cancel/'):
            # Cancel a pending job — only if not yet submitted to Magnific.
            try:
                job_id = self.path[len('/api/kameramotor/cancel/'):].strip('/')
                km_dir = COCKPIT_DIR.parent / 'kameramotor'
                is_openai = job_id.startswith('oai_')
                job_file   = km_dir / ('openai_jobs' if is_openai else 'jobs') / f'{job_id}.json'
                state_file = km_dir / ('openai_state' if is_openai else 'state') / f'{job_id}.json'
                if state_file.exists():
                    try:
                        st = json.loads(state_file.read_text())
                        # If already touched a provider, refuse cancel
                        if st.get('identifiers') or st.get('magnific_submit_at') or st.get('started_at') or st.get('openai_request_id'):
                            self._json(409, {'ok': False, 'error': 'already submitted to provider'})
                            return
                    except Exception:
                        pass
                removed = []
                if job_file.exists():   job_file.unlink();   removed.append('jobs')
                if state_file.exists(): state_file.unlink(); removed.append('state')
                if not removed:
                    self._json(404, {'ok': False, 'error': 'job not found'})
                    return
                self._json(200, {'ok': True, 'removed': removed})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        elif self.path == '/api/kameramotor/open-folder':
            try:
                import subprocess as _sp
                data = json.loads(body.decode('utf-8')) if body else {}
                folder = data.get('path', '')
                home_base = '/Users/victorholland'
                real = str(Path(folder).resolve())
                if folder and real.startswith(home_base) and Path(folder).exists():
                    _sp.Popen(['open', folder])
                    self._json(200, {'ok': True})
                else:
                    self._json(400, {'ok': False, 'error': 'Ungültiger Pfad'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/kameramotor/settings':
            try:
                data = json.loads(body.decode('utf-8')) if body else {}
                sf = COCKPIT_DIR.parent / 'kameramotor' / 'settings.json'
                sf.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8')
                self._json(200, {'ok': True})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/task/submit':
            try:
                # Bearer-Token-Auth
                if not _check_task_auth(self.headers):
                    self._json(401, {'ok': False, 'error': 'Unauthorized — Bearer-Token fehlt oder ungültig'})
                    return
                # Payload-Grössenlimit
                if len(body) > TASK_MAX_PAYLOAD_BYTES:
                    self._json(400, {'ok': False, 'error': f'Payload zu gross (max. {TASK_MAX_PAYLOAD_BYTES//1000} KB)'})
                    return
                # Tages-Limit
                ok_limit, count = _check_task_daily_limit()
                if not ok_limit:
                    self._json(429, {'ok': False, 'error': f'Tageslimit erreicht ({TASK_DAILY_LIMIT} Tasks/Tag)'})
                    return
                import uuid as _uuid
                data = json.loads(body.decode('utf-8')) if body else {}
                required = ['type', 'title', 'payload']
                missing = [f for f in required if not data.get(f)]
                if missing:
                    self._json(400, {'ok': False, 'error': f'Fehlende Felder: {", ".join(missing)}'})
                    return
                if data.get('type') not in ('task', 'decision', 'context'):
                    self._json(400, {'ok': False, 'error': 'type muss task, decision oder context sein'})
                    return
                # Payload-Blacklist (Loop-Schutz)
                payload_str = str(data.get('payload', ''))
                for banned in TASK_PAYLOAD_BLACKLIST:
                    if banned.lower() in payload_str.lower():
                        self._json(400, {'ok': False, 'error': f'Payload enthält verbotenes Muster: {banned}'})
                        return
                import datetime as _dt
                task_id = data.get('id') or f"{_dt.datetime.now().strftime('%Y%m%d-%H%M%S')}-{_uuid.uuid4().hex[:6]}"
                task = {
                    'id': task_id,
                    'from': data.get('from', 'chatgpt'),
                    'type': data['type'],
                    'title': str(data['title'])[:120],
                    'payload': payload_str[:TASK_MAX_PAYLOAD_BYTES],
                    'priority': data.get('priority', 'normal'),
                    'ts': data.get('ts', _dt.datetime.utcnow().isoformat() + 'Z'),
                    'received_at': _dt.datetime.utcnow().isoformat() + 'Z',
                    'depth': 0,
                }
                outbox = COCKPIT_DIR.parent / 'OUTBOX'
                outbox.mkdir(exist_ok=True)
                fname = f"{_dt.datetime.now().strftime('%Y%m%d-%H%M%S')}_{task_id}.json"
                (outbox / fname).write_text(json.dumps(task, indent=2, ensure_ascii=False), encoding='utf-8')
                _increment_task_daily_count()
                self._json(200, {'ok': True, 'id': task_id, 'filename': fname, 'status': 'queued'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        # ── ChatGPT Importer ──────────────────────────────────────────────
        elif self.path == '/api/chatgpt/upload':
            # Datei-Upload für chatgpt_importer.html — speichert in chatgpt_importer/uploads/
            try:
                import tempfile as _tf, email as _em
                content_type = self.headers.get('Content-Type', '')
                uploads_dir = Path(__file__).parent.parent / 'chatgpt_importer' / 'uploads'
                uploads_dir.mkdir(parents=True, exist_ok=True)
                # Grenze aus Content-Type extrahieren
                boundary = None
                for part in content_type.split(';'):
                    part = part.strip()
                    if part.startswith('boundary='):
                        boundary = part[9:].strip().encode()
                        break
                if not boundary:
                    self._json(400, {'ok': False, 'error': 'Kein Boundary gefunden'})
                    return
                # Multipart manuell parsen
                lines = body.split(b'\r\n')
                filename = None
                file_content = None
                in_file = False
                file_lines = []
                for i, line in enumerate(lines):
                    if boundary in line:
                        if file_lines:
                            file_content = b'\r\n'.join(file_lines[:-1] if file_lines and not file_lines[-1] else file_lines)
                        file_lines = []
                        in_file = False
                    elif b'Content-Disposition' in line:
                        m = re.search(rb'filename="([^"]+)"', line)
                        if m:
                            filename = m.group(1).decode('utf-8', errors='replace')
                            in_file = True
                    elif b'Content-Type' in line and in_file:
                        pass  # überspringen
                    elif in_file and line == b'' and not file_lines:
                        pass  # Header-Body-Trenner
                    elif in_file:
                        file_lines.append(line)
                if file_lines and file_content is None:
                    file_content = b'\r\n'.join(file_lines)
                if not filename or file_content is None:
                    self._json(400, {'ok': False, 'error': 'Datei nicht gefunden im Upload'})
                    return
                safe_name = re.sub(r'[^a-zA-Z0-9._\-]', '_', filename)
                out_path = uploads_dir / safe_name
                out_path.write_bytes(file_content)
                self._json(200, {'ok': True, 'path': str(out_path), 'filename': safe_name})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/chatgpt/import':
            try:
                import sys as _sys
                _sys.path.insert(0, str(Path(__file__).parent.parent))
                from chatgpt_importer import core as _cgi
                data = json.loads(body) if body else {}
                source = str(data.get('path', '')).strip()
                skip_pii = bool(data.get('skip_pii_check', False))
                if not source:
                    self._json(400, {'ok': False, 'error': 'path erforderlich'})
                    return
                result = _cgi.import_export(source, skip_pii_check=skip_pii)
                self._json(200, result)
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/chatgpt/redact-report':
            try:
                import sys as _sys
                _sys.path.insert(0, str(Path(__file__).parent.parent))
                from chatgpt_importer import core as _cgi
                result = _cgi.redact_report()
                self._json(200, result)
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/chatgpt/summarize-profile':
            try:
                import sys as _sys
                _sys.path.insert(0, str(Path(__file__).parent.parent))
                from chatgpt_importer import core as _cgi
                result = _cgi.summarize_profile()
                self._json(200, result)
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        # ── Cherry Rückkanal ──────────────────────────────────────────────
        elif self.path == '/api/cherry/inbox/mark-read':
            try:
                data = json.loads(body) if body else {}
                ids  = data.get('ids', [])
                if not isinstance(ids, list) or not ids:
                    self._json(400, {'ok': False, 'error': 'ids-Liste erforderlich'})
                    return
                count = _cherry_inbox.mark_read(ids) if _CHERRY_OK else 0
                self._json(200, {'ok': True, 'updated': count})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/cherry/inbox/poll':
            try:
                new_count = _cherry_inbox.force_poll() if _CHERRY_OK else 0
                self._json(200, {'ok': True, 'new_messages': new_count})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/cherry/notify/config':
            try:
                data = json.loads(body) if body else {}
                if _CHERRY_OK:
                    updated = _cherry_notify.save_config(data)
                    self._json(200, {'ok': True, 'config': _cherry_notify.get_config()})
                else:
                    self._json(503, {'ok': False, 'error': 'Cherry-Modul nicht geladen'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/cherry/notify/test':
            try:
                results = _cherry_notify.notify_test() if _CHERRY_OK else {}
                self._json(200, {'ok': True, 'channels': results})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        elif self.path == '/api/cherry/bridge/start':
            import urllib.request as _ur2, urllib.error as _ue2
            try:
                _ur2.urlopen('http://localhost:9225/json', timeout=2)
                self._json(200, {'ok': True, 'note': 'Bridge läuft bereits'})
            except Exception:
                try:
                    import subprocess as _bsp
                    _bsp.Popen([
                        '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
                        '--remote-debugging-port=9225',
                        f'--user-data-dir={Path.home()}/.cherry-chrome',
                        'https://chatgpt.com',
                    ], stdout=_bsp.DEVNULL, stderr=_bsp.DEVNULL)
                    self._json(200, {'ok': True, 'note': 'Chrome startet — ChatGPT öffnet sich'})
                except Exception as _be:
                    self._json(500, {'ok': False, 'error': str(_be)})

        elif self.path == '/api/cherry/send':
            # Sendet Nachricht an Cherry via bridge.py — Bridge muss laufen (Chrome Port 9225)
            try:
                data    = json.loads(body) if body else {}
                message = str(data.get('message', '')).strip()
                thread_url = str(data.get('thread_url', '')).strip() or None
                if not message:
                    self._json(400, {'ok': False, 'error': 'message erforderlich'})
                    return
                bridge_path = Path(__file__).parent.parent / 'chatgpt_bridge' / 'bridge.py'
                if not bridge_path.exists():
                    self._json(503, {'ok': False, 'error': 'bridge.py nicht gefunden'})
                    return

                import subprocess as _sp
                import sys as _sys
                cmd = [_sys.executable, str(bridge_path), message]
                if thread_url:
                    cmd.append(thread_url)

                def _run_bridge():
                    try:
                        result = _sp.run(cmd, capture_output=True, text=True, timeout=660)
                        return result.returncode == 0, result.stdout, result.stderr
                    except Exception as e:
                        return False, '', str(e)

                # Synchron ausführen — bridge.py wartet auf Antwort (bis 10 Min.)
                # Thread damit der Server nicht blockiert
                import queue as _q
                result_queue = _q.Queue()

                def _bridge_thread():
                    ok, out, err = _run_bridge()
                    result_queue.put((ok, out, err))

                t = threading.Thread(target=_bridge_thread, daemon=True)
                t.start()
                # Warte max 5s auf sofortige Antwort (Fehler/Bridge-nicht-da)
                t.join(timeout=5)

                if not t.is_alive():
                    ok, out, err = result_queue.get_nowait()
                    if ok:
                        self._json(200, {'ok': True, 'note': 'Gesendet, Antwort in Inbox'})
                    else:
                        self._json(500, {'ok': False, 'error': err[:300] or 'Bridge-Fehler'})
                else:
                    # Bridge läuft noch (normal — wartet auf Cherry-Antwort)
                    self._json(202, {'ok': True, 'status': 'sending', 'note': 'Bridge sendet — Antwort erscheint im Inbox'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        # ── OpenAI Web Search via Responses API ───────────────────────────
        elif self.path == '/api/cherry/web_search':
            try:
                import subprocess as _sp2, urllib.request as _ur, urllib.error as _ue
                data    = json.loads(body) if body else {}
                query   = str(data.get('query', '')).strip()
                context = str(data.get('context_size', 'medium'))
                if not query:
                    self._json(400, {'ok': False, 'error': 'query erforderlich'})
                    return
                # Key aus Schlüsselbund
                oai_key = _sp2.check_output(
                    ['security', 'find-generic-password', '-s', 'openai', '-w'],
                    stderr=_sp2.DEVNULL, text=True
                ).strip()
                if not oai_key:
                    self._json(503, {'ok': False, 'error': 'OpenAI-Key nicht im Schlüsselbund'})
                    return
                payload = json.dumps({
                    'model': 'gpt-4o-mini',
                    'input': query,
                    'tools': [{'type': 'web_search_preview', 'search_context_size': context}],
                }).encode()
                req = _ur.Request(
                    'https://api.openai.com/v1/responses', data=payload,
                    headers={'Authorization': f'Bearer {oai_key}', 'Content-Type': 'application/json'},
                    method='POST'
                )
                with _ur.urlopen(req, timeout=30) as resp:
                    result = json.loads(resp.read())
                # Text aus output extrahieren
                text = ''
                annotations = []
                for item in result.get('output', []):
                    if item.get('type') == 'message':
                        for c in item.get('content', []):
                            if c.get('type') == 'output_text':
                                text = c.get('text', '')
                                annotations = c.get('annotations', [])
                usage = result.get('usage', {})
                self._json(200, {
                    'ok': True,
                    'text': text,
                    'annotations': annotations[:10],
                    'model': result.get('model', ''),
                    'response_id': result.get('id', ''),
                    'tokens': {'input': usage.get('input_tokens', 0), 'output': usage.get('output_tokens', 0)},
                })
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        # ── Responses API Sendeweg (kein Browser nötig) ───────────────────
        elif self.path == '/api/cherry/send_via_api':
            try:
                import subprocess as _sp3, urllib.request as _ur2
                data    = json.loads(body) if body else {}
                message = str(data.get('message', '')).strip()
                prev_id = str(data.get('previous_response_id', '')).strip() or None
                system  = str(data.get('system', '')).strip()
                if not message:
                    self._json(400, {'ok': False, 'error': 'message erforderlich'})
                    return
                oai_key = _sp3.check_output(
                    ['security', 'find-generic-password', '-s', 'openai', '-w'],
                    stderr=_sp3.DEVNULL, text=True
                ).strip()
                if not oai_key:
                    self._json(503, {'ok': False, 'error': 'OpenAI-Key nicht im Schlüsselbund'})
                    return
                payload_dict = {'model': 'gpt-4o-mini', 'input': message}
                if prev_id:
                    payload_dict['previous_response_id'] = prev_id
                if system:
                    payload_dict['instructions'] = system
                payload = json.dumps(payload_dict).encode()
                req = _ur2.Request(
                    'https://api.openai.com/v1/responses', data=payload,
                    headers={'Authorization': f'Bearer {oai_key}', 'Content-Type': 'application/json'},
                    method='POST'
                )
                with _ur2.urlopen(req, timeout=45) as resp:
                    result = json.loads(resp.read())
                text = ''
                for item in result.get('output', []):
                    if item.get('type') == 'message':
                        for c in item.get('content', []):
                            if c.get('type') == 'output_text':
                                text = c.get('text', '')
                usage = result.get('usage', {})
                resp_id = result.get('id', '')
                # Antwort in cherry_messages.db speichern (als "api"-Kanal)
                if _CHERRY_OK and text:
                    import sys as _sys2
                    _sys2.path.insert(0, str(Path(__file__).parent.parent / 'chatgpt_bridge'))
                    from message_schema import make_message
                    msg_out = make_message(body=message, from_='watson', to='gpt-api', subject='API-Senden', meta={'channel': 'responses_api'})
                    msg_in  = make_message(body=text, from_='gpt-api', to='watson', subject='API-Antwort', meta={'response_id': resp_id, 'channel': 'responses_api', 'prompt': message[:120]})
                    _cherry_inbox._store_message(_cherry_inbox.init_db(), msg_out)
                    _cherry_inbox._store_message(_cherry_inbox.init_db(), msg_in)
                self._json(200, {
                    'ok': True,
                    'text': text,
                    'response_id': resp_id,
                    'model': result.get('model', ''),
                    'tokens': {'input': usage.get('input_tokens', 0), 'output': usage.get('output_tokens', 0)},
                    'estimated_cost_usd': round(usage.get('input_tokens', 0) * 0.00000015 + usage.get('output_tokens', 0) * 0.0000006, 6),
                })
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})

        else:
            self._json(404, {'error': 'not found'})

    def do_GET(self):
        parsed = urlparse(self.path)
        if self._voice_v32_proxy():
            return
        if parsed.path.startswith('/api/flaneur/tile/'):
            parts = parsed.path.strip('/').split('/')
            if len(parts) != 8:
                self._json(404, {'ok': False, 'error': 'bad tile path'})
                return
            _, flaneur, tile, provider, style, z, x, y_file = parts
            if flaneur != 'flaneur' or tile != 'tile' or not y_file.endswith('.png'):
                self._json(404, {'ok': False, 'error': 'bad tile path'})
                return
            y = y_file[:-4]
            if not (z.isdigit() and x.isdigit() and y.isdigit()):
                self._json(400, {'ok': False, 'error': 'bad zxy'})
                return
            cfg = _FLANEUR_TILE_PROVIDERS.get(provider)
            if not cfg or style not in cfg.get('styles', {}):
                self._json(404, {'ok': False, 'error': 'unknown tile style'})
                return
            try:
                kind = cfg.get('kind')
                if kind == 'mapbox':
                    key = _flaneur_keychain(cfg['service'], cfg.get('account', ''))
                    if not key:
                        self._json(503, {'ok': False, 'error': 'mapbox key missing'})
                        return
                    style_id = cfg['styles'][style]
                    url = f'https://api.mapbox.com/styles/v1/{style_id}/tiles/256/{z}/{x}/{y}@2x?access_token={key}'
                elif kind == 'thunderforest':
                    key = _flaneur_keychain(cfg['service'], cfg.get('account', ''))
                    if not key:
                        self._json(503, {'ok': False, 'error': 'thunderforest key missing'})
                        return
                    style_id = cfg['styles'][style]
                    url = f'https://api.thunderforest.com/{style_id}/{z}/{x}/{y}.png?apikey={key}'
                elif kind == 'maptiler':
                    key = _flaneur_keychain(cfg['service'], cfg.get('account', ''))
                    if not key:
                        self._json(503, {'ok': False, 'error': 'maptiler key missing'})
                        return
                    style_id = cfg['styles'][style]
                    url = f'https://api.maptiler.com/maps/{style_id}/256/{z}/{x}/{y}.png?key={key}'
                elif kind == 'stadia':
                    key = _flaneur_keychain(cfg['service'], cfg.get('account', ''))
                    if not key:
                        self._json(503, {'ok': False, 'error': 'stadia key missing'})
                        return
                    style_id, ext = cfg['styles'][style]
                    url = f'https://tiles.stadiamaps.com/tiles/{style_id}/{z}/{x}/{y}.{ext}?api_key={key}'
                elif kind == 'wms3857':
                    wms = cfg['styles'][style]
                    minx, miny, maxx, maxy = _flaneur_xyz_bbox_3857(int(z), int(x), int(y))
                    url = wms['url'] + '?' + urlencode({
                        'SERVICE': 'WMS',
                        'VERSION': '1.3.0',
                        'REQUEST': 'GetMap',
                        'LAYERS': wms['layers'],
                        'STYLES': '',
                        'FORMAT': wms.get('format', 'image/png'),
                        'TRANSPARENT': wms.get('transparent', 'true'),
                        'CRS': 'EPSG:3857',
                        'WIDTH': '256',
                        'HEIGHT': '256',
                        'BBOX': f'{minx},{miny},{maxx},{maxy}',
                    })
                elif kind == 'arcgis-tile':
                    url = cfg['styles'][style].format(z=z, x=x, y=y)
                elif kind == 'jawg':
                    key = _flaneur_keychain(cfg['service'], cfg.get('account', ''))
                    if not key:
                        self._json(503, {'ok': False, 'error': 'jawg key missing'})
                        return
                    style_id = cfg['styles'][style]
                    url = f'https://tile.jawg.io/{style_id}/{z}/{x}/{y}.png?access-token={key}'
                elif kind == 'geoapify':
                    key = _flaneur_keychain(cfg['service'], cfg.get('account', ''))
                    if not key:
                        self._json(503, {'ok': False, 'error': 'geoapify key missing'})
                        return
                    style_id = cfg['styles'][style]
                    url = f'https://maps.geoapify.com/v1/tile/{style_id}/{z}/{x}/{y}.png?apiKey={key}'
                elif kind == 'template':
                    url = cfg['styles'][style].format(z=z, x=x, y=y)
                else:
                    self._json(404, {'ok': False, 'error': 'bad provider'})
                    return
                req = _urlrequest.Request(url, headers={
                    'User-Agent': 'FlaneurTileProxy/1.0 (beachorchestra.com)',
                    'Accept': 'image/png,image/*;q=0.8,*/*;q=0.5',
                })
                with _urlrequest.urlopen(req, timeout=12) as r:
                    data = r.read()
                    ctype = r.headers.get('Content-Type', 'image/png')
                self.send_response(200)
                self.send_header('Content-Type', ctype)
                self.send_header('Content-Length', str(len(data)))
                self.send_header('Cache-Control', 'public, max-age=86400')
                self._cors()
                self.end_headers()
                self.wfile.write(data)
            except _urlerror.HTTPError as e:
                self._json(e.code if 400 <= e.code < 600 else 502, {'ok': False, 'error': 'tile upstream error'})
            except Exception as e:
                self._json(502, {'ok': False, 'error': 'tile proxy failed'})
            return
        if parsed.path in ('/api/flaneur/live_location', '/api/whatsapp/live_location'):
            if FLANEUR_LOCATION_FILE.exists():
                try:
                    loc = json.loads(FLANEUR_LOCATION_FILE.read_text(encoding='utf-8'))
                except Exception:
                    loc = {}
                self._json(200, {'found': bool(loc.get('lat') is not None and loc.get('lng') is not None), 'location': loc})
            else:
                self._json(200, {'found': False, 'location': {}})
            return

        # Host-basiertes Routing: reisebericht.beachorchestra.com → reisebericht.html
        _host = self.headers.get('Host', '')
        if _host.startswith('sommerurlaub.'):
            _su_map = {
                ('/', '', '/sommerurlaub.html'): ('sommerurlaub.html', 'text/html; charset=utf-8', 'no-store'),
                ('/angebot', '/angebot.html', '/sommerurlaub_angebot.html'): ('sommerurlaub_angebot.html', 'text/html; charset=utf-8', 'no-store'),
                ('/tief', '/tief.html', '/sommerurlaub_tief.html'): ('sommerurlaub_tief.html', 'text/html; charset=utf-8', 'no-store'),
                ('/sommerurlaub.webmanifest',): ('sommerurlaub.webmanifest', 'application/manifest+json', 'no-store'),
                ('/sommerurlaub_angebot.webmanifest',): ('sommerurlaub_angebot.webmanifest', 'application/manifest+json', 'no-store'),
                ('/sommerurlaub_tief.webmanifest',): ('sommerurlaub_tief.webmanifest', 'application/manifest+json', 'no-store'),
                ('/sommerurlaub-apple-touch-icon.png',): ('sommerurlaub-apple-touch-icon.png', 'image/png', 'max-age=86400'),
                ('/sommerurlaub-icon-512.png',): ('sommerurlaub-icon-512.png', 'image/png', 'max-age=86400'),
                ('/apple-touch-icon.png',): ('sommerurlaub-apple-touch-icon.png', 'image/png', 'max-age=86400'),
            }
            for _paths, (_fname, _ctype, _cc) in _su_map.items():
                if parsed.path in _paths:
                    _su_file = Path(COCKPIT_DIR) / _fname
                    if _su_file.exists():
                        _su_data = _su_file.read_bytes()
                        self.send_response(200)
                        self.send_header('Content-Type', _ctype)
                        self.send_header('Content-Length', len(_su_data))
                        self.send_header('Cache-Control', _cc)
                        self._cors()
                        self.end_headers()
                        self.wfile.write(_su_data)
                        return
            # Archived version routing: /v/1, /v/2, ...
            import re as _re_su
            _vm = _re_su.match(r'^/v/(\d+)$', parsed.path)
            if _vm:
                _vn = int(_vm.group(1))
                _vfile = Path(COCKPIT_DIR) / f'sommerurlaub_angebot_v{_vn:03d}.html'
                if _vfile.exists():
                    _vdata = _vfile.read_bytes()
                    self.send_response(200)
                    self.send_header('Content-Type', 'text/html; charset=utf-8')
                    self.send_header('Content-Length', len(_vdata))
                    self.send_header('Cache-Control', 'no-store')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(_vdata)
                    return
                self._json(404, {'error': f'Version {_vn} nicht gefunden'})
                return
        if _host.startswith('reisebericht.') and parsed.path in ('/', ''):
            self.send_response(302)
            self.send_header('Location', '/reisebericht.html')
            self._cors()
            self.end_headers()
            return
        if _host.startswith('voice.') and parsed.path in ('/', ''):
            self.send_response(302)
            self.send_header('Location', '/mikrofon')
            self._cors()
            self.end_headers()
            return
        if _host.startswith('kameramotor.') and parsed.path in ('/', ''):
            self.send_response(302)
            self.send_header('Location', '/kameramotor.html')
            self._cors()
            self.end_headers()
            return
        if _host.startswith('fotolabor.') and parsed.path in ('/', ''):
            self.send_response(302)
            self.send_header('Location', '/thecamera.html?labor=fotolabor')
            self._cors()
            self.end_headers()
            return
        if _host.startswith('filmlabor.') and parsed.path in ('/', ''):
            self.send_response(302)
            self.send_header('Location', '/thecamera.html?labor=filmlabor')
            self._cors()
            self.end_headers()
            return
        if _host.startswith('camera.') and parsed.path in ('/', ''):
            self.send_response(302)
            self.send_header('Location', '/thecamera.html')
            self._cors()
            self.end_headers()
            return
        if parsed.path in ('/mikrofon', '/mikrofon/'):
            _mik_file = Path(COCKPIT_DIR) / 'voice_v2.html'
            _mik_data = _mik_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_mik_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_mik_data)
            return
        if parsed.path in ('/mikrofon.webmanifest',):
            _mwm_file = Path(COCKPIT_DIR) / 'mikrofon.webmanifest'
            _mwm_data = _mwm_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'application/manifest+json')
            self.send_header('Content-Length', len(_mwm_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_mwm_data)
            return
        if parsed.path in ('/alarme', '/alarme/', '/alarmzentrale', '/alarmzentrale/'):
            _alarm_file = Path(COCKPIT_DIR) / 'alarmzentrale.html'
            _alarm_data = _alarm_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_alarm_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_alarm_data)
            return
        if parsed.path in ('/alarmzentrale.webmanifest',):
            _alarm_m_file = Path(COCKPIT_DIR) / 'alarmzentrale.webmanifest'
            _alarm_m_data = _alarm_m_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'application/manifest+json')
            self.send_header('Content-Length', len(_alarm_m_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_alarm_m_data)
            return
        if parsed.path == '/api/alarms':
            qs = parse_qs(parsed.query)
            try:
                limit = int(qs.get('limit', ['80'])[0])
            except Exception:
                limit = 80
            self._json(200, _alarm_state(max(1, min(limit, 200))))
            return
        if parsed.path in ('/mikrofon-v2', '/mikrofon-v2/'):
            _mv2_file = Path(COCKPIT_DIR) / 'mikrofon_v2.html'
            _mv2_data = _mv2_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_mv2_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_mv2_data)
            return
        if parsed.path in ('/mikrofon-v2.webmanifest',):
            _mv2m_file = Path(COCKPIT_DIR) / 'mikrofon-v2.webmanifest'
            _mv2m_data = _mv2m_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'application/manifest+json')
            self.send_header('Content-Length', len(_mv2m_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_mv2m_data)
            return
        if parsed.path in ('/thecamera', '/thecamera/'):
            _tc_file = Path(COCKPIT_DIR) / 'thecamera.html'
            _tc_data = _tc_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_tc_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_tc_data)
            return
        if parsed.path in ('/mathe-mai', '/mathe-mai/'):
            _m_file = Path(COCKPIT_DIR) / 'mathe-lernhilfe.html'
            _m_data = _m_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_m_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_m_data)
            return
        if parsed.path in ('/emil/spickzettel', '/spickzettel'):
            _sf = Path(COCKPIT_DIR) / 'emil_spickzettel.html'
            _sd = _sf.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_sd))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_sd)
            return
        if parsed.path in ('/emil/rueckseite', '/rueckseite'):
            _rf = Path(COCKPIT_DIR) / 'emil_spickzettel_rueckseite.html'
            _rd = _rf.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_rd))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_rd)
            return
        if parsed.path in ('/emil', '/emil/'):
            _ef = Path(COCKPIT_DIR) / 'emil_session.html'
            _ed = _ef.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_ed))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_ed)
            return
        if parsed.path == '/api/lernbegleiter/session-data':
            import json as _jsd, datetime as _dsd
            sessions_dir = Path(COCKPIT_DIR) / 'lernbegleiter_sessions'
            # optional ?date=YYYY-MM-DD param, defaults to today
            qs = __import__('urllib.parse', fromlist=['parse_qs']).parse_qs(parsed.query)
            day = qs.get('date', [_dsd.date.today().isoformat()])[0]
            log_file = sessions_dir / f'{day}.jsonl'
            events = []
            if log_file.exists():
                for line in log_file.read_text(encoding='utf-8').splitlines():
                    line = line.strip()
                    if line:
                        try:
                            ev = _jsd.loads(line)
                            # don't send full base64 in listing — too heavy; keep for download
                            if ev.get('type') == 'foto':
                                ev = {k: v for k, v in ev.items() if k != 'image_b64'}
                                ev['has_image'] = True
                            events.append(ev)
                        except Exception:
                            pass
            fotos = [e for e in events if e.get('type') == 'foto']
            kurzchecks = [e for e in events if e.get('type') == 'kurzcheck']
            chats = [e for e in events if e.get('type') == 'chat']
            self._json(200, {
                'date': day,
                'total_events': len(events),
                'fotos': fotos,
                'kurzchecks': kurzchecks,
                'chats': chats,
            })
            return
        if parsed.path == '/api/lernbegleiter/session-image':
            import json as _jsi, datetime as _dsi
            sessions_dir = Path(COCKPIT_DIR) / 'lernbegleiter_sessions'
            qs2 = __import__('urllib.parse', fromlist=['parse_qs']).parse_qs(parsed.query)
            day2 = qs2.get('date', [_dsi.date.today().isoformat()])[0]
            idx = int(qs2.get('idx', ['0'])[0])
            log_file2 = sessions_dir / f'{day2}.jsonl'
            foto_events = []
            if log_file2.exists():
                for line in log_file2.read_text(encoding='utf-8').splitlines():
                    line = line.strip()
                    if line:
                        try:
                            ev = _jsi.loads(line)
                            if ev.get('type') == 'foto':
                                foto_events.append(ev)
                        except Exception:
                            pass
            if idx >= len(foto_events) or not foto_events[idx].get('image_b64'):
                self._json(404, {'error': 'not found'})
                return
            import base64 as _b64
            img_bytes = _b64.b64decode(foto_events[idx]['image_b64'])
            self.send_response(200)
            self.send_header('Content-Type', 'image/jpeg')
            self.send_header('Content-Length', len(img_bytes))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(img_bytes)
            return
        if parsed.path == '/api/lernbegleiter/version':
            _lb_file = Path(COCKPIT_DIR) / 'lernbegleiter.html'
            _mtime = int(_lb_file.stat().st_mtime) if _lb_file.exists() else 0
            self._json(200, {'mtime': _mtime})
            return
        if parsed.path == '/api/lernbegleiter/state':
            _st_file = Path(COCKPIT_DIR) / 'lernbegleiter_state.json'
            if _st_file.exists():
                _st_data = _st_file.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.send_header('Content-Length', len(_st_data))
                self.send_header('Cache-Control', 'no-store')
                self._cors()
                self.end_headers()
                self.wfile.write(_st_data)
            else:
                self._json(404, {'status': 'no_state'})
            return
        if parsed.path in ('/mathe-begleiter', '/mathe-begleiter/', '/mathe-mai26', '/mathe-mai26/'):
            _mb_file = Path(COCKPIT_DIR) / 'lernbegleiter.html'
            _mb_data = _mb_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', len(_mb_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_mb_data)
            return
        if parsed.path == '/lernbegleiter.webmanifest':
            _mf_file = Path(COCKPIT_DIR) / 'lernbegleiter.webmanifest'
            _mf_data = _mf_file.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', 'application/manifest+json')
            self.send_header('Content-Length', len(_mf_data))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            self.wfile.write(_mf_data)
            return
        if parsed.path.startswith('/photos/'):
            import os as _os
            fname = parsed.path[len('/photos/'):].lstrip('/')
            # Nur .jpg und .json erlaubt, kein Pfad-Traversal
            if '/' not in fname and '.' in fname and fname.split('.')[-1] in ('jpg','jpeg','json','png'):
                fpath = COCKPIT_DIR / 'photos' / fname
                if fpath.exists():
                    ext = fname.split('.')[-1].lower()
                    mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png', 'json': 'application/json'}.get(ext, 'application/octet-stream')
                    data = fpath.read_bytes()
                    self.send_response(200)
                    self.send_header('Content-Type', mime)
                    self.send_header('Content-Length', len(data))
                    self._cors()
                    self.end_headers()
                    self.wfile.write(data)
                    return
            self.send_response(404); self.end_headers(); return
        if parsed.path == '/api/sommerurlaub/load':
            try:
                data_file = Path(COCKPIT_DIR) / 'sommerurlaub_feedback.json'
                if data_file.exists():
                    saved = json.loads(data_file.read_text(encoding='utf-8'))
                    self._json(200, {'ok': True, 'data': saved})
                else:
                    self._json(200, {'ok': True, 'data': None})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/sommerurlaub/build_status':
            with _SU_BUILD_LOCK:
                self._json(200, {**_SU_BUILD})
            return
        if parsed.path == '/api/sommerurlaub/build_reset':
            with _SU_BUILD_LOCK:
                _SU_BUILD['status'] = 'idle'
                _SU_BUILD['step'] = ''
                _SU_BUILD['progress'] = 0
                _SU_BUILD['error'] = ''
            self._json(200, {'ok': True})
            return
        if parsed.path == '/api/sommerurlaub/deeper_status':
            with _SU_DEEPER_LOCK:
                self._json(200, {**_SU_DEEPER})
            return
        if parsed.path == '/api/sommerurlaub/versions':
            versions_file = Path(COCKPIT_DIR) / 'sommerurlaub_versions.json'
            try:
                vers = json.loads(versions_file.read_text(encoding='utf-8')) if versions_file.exists() else []
            except Exception:
                vers = []
            self._json(200, {'ok': True, 'versions': list(reversed(vers))})
            return
        if parsed.path == '/api/sommerurlaub/config':
            import subprocess as _sp
            try:
                tok = _sp.check_output(
                    ['security', 'find-generic-password', '-s', 'mapbox-token', '-a', 'beachorchestra', '-w'],
                    stderr=_sp.DEVNULL
                ).decode().strip()
                self._json(200, {'ok': True, 'mapbox_token': tok})
            except Exception as _e:
                self._json(500, {'ok': False, 'error': str(_e)})
            return
        if parsed.path == '/api/health':
            from datetime import datetime as _dt
            self._json(200, {'ok': True, 'server': 'sancho-cockpit', 'ts': _dt.now().isoformat()})
            return
        if parsed.path == '/api/victor_input/chatgpt_input':
            # Liest ChatGPT-Textarea live via CDP (Port 9225)
            try:
                import asyncio as _aio, websockets as _ws2
                async def _read_chatgpt():
                    tabs_resp = __import__('urllib.request', fromlist=['urlopen']).urlopen(
                        'http://localhost:9225/json', timeout=2).read()
                    tabs = json.loads(tabs_resp)
                    chatgpt_tab = next((t for t in tabs if 'chatgpt.com' in t.get('url', '')), None)
                    if not chatgpt_tab:
                        return None
                    ws_url = chatgpt_tab.get('webSocketDebuggerUrl') or f"ws://localhost:9225/devtools/page/{chatgpt_tab['id']}"
                    async with _ws2.connect(ws_url, open_timeout=3) as ws:
                        cmd = json.dumps({'id': 99, 'method': 'Runtime.evaluate', 'params': {
                            'expression': '(document.querySelector("#prompt-textarea p") || document.querySelector("#prompt-textarea"))?.textContent?.trim() || ""',
                            'returnByValue': True
                        }})
                        await ws.send(cmd)
                        resp = await _aio.wait_for(ws.recv(), timeout=3)
                        data = json.loads(resp)
                        return data.get('result', {}).get('result', {}).get('value', '')
                text = _aio.run(_read_chatgpt())
                if text is None:
                    self._json(200, {'ok': False, 'error': 'Kein ChatGPT-Tab auf Port 9225'})
                else:
                    self._json(200, {'ok': True, 'text': text})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/victor_input/glossary':
            try:
                glossary_file = Path(__file__).parent.parent / 'victor_input' / 'glossary' / 'glossary.json'
                if glossary_file.exists():
                    data = json.loads(glossary_file.read_text(encoding='utf-8'))
                    self._json(200, {'ok': True, **data})
                else:
                    self._json(200, {'ok': True, 'terms': [], 'corrections': {}, 'prompt': ''})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/voice/load_transcript':
            try:
                from datetime import datetime as _dt_vl
                date_str = _dt_vl.now().strftime('%Y-%m-%d')
                transcript_file = Path(__file__).parent.parent / 'victor_input' / 'transcripts' / f'transcript_{date_str}.txt'
                if transcript_file.exists():
                    text = transcript_file.read_text(encoding='utf-8')
                    ts = int(transcript_file.stat().st_mtime * 1000)
                    self._json(200, {'ok': True, 'text': text, 'ts': ts})
                else:
                    self._json(200, {'ok': True, 'text': '', 'ts': 0})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/reise_comments':
            try:
                data = json.loads(REISE_COMMENTS_FILE.read_text(encoding='utf-8')) if REISE_COMMENTS_FILE.exists() else {}
                self._json(200, data)
            except Exception as e:
                self._json(500, {'error': str(e)})
            return
        if parsed.path == '/api/reise_annotations':
            try:
                data = json.loads(REISE_ANNOTATIONS_FILE.read_text(encoding='utf-8')) if REISE_ANNOTATIONS_FILE.exists() else {}
                self._json(200, data)
            except Exception as e:
                self._json(500, {'error': str(e)})
            return
        if parsed.path.startswith('/travel_data/'):
            rel = parsed.path[len('/travel_data/'):]
            fp = TRAVEL_DATA_DIR / rel
            if fp.exists() and fp.is_file() and fp.suffix == '.json':
                try:
                    data = fp.read_bytes()
                    self.send_response(200)
                    self.send_header('Content-Type', 'application/json')
                    self.send_header('Content-Length', len(data))
                    self.send_header('Cache-Control', 'max-age=3600')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(data)
                except Exception as e:
                    self._json(500, {'error': str(e)})
            else:
                self._json(404, {'error': 'not found'})
            return
        if parsed.path == '/api/ws/status':
            import socket as _sock
            s = _sock.socket()
            s.settimeout(1)
            try:
                s.connect(('127.0.0.1', 8093))
                s.close()
                self._json(200, {'running': True, 'port': 8093})
            except Exception:
                self._json(200, {'running': False, 'port': 8093})
            return
        if parsed.path == '/api/prefetch_status':
            pending = sum(1 for v in _FETCH_QUEUE.values() if v == 'pending')
            done = sum(1 for v in _FETCH_QUEUE.values() if v == 'done')
            errors = sum(1 for v in _FETCH_QUEUE.values() if v == 'error')
            self._json(200, {'pending': pending, 'done': done, 'errors': errors,
                             'total': len(_FETCH_QUEUE)})
            return
        if parsed.path in ('/api/health', '/api/health/'):
            import time as _time
            self._json(200, {'ok': True, 'ts': int(_time.time()), 'status': 'ok', 'server': 'cockpit-minimal'})
            return
        if parsed.path == '/api/time-camera/health':
            self._json(200, {
                'ok': True,
                'app': 'Time Travel Camera',
                'storage': str(TTC_JOBS_DIR),
                'fotolabor_queue': str(KAMERAMOTOR_MAGNIFIC_JOBS_DIR),
            })
            return
        if parsed.path.startswith('/api/time-camera/jobs/'):
            try:
                tail = parsed.path[len('/api/time-camera/jobs/'):].strip('/')
                parts = tail.split('/') if tail else []
                job_id = _ttc_safe_job_id(parts[0] if parts else '')
                if not job_id:
                    self._json(400, {'ok': False, 'error': 'job_id fehlt'})
                    return
                if len(parts) == 1:
                    self._json(200, _ttc_status_for(job_id))
                    return
                if len(parts) == 2 and parts[1] == 'manifest':
                    self._json(200, _ttc_manifest(job_id))
                    return
                if len(parts) >= 3 and parts[1] == 'assets':
                    name = re.sub(r'[^a-zA-Z0-9._-]+', '_', parts[2])
                    if name == 'manifest.json':
                        payload = json.dumps(_ttc_manifest(job_id), indent=2, ensure_ascii=False).encode('utf-8')
                        self.send_response(200)
                        self.send_header('Content-Type', 'application/json; charset=utf-8')
                        self.send_header('Content-Length', str(len(payload)))
                        self.send_header('Cache-Control', 'no-store')
                        self._cors()
                        self.end_headers()
                        self.wfile.write(payload)
                        return
                    path = _ttc_job_dir(job_id) / 'assets' / name
                    if not path.exists() or not path.is_file():
                        self._json(404, {'ok': False, 'error': 'asset nicht gefunden'})
                        return
                    ctype = 'image/jpeg'
                    if path.suffix.lower() == '.png':
                        ctype = 'image/png'
                    elif path.suffix.lower() == '.webp':
                        ctype = 'image/webp'
                    data = path.read_bytes()
                    self.send_response(200)
                    self.send_header('Content-Type', ctype)
                    self.send_header('Content-Length', str(len(data)))
                    self.send_header('Cache-Control', 'max-age=31536000, immutable' if name.startswith('result_') else 'no-store')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(data)
                    return
                self._json(404, {'ok': False, 'error': 'time-camera route unbekannt'})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/kameramotor/guard':
            try:
                guard = KAMERAMOTOR_DIR / 'provider_access_guard.py'
                r = subprocess.run([sys.executable, str(guard), '--json'],
                                   capture_output=True, text=True, timeout=30)
                data = json.loads(r.stdout or '{}')
                data['returncode'] = r.returncode
                if r.stderr:
                    data['stderr'] = r.stderr[-2000:]
                self._json(200 if data.get('ok') else 409, data)
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/mikrofon-v2/session_load':
            # Lädt Raw + Polished einer bestehenden Session für ?load=<id> im Frontend
            try:
                import re as _re_sl
                qs = parse_qs(parsed.query)
                sid = str(qs.get('id', [''])[0]).strip()
                # Pfad-Traversal-Schutz: nur SR-... Pattern, basename only
                if not _re_sl.match(r'^SR-[A-Za-z0-9T\-]+$', sid):
                    self._json(404, {'ok': False, 'error': 'Ungültige Session-ID'})
                    return
                sid_safe = Path(sid).name  # basename only
                sess_dir = Path(__file__).parent.parent / 'recordings' / 'signalraum' / sid_safe
                if not sess_dir.is_dir():
                    self._json(404, {'ok': False, 'error': f'Session {sid_safe!r} nicht gefunden'})
                    return
                # Raw: transcript_v2.txt bevorzugt, sonst transcript.txt
                raw_text = None
                for fn in ('transcript_v2.txt', 'transcript.txt'):
                    p = sess_dir / fn
                    if p.exists():
                        raw_text = p.read_text(encoding='utf-8')
                        break
                if raw_text is None:
                    self._json(404, {'ok': False, 'error': f'Kein Transkript in {sid_safe!r}'})
                    return
                # Polished: polished_haiku.txt falls vorhanden
                polished_text = None
                for fn in ('polished_haiku.txt', 'polished.txt'):
                    p = sess_dir / fn
                    if p.exists():
                        polished_text = p.read_text(encoding='utf-8')
                        break
                self._json(200, {
                    'ok': True,
                    'id': sid_safe,
                    'raw': raw_text,
                    'polished': polished_text,
                })
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/sancho_panes':
            # Candy-Filter: private Sessions nicht im Cockpit anzeigen
            sanchos = _load_sancho_registry()
            sanchos = [s for s in sanchos
                       if 'candy' not in s.get('name', '').lower()
                       and 'candy' not in s.get('birth_name', '').lower()
                       and 'candy' not in s.get('session', '').lower()]
            import concurrent.futures as _cf
            def check(s):
                s['alive'] = _tmux_session_alive(s['session'])
                return s
            with _cf.ThreadPoolExecutor(max_workers=8) as ex:
                result = list(ex.map(check, sanchos))
            self._json(200, result)
            return
        if parsed.path == '/api/wohnungen/ratings':
            qs = dict(x.split('=') for x in parsed.query.split('&') if '=' in x) if parsed.query else {}
            addr = re.sub(r'[^a-z0-9_]', '_', (qs.get('addr') or 'unknown').lower())
            p = COCKPIT_DIR / f'wohnungen_ratings_{addr}.json'
            self._json(200, json.loads(p.read_text()) if p.exists() else {})
            return
        if parsed.path == '/api/wohnung/load':
            p = COCKPIT_DIR / 'wohnung_data.json'
            self._json(200, json.loads(p.read_text()) if p.exists() else {})
            return
        if parsed.path == '/api/netzwerk/load':
            p = COCKPIT_DIR / 'netzwerk_data.json'
            self._json(200, json.loads(p.read_text()) if p.exists() else {})
            return
        if parsed.path == '/api/quellen_bewertung_load':
            p = COCKPIT_DIR / 'quellen_bewertung_bsl.json'
            self._json(200, json.loads(p.read_text(encoding='utf-8')) if p.exists() else {})
            return
        if parsed.path == '/api/kameramotor/filters':
            try:
                input_dir = Path('/Users/victorholland/Vibe Coding/The Camera/Input')
                filters = []
                for d in sorted(input_dir.iterdir()):
                    if not d.is_dir() or d.name.startswith('_'):
                        continue
                    prompt_file = d / 'Prompt.txt'
                    filters.append({
                        'name': d.name,
                        'path': str(prompt_file) if prompt_file.exists() else '',
                        'has_prompt': prompt_file.exists(),
                    })
                self._json(200, {'ok': True, 'filters': filters})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/kameramotor/status':
            try:
                km_dir = COCKPIT_DIR.parent / 'kameramotor'
                state_dir = km_dir / 'state'
                def _load_state(job_id):
                    sf = state_dir / f'{job_id}.json'
                    if sf.exists():
                        try: return json.loads(sf.read_text())
                        except: pass
                    return {}
                def _list(folder):
                    d = km_dir / folder
                    if not d.exists(): return []
                    jobs = []
                    for f in sorted(d.glob('*.json')):
                        try:
                            job = json.loads(f.read_text())
                            st  = _load_state(f.stem)
                            # Merge relevant state fields (no large blobs)
                            for k in ('submitted_at', 'started_at', 'magnific_submit_at',
                                      'title_at', 'done_at', 'eta_ms', 'phase1_ms',
                                      'original_path', 'original_name', 'output_dir_actual',
                                      'seeds', 'downloaded', 'identifiers',
                                      'ai_name', 'error', 'done'):
                                if k in st: job[k] = st[k]
                            # group_id und image immer im Response mitliefern (auch als null)
                            if 'group_id' not in job: job['group_id'] = None
                            if 'image' not in job: job['image'] = None
                            # output_dir_actual überschreibt output_dir wenn da
                            if st.get('output_dir_actual'):
                                job['output_dir'] = st['output_dir_actual']
                            jobs.append({'id': f.stem, 'folder': folder, **job})
                        except Exception:
                            jobs.append({'id': f.stem, 'folder': folder})
                    return jobs
                def _list_openai(folder):
                    d = km_dir / folder
                    if not d.exists(): return []
                    jobs = []
                    state_dir_oai = km_dir / 'openai_state'
                    for f in sorted(d.glob('*.json')):
                        try:
                            job = json.loads(f.read_text())
                            sf = state_dir_oai / f'{f.stem}.json'
                            st = json.loads(sf.read_text()) if sf.exists() else {}
                            for k in ('submitted_at', 'started_at', 'done_at', 'eta_ms',
                                      'downloaded', 'identifiers', 'error', 'done',
                                      'status', 'note', 'openai_request_id',
                                      'estimate', 'job_preview', 'openai_usage', 'provider',
                                      'openai_model', 'official_api', 'approved_at'):
                                if k in st: job[k] = st[k]
                            if 'group_id' not in job: job['group_id'] = None
                            if 'image' not in job: job['image'] = None
                            jobs.append({'id': f.stem, 'folder': folder, **job})
                        except Exception:
                            jobs.append({'id': f.stem, 'folder': folder})
                    return jobs
                # Separate jobs/ into active (have state with identifiers) vs pending
                all_queued = _list('jobs')
                active, pending = [], []
                for j in all_queued:
                    st = _load_state(j['id'])
                    if st.get('identifiers') and not st.get('done'):
                        j['_active'] = True
                        active.append(j)
                    else:
                        pending.append(j)
                self._json(200, {
                    'ok': True,
                    'active':  active,
                    'pending': pending + _list_openai('openai_jobs'),
                    'done':    _list('done') + _list_openai('openai_done'),
                    'failed':  _list('failed') + _list_openai('openai_failed'),
                })
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/kameramotor/output-thumbnail':
            try:
                from urllib.parse import parse_qs as _pqs
                qp = _pqs(parsed.query)
                out_dir = qp.get('dir', [''])[0]
                home_base = '/Users/victorholland'
                real_dir = str(Path(out_dir).resolve()) if out_dir else ''
                if not out_dir or not real_dir.startswith(home_base) or not Path(out_dir).exists():
                    self._json(404, {'error': 'not found'})
                    return
                img_file = None
                for f in sorted(Path(out_dir).iterdir()):
                    if f.suffix.lower() not in ('.jpg', '.jpeg', '.png'): continue
                    if f.name.startswith('_original'): continue  # Original, nicht Output
                    if f.name.startswith('.'): continue
                    img_file = f  # letztes jpg alphabetisch = generiertes Bild
                if not img_file:
                    self._json(404, {'error': 'no image'})
                    return
                # Thumbnail via sips (max 300px, 75% JPEG)
                import tempfile as _tf2, subprocess as _sp3
                tmp = _tf2.NamedTemporaryFile(suffix='.jpg', delete=False)
                tmp.close()
                _sp3.run(['sips', '-Z', '300', str(img_file),
                          '--setProperty', 'format', 'jpeg',
                          '--setProperty', 'formatOptions', '75',
                          '--out', tmp.name], capture_output=True)
                img_bytes = Path(tmp.name).read_bytes()
                Path(tmp.name).unlink(missing_ok=True)
                self.send_response(200)
                self.send_header('Content-Type', 'image/jpeg')
                self.send_header('Content-Length', str(len(img_bytes)))
                self.send_header('Cache-Control', 'max-age=86400')
                self.end_headers()
                self.wfile.write(img_bytes)
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/kameramotor/settings':
            sf = COCKPIT_DIR.parent / 'kameramotor' / 'settings.json'
            if sf.exists():
                self._json(200, {'ok': True, 'settings': json.loads(sf.read_text(encoding='utf-8'))})
            else:
                self._json(200, {'ok': True, 'settings': None})
            return
        if parsed.path == '/api/kameramotor/thumbnail':
            try:
                from urllib.parse import parse_qs as _pqs
                import subprocess as _sp2, hashlib as _hl, os as _tos
                qp = _pqs(parsed.query)
                img_path = qp.get('path', [''])[0]
                home_base = '/Users/victorholland'
                real = str(Path(img_path).resolve()) if img_path else ''
                if not img_path or not real.startswith(home_base) or not Path(img_path).exists():
                    self._json(404, {'error': 'not found'})
                    return
                # Disk-Cache: Thumbnail wird einmal erzeugt und auf Platte gespeichert.
                # Cache-Key: sha1(path + mtime) — ändert sich nur wenn Datei sich ändert.
                _mtime = str(_tos.path.getmtime(img_path))
                _ckey  = _hl.sha1((img_path + _mtime).encode()).hexdigest()
                _tcdir = COCKPIT_DIR.parent / 'kameramotor' / '_thumbcache'
                _tcdir.mkdir(parents=True, exist_ok=True)
                _tcpath = _tcdir / (_ckey + '.jpg')
                if _tcpath.exists():
                    img_bytes = _tcpath.read_bytes()
                else:
                    ext = img_path.lower().rsplit('.', 1)[-1]
                    import tempfile as _tf3
                    if ext in ('heic', 'heif', 'png'):
                        sz = '300' if ext in ('heic', 'heif') else '900'
                        q  = '75'  if ext in ('heic', 'heif') else '82'
                        tmp3 = _tf3.NamedTemporaryFile(suffix='.jpg', delete=False)
                        tmp3.close()
                        _sp2.run(['sips', '-Z', sz, img_path,
                                  '--setProperty', 'format', 'jpeg',
                                  '--setProperty', 'formatOptions', q,
                                  '--out', tmp3.name], capture_output=True)
                        img_bytes = Path(tmp3.name).read_bytes()
                        Path(tmp3.name).unlink(missing_ok=True)
                    else:
                        img_bytes = Path(img_path).read_bytes()
                    # Auf Platte speichern
                    _tcpath.write_bytes(img_bytes)
                self.send_response(200)
                self.send_header('Content-Type', 'image/jpeg')
                self.send_header('Content-Length', str(len(img_bytes)))
                self.send_header('Cache-Control', 'max-age=604800, immutable')
                self.end_headers()
                self.wfile.write(img_bytes)
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/kameramotor/events':
            # SSE Push-Endpoint — hält Verbindung offen, sendet job-update wenn Queue-Datei befüllt
            import time as _time
            SSE_QUEUE = Path('/Users/victorholland/Vibe Coding/The Camera/.sse_queue')
            try:
                self.send_response(200)
                self.send_header('Content-Type', 'text/event-stream')
                self.send_header('Cache-Control', 'no-cache')
                self.send_header('Connection', 'keep-alive')
                self.send_header('Access-Control-Allow-Origin', '*')
                self.end_headers()
                deadline = _time.time() + 300  # max 5 Minuten
                last_ping = _time.time()
                while _time.time() < deadline:
                    # Queue-Datei lesen und leeren
                    if SSE_QUEUE.exists():
                        try:
                            content = SSE_QUEUE.read_text(encoding='utf-8').strip()
                            if content:
                                SSE_QUEUE.write_text('', encoding='utf-8')
                                self.wfile.write(b'data: {"type":"job-update"}\n\n')
                                self.wfile.flush()
                                last_ping = _time.time()
                        except Exception:
                            pass
                    # Keepalive alle 25s
                    if _time.time() - last_ping >= 25:
                        self.wfile.write(b'data: {"type":"ping"}\n\n')
                        self.wfile.flush()
                        last_ping = _time.time()
                    _time.sleep(0.8)
            except Exception:
                pass
            return
        if parsed.path == '/api/naming/image-full':
            # Bild in voller Auflösung ausgeben (JPEG-Konvertierung für HEIC)
            from urllib.parse import parse_qs as _pqs2
            qp = _pqs2(parsed.query)
            filename = qp.get('file', [''])[0]
            img_dir  = Path('/Users/victorholland/Vibe Coding/The Camera/Testumgebung/Benennungstests')
            img_path = img_dir / filename
            if not filename or not img_path.exists():
                self._json(404, {'error': 'not found'})
                return
            try:
                import subprocess as _if_sp, tempfile as _if_tf
                ext = img_path.suffix.lower()
                if ext in ('.heic', '.heif'):
                    tmp = _if_tf.NamedTemporaryFile(suffix='.jpg', delete=False)
                    tmp.close()
                    _if_sp.run(['sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', '95',
                                str(img_path), '--out', tmp.name], capture_output=True)
                    img_bytes = Path(tmp.name).read_bytes()
                    Path(tmp.name).unlink(missing_ok=True)
                    mime = 'image/jpeg'
                else:
                    img_bytes = img_path.read_bytes()
                    mime = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
                            'png': 'image/png', 'webp': 'image/webp'}.get(ext.lstrip('.'), 'image/jpeg')
                self.send_response(200)
                self.send_header('Content-Type', mime)
                self.send_header('Content-Length', str(len(img_bytes)))
                self.send_header('Cache-Control', 'max-age=3600')
                self.end_headers()
                self.wfile.write(img_bytes)
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/naming/list-images':
            img_dir = Path('/Users/victorholland/Vibe Coding/The Camera/Testumgebung/Benennungstests')
            exts = {'.jpg', '.jpeg', '.png', '.heic', '.heif', '.webp'}
            files = sorted(f.name for f in img_dir.iterdir() if f.suffix.lower() in exts)
            self._json(200, {'ok': True, 'files': files})
            return
        if parsed.path == '/api/naming/vision-key-status':
            key_file = COCKPIT_DIR.parent / 'kameramotor' / 'vision_key.txt'
            log_file = COCKPIT_DIR.parent / 'kameramotor' / 'vision_pass.log'
            has_key = key_file.exists() and key_file.read_text().strip().startswith('AIza')
            last_log = ''
            if log_file.exists():
                lines = log_file.read_text().strip().splitlines()
                last_log = lines[-1] if lines else ''
            self._json(200, {'ok': True, 'has_key': has_key, 'last_log': last_log})
            return
        if parsed.path == '/api/naming/feedback':
            fb_file = COCKPIT_DIR.parent / 'kameramotor' / 'naming_feedback.json'
            if fb_file.exists():
                try:
                    self._json(200, json.loads(fb_file.read_text(encoding='utf-8')))
                except Exception as e:
                    self._json(500, {'ok': False, 'error': str(e)})
            else:
                self._json(200, {})
            return
        if parsed.path == '/api/naming/results':
            results_file = COCKPIT_DIR.parent / 'kameramotor' / 'naming_results.json'
            if results_file.exists():
                try:
                    self._json(200, json.loads(results_file.read_text(encoding='utf-8')))
                except Exception as e:
                    self._json(500, {'ok': False, 'error': str(e)})
            else:
                self._json(200, {'images': [], 'processed': 0, 'total': 0, 'running': True})
            return
        if parsed.path == '/api/reisebericht/queue':
            with RB_QUEUE_LOCK:
                queue_ids = list(RB_QUEUE)
            with RB_LOCK:
                running = [jid for jid, j in RB_JOBS.items() if j.get('status') == 'running']
                done    = [jid for jid, j in RB_JOBS.items() if j.get('status') == 'done']
                errors  = [jid for jid, j in RB_JOBS.items() if j.get('status') == 'error']
            self._json(200, {
                'ok': True,
                'queue': queue_ids,
                'running': running,
                'done': done,
                'errors': errors,
                'total': len(queue_ids) + len(running),
            })
            return

        if parsed.path.startswith('/api/reisebericht/status/'):
            job_id = parsed.path.split('/')[-1]
            with RB_LOCK:
                job = RB_JOBS.get(job_id)
            if not job:
                self._json(404, {'ok': False, 'error': 'Job nicht gefunden'})
            else:
                self._json(200, {'ok': True, **job})
            return

        if parsed.path.startswith('/api/reisebericht/debug_log/'):
            job_id = parsed.path.rstrip('/').split('/')[-1]
            job = RB_JOBS.get(job_id)
            if not job:
                self._json(404, {'error': 'job not found'}); return
            self._json(200, {'ok': True, 'log': job.get('debug_log', []), 'status': job.get('status'), 'error': job.get('error')})
            return

        if parsed.path.startswith('/api/reisebericht/audio/'):
            from urllib.parse import unquote as _uq
            fname = _uq(parsed.path[len('/api/reisebericht/audio/'):].lstrip('/'))
            if '/' in fname or '..' in fname or not fname.endswith('.mp3'):
                self._json(403, {'error': 'forbidden'}); return
            fpath = RB_OUTPUT_DIR / fname
            if not fpath.exists():
                self._json(404, {'error': 'nicht gefunden'}); return
            fsize = fpath.stat().st_size
            # Range-Request-Support für iPhone-Seeking
            range_hdr = self.headers.get('Range', '')
            start, end = 0, fsize - 1
            if range_hdr.startswith('bytes='):
                parts = range_hdr[6:].split('-')
                try:
                    start = int(parts[0]) if parts[0] else 0
                    end   = int(parts[1]) if len(parts) > 1 and parts[1] else fsize - 1
                except ValueError:
                    pass
            length = end - start + 1
            if range_hdr:
                self.send_response(206)
                self.send_header('Content-Range', f'bytes {start}-{end}/{fsize}')
            else:
                self.send_response(200)
            self.send_header('Content-Type', 'audio/mpeg')
            self.send_header('Content-Length', str(length))
            self.send_header('Accept-Ranges', 'bytes')
            self.send_header('Cache-Control', 'no-cache')
            self._cors()
            self.end_headers()
            try:
                with open(fpath, 'rb') as _f:
                    _f.seek(start)
                    remaining = length
                    while remaining > 0:
                        chunk = _f.read(min(65536, remaining))
                        if not chunk: break
                        self.wfile.write(chunk)
                        remaining -= len(chunk)
            except (BrokenPipeError, ConnectionResetError, OSError):
                pass
            return

        if parsed.path.startswith('/reiseberichte/'):
            from urllib.parse import unquote as _uq
            rel = _uq(parsed.path[len('/reiseberichte/'):].lstrip('/'))
            if '..' in rel.split('/') or not rel:
                self._json(403, {'error': 'forbidden'}); return
            fpath = (RB_OUTPUT_DIR / rel).resolve()
            if not str(fpath).startswith(str(RB_OUTPUT_DIR.resolve()) + '/'):
                self._json(403, {'error': 'forbidden'}); return
            if not fpath.exists():
                self._json(404, {'error': 'nicht gefunden'}); return
            suffix = fpath.suffix.lower()
            if suffix == '.html':
                ctype = 'text/html; charset=utf-8'
            elif suffix == '.mp3':
                ctype = 'audio/mpeg'
            elif suffix == '.json':
                ctype = 'application/json; charset=utf-8'
            else:
                self._json(403, {'error': 'forbidden'}); return
            if suffix == '.mp3':
                fsize = fpath.stat().st_size
                range_hdr = self.headers.get('Range')
                start, end = 0, fsize - 1
                if range_hdr and range_hdr.startswith('bytes='):
                    try:
                        spec = range_hdr.split('=', 1)[1].split(',', 1)[0]
                        a, b = spec.split('-', 1)
                        if a:
                            start = int(a)
                        if b:
                            end = int(b)
                    except Exception:
                        start, end = 0, fsize - 1
                start = max(0, min(start, fsize - 1))
                end = max(start, min(end, fsize - 1))
                length = end - start + 1
                if range_hdr:
                    self.send_response(206)
                    self.send_header('Content-Range', f'bytes {start}-{end}/{fsize}')
                else:
                    self.send_response(200)
                self.send_header('Content-Type', ctype)
                self.send_header('Content-Length', str(length))
                self.send_header('Accept-Ranges', 'bytes')
                self.send_header('Cache-Control', 'no-store')
                self._cors()
                self.end_headers()
                try:
                    with open(fpath, 'rb') as _f:
                        _f.seek(start)
                        remaining = length
                        while remaining > 0:
                            chunk = _f.read(min(65536, remaining))
                            if not chunk:
                                break
                            self.wfile.write(chunk)
                            remaining -= len(chunk)
                except (BrokenPipeError, ConnectionResetError, OSError):
                    pass
                return
            data = fpath.read_bytes()
            self.send_response(200)
            self.send_header('Content-Type', ctype)
            self.send_header('Content-Length', str(len(data)))
            self.send_header('Cache-Control', 'no-store')
            self._cors()
            self.end_headers()
            try:
                self.wfile.write(data)
            except (BrokenPipeError, ConnectionResetError, OSError):
                pass
            return

        if parsed.path == '/api/reisebericht/archive':
            try:
                RB_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
                entries = []
                for mp3 in sorted(RB_OUTPUT_DIR.glob('*.mp3'),
                                  key=lambda p: p.stat().st_mtime, reverse=True)[:40]:
                    sidecar = mp3.with_name(mp3.stem + '_quellen.json')
                    entry = {'mp3': mp3.name, 'ts': None, 'name': '', 'address': ''}
                    if sidecar.exists():
                        try:
                            sc = json.loads(sidecar.read_text(encoding='utf-8'))
                            entry['ts']      = sc.get('ts')
                            entry['name']    = sc.get('name', '')
                            entry['address'] = sc.get('address', '')
                        except Exception:
                            pass
                    if not entry['name']:
                        # Derive name from filename: Reisebericht_<name>_...mp3
                        parts = mp3.stem.split('_')
                        entry['name'] = parts[1] if len(parts) > 1 else mp3.stem
                    entries.append(entry)
                self._json(200, {'ok': True, 'entries': entries})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/version/status':
            self._json(200, {'status': 'current'})
            return
        if parsed.path == '/api/stimmen/feedback':
            dest_json = WATSON_VOICES_DIR / 'stimmen_bewertungen.json'
            if dest_json.exists():
                try:
                    self._json(200, json.loads(dest_json.read_text(encoding='utf-8')))
                except Exception:
                    self._json(200, {})
            else:
                self._json(200, {})
            return
        if parsed.path == '/api/stimmen/load':
            # Lädt Stimmen-Auswahl aus stimmen.html (geräteübergreifend)
            choices_file = COCKPIT_DIR.parent / 'stimmen_choices.json'
            try:
                if choices_file.exists():
                    data = json.loads(choices_file.read_text(encoding='utf-8'))
                    self._json(200, {'ok': True, 'choices': data.get('choices', {}),
                                     'confirmed': data.get('confirmed', {}),
                                     '_ts': data.get('_ts', 0)})
                else:
                    self._json(200, {'ok': True, 'choices': {}, 'confirmed': {}, '_ts': 0})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e), 'choices': {}, 'confirmed': {}, '_ts': 0})
            return
        if parsed.path == '/api/costs/summary':
            self._json(200, _costs_summary())
            return
        if parsed.path == '/api/kosten':
            qs = parse_qs(parsed.query)
            range_ = qs.get('range', ['today'])[0]
            if range_ not in ('today', 'month'):
                range_ = 'today'
            try:
                self._json(200, _kosten_data(range_))
            except Exception as e:
                self._json(500, {'error': str(e)})
            return
        if parsed.path in ('/api/feedback/all', '/api/design_feedback/all'):
            entries = _read_jsonl(LEXIKON_DIR / 'design_feedback.jsonl')
            self._json(200, {'entries': entries})
            return
        if parsed.path == '/api/design_eval/all':
            entries = _read_jsonl(LEXIKON_DIR / 'design_eval.jsonl')
            self._json(200, {'entries': entries})
            return
        if parsed.path == '/api/design_standard/all':
            entries = _read_jsonl(LEXIKON_DIR / 'design_standards.jsonl')
            self._json(200, {'entries': entries})
            return
        if parsed.path == '/api/page_version':
            qs = parse_qs(parsed.query)
            page = qs.get('page', [''])[0]
            try:
                import json as _pvj
                _pvfile = COCKPIT_DIR.parent / 'version_bumps.json'
                _pvstore = _pvj.loads(_pvfile.read_text()) if _pvfile.exists() else {}
                entry = _pvstore.get(page, {})
                self._json(200, {'page': page, 'version': entry.get('version'), 'change': entry.get('change', '')})
            except Exception:
                self._json(200, {'page': page, 'version': None})
            return
        if parsed.path.startswith('/api/inbox/'):
            role = parsed.path[len('/api/inbox/'):].strip('/')
            if not role or not all(c.isalnum() or c in '-_' for c in role):
                self._json(400, {'ok': False, 'error': 'invalid role'})
                return
            inbox_file = INBOX_DIR / f'{role}.md'
            content = inbox_file.read_text(encoding='utf-8') if inbox_file.exists() else ''
            self._json(200, {'role': role, 'content': content})
            return
        if parsed.path == '/api/zotify/status':
            qs = parse_qs(parsed.query)
            job_id = qs.get('job', [''])[0]
            if job_id and job_id in ZOTIFY_JOBS:
                self._json(200, ZOTIFY_JOBS[job_id])
            else:
                votify_cookies = Path.home() / 'Library/Application Support/Votify/cookies.txt'
                self._json(200, {
                    'votify_ready': votify_cookies.exists(),
                    'jobs': {k: {'status': v['status'], 'url': v.get('url', '')} for k, v in ZOTIFY_JOBS.items()}
                })
            return
        # ── Song-Erkennung GET-Endpoints (M-029) ─────────────────────
        if parsed.path == '/presence/melanie':
            # Proxy to WhatsApp server on port 8092 — keeps home.html on same origin
            try:
                import urllib.request as _ur
                with _ur.urlopen('http://127.0.0.1:8092/presence/melanie', timeout=2) as r:
                    data = json.loads(r.read())
                self._json(200, data)
            except Exception:
                self._json(200, {'status': 'offline', 'last_seen': None, 'updated_at': None, 'wa_status': 'disconnected'})
            return
        if parsed.path == '/api/song/status':
            if not _SONG_MOD_OK:
                self._json(500, {'ok': False, 'error': 'Modul nicht geladen'})
                return
            status = _song_mod.get_status()
            status['spotify_connected'] = _song_mod.spotify_is_connected()
            self._json(200, status)
            return
        if parsed.path == '/api/spotify/status':
            if not _SONG_MOD_OK:
                self._json(500, {'ok': False, 'error': 'Modul nicht geladen'})
                return
            try:
                connected = _song_mod.spotify_is_connected()
                self._json(200, {'ok': True, 'connected': connected})
            except Exception as e:
                self._json(200, {'ok': False, 'connected': False, 'error': str(e)})
            return
        if parsed.path == '/api/spotify/auth_url':
            if not _SONG_MOD_OK:
                self._json(500, {'ok': False, 'error': 'Modul nicht geladen'})
                return
            try:
                url = _song_mod.spotify_auth_url()
                self._json(200, {'ok': True, 'url': url})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/spotify/song_auth_url':
            if not _SONG_MOD_OK:
                self._json(500, {'ok': False, 'error': 'Modul nicht geladen'})
                return
            try:
                url = _song_mod.spotify_auth_url()
                self._json(200, {'ok': True, 'url': url})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/spotify/callback' or parsed.path == '/api/spotify/song_callback':
            # OAuth-Callback: tauscht Code gegen Token
            qs = parse_qs(parsed.query)
            code = qs.get('code', [''])[0]
            if code and _SONG_MOD_OK:
                ok = _song_mod.spotify_handle_callback(code)
                # Redirect zurück zur Song-Erkennungs-Seite
                self.send_response(302)
                loc = '/song_erkennung.html?spotify_ok=1' if ok else '/song_erkennung.html?spotify_err=1'
                self.send_header('Location', loc)
                self._cors()
                self.end_headers()
            else:
                self.send_response(302)
                self.send_header('Location', '/song_erkennung.html?spotify_err=1')
                self._cors()
                self.end_headers()
            return
        if parsed.path == '/api/talk/ts':
            ts_file = COCKPIT_DIR / 'audio' / 'watson_ts.txt'
            if ts_file.exists():
                try:
                    ts = int(ts_file.read_text().strip())
                    self._json(200, {'ts': ts, 'available': True})
                except Exception:
                    self._json(200, {'ts': 0, 'available': False})
            else:
                self._json(200, {'ts': 0, 'available': False})
            return
        if parsed.path == '/api/watson/status':
            import subprocess as _sp, os as _os
            candidates_file = WATSON_VOICES_DIR / 'candidates.json'
            try:
                with open(candidates_file) as f:
                    candidates = json.load(f)
                total = len(candidates)
                coverage = []
                short_files = []
                missing = []
                for c in candidates:
                    name = c['name']
                    src = WATSON_VOICES_DIR / name / 'source.mp3'
                    if src.exists():
                        size_bytes = src.stat().st_size
                        size_mb = round(size_bytes / 1024 / 1024, 1)
                        coverage.append({'name': name, 'label': c.get('label', name), 'size_mb': size_mb})
                        if size_bytes < 3 * 1024 * 1024 and name != 'victor_holland':
                            short_files.append({'name': name, 'label': c.get('label', name), 'size_mb': size_mb})
                    elif name != 'victor_holland':
                        missing.append({'name': name, 'label': c.get('label', name)})
                # Check HF token
                hf_check = _sp.run(['security', 'find-generic-password', '-s', 'huggingface', '-w'],
                                    capture_output=True, text=True)
                hf_token_set = hf_check.returncode == 0 and bool(hf_check.stdout.strip())
                # Check Watson IVC
                voice_id_file = WATSON_VOICES_DIR / 'friedrich_bauschulte' / 'voice_id.txt'
                watson_voice_id = voice_id_file.read_text().strip() if voice_id_file.exists() else None
                self._json(200, {
                    'total': total,
                    'with_source': len(coverage),
                    'missing': missing,
                    'short_files': short_files,
                    'hf_token_set': hf_token_set,
                    'watson_voice_id': watson_voice_id,
                })
            except Exception as e:
                self._json(500, {'error': str(e)})
            return
# INSTRUMENT-AUSNAHME: GET-Endpoints für ConvAI agent_config und signed_url Proxy, kein TTS, Victor-Go erteilt im Auftragstext
        if parsed.path == '/api/convai/agent_config':
            # Liest gespeicherte agent_id für Watson ConvAI
            config_file = COCKPIT_DIR / 'elevenlabs_agent_config.json'
            if config_file.exists():
                try:
                    cfg = json.loads(config_file.read_text())
                    self._json(200, {'ok': True, 'agent_id': cfg.get('agent_id', '')})
                except Exception:
                    self._json(200, {'ok': False, 'agent_id': ''})
            else:
                self._json(200, {'ok': False, 'agent_id': ''})
            return
        if parsed.path == '/api/convai/signed_url':
            # Holt signed URL von ElevenLabs (server-side proxy — key bleibt sicher)
            qs = parse_qs(parsed.query)
            agent_id = (qs.get('agent_id', ['']) or [''])[0].strip()
            if not agent_id:
                self._json(400, {'ok': False, 'error': 'agent_id fehlt'})
                return
            try:
                import subprocess as _sp2, urllib.request as _ur2
                key_result = _sp2.run(
                    ['security', 'find-generic-password', '-s', '11labs', '-w'],
                    capture_output=True, text=True
                )
                el_key = key_result.stdout.strip() if key_result.returncode == 0 else ''
                if not el_key:
                    for line in CREDENTIALS_FILE.read_text(encoding='utf-8').splitlines():
                        if line.strip().startswith('ELEVENLABS_API_KEY='):
                            el_key = line.split('=', 1)[1].strip()
                            break
                if not el_key:
                    self._json(500, {'ok': False, 'error': 'Kein ElevenLabs API Key'})
                    return
                req2 = _ur2.Request(
                    f'https://api.elevenlabs.io/v1/convai/conversation/get_signed_url?agent_id={agent_id}',
                    headers={'xi-api-key': el_key}
                )
                with _ur2.urlopen(req2, timeout=10) as resp2:
                    result2 = json.loads(resp2.read())
                signed_url = result2.get('signed_url', '')
                if signed_url:
                    self._json(200, {'ok': True, 'signed_url': signed_url})
                else:
                    self._json(500, {'ok': False, 'error': 'Keine signed_url', 'raw': str(result2)})
            except Exception as e3:
                self._json(500, {'ok': False, 'error': str(e3)})
            return
# INSTRUMENT-AUSNAHME: GET-Endpoints für ElevenLabs Key + Shared Voices Proxy — Stimmen-Testseite — Victor-Go erteilt im Auftragstext 2026-06-06
        if parsed.path == '/api/elevenlabs/key':
            # Liefert den ElevenLabs Key aus dem Keychain
            import subprocess as _sp_el
            try:
                el_key = _sp_el.check_output(
                    ['security', 'find-generic-password', '-s', '11labs', '-w'],
                    text=True, stderr=_sp_el.DEVNULL
                ).strip()
            except Exception:
                el_key = ''
            # Fallback: credentials.env
            if not el_key:
                try:
                    for _ln in CREDENTIALS_FILE.read_text().splitlines():
                        if _ln.strip().startswith('ELEVENLABS_API_KEY='):
                            el_key = _ln.split('=', 1)[1].strip()
                except Exception:
                    pass
            resp = json.dumps({'ok': bool(el_key), 'key': el_key if el_key else ''}).encode()
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self._cors()
            self.end_headers()
            self.wfile.write(resp)
            return
        if parsed.path.startswith('/api/elevenlabs/voices'):
            # Proxy: ElevenLabs Shared Voices Liste
            import subprocess as _sp_el2, urllib.request as _ur_el2, urllib.parse as _up_el2
            try:
                el_key2 = _sp_el2.check_output(
                    ['security', 'find-generic-password', '-s', '11labs', '-w'],
                    text=True, stderr=_sp_el2.DEVNULL
                ).strip()
            except Exception:
                el_key2 = ''
            _qp = parse_qs(parsed.query)
            _search = _qp.get('search', [''])[0]
            _gender = _qp.get('gender', ['female'])[0]
            _page_size = _qp.get('page_size', ['100'])[0]
            try:
                _el_voices_url = f'https://api.elevenlabs.io/v1/shared-voices?page_size={_page_size}&gender={_gender}'
                if _search:
                    _el_voices_url += f'&search={_up_el2.quote(_search)}'
                _vreq = _ur_el2.Request(_el_voices_url, headers={'xi-api-key': el_key2})
                with _ur_el2.urlopen(_vreq, timeout=15) as _vresp:
                    _vdata = _vresp.read()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self._cors()
                self.end_headers()
                self.wfile.write(_vdata)
            except Exception as _ve:
                err = json.dumps({'error': str(_ve)}).encode()
                self.send_response(500)
                self.send_header('Content-Type', 'application/json')
                self._cors()
                self.end_headers()
                self.wfile.write(err)
            return
        if parsed.path.startswith('/watson-audio/'):
            rel = parsed.path[len('/watson-audio/'):]
            audio_path = WATSON_VOICES_DIR / rel
            if audio_path.exists() and audio_path.suffix == '.mp3':
                data = audio_path.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'audio/mpeg')
                self.send_header('Content-Length', len(data))
                self._cors()
                self.end_headers()
                self.wfile.write(data)
            else:
                self._json(404, {'error': 'not found'})
            return
        # ── Rode-Clip Tags (GET) ──────────────────────────────────────
        if parsed.path == '/api/rode_clip_tags':
            tags_file = WATSON_VOICES_DIR / 'rode_clips' / 'tags.json'
            if tags_file.exists():
                try:
                    self._json(200, {'tags': json.loads(tags_file.read_text(encoding='utf-8'))})
                except Exception:
                    self._json(200, {'tags': {}})
            else:
                self._json(200, {'tags': {}})
            return
        if parsed.path == '/api/rode_emotion_tags':
            ef = WATSON_VOICES_DIR / 'rode_clips' / 'emotion_tags.json'
            etags_raw = json.loads(ef.read_text('utf-8')) if ef.exists() else {}
            # Migrate string → array
            etags = {}
            for k, v in etags_raw.items():
                etags[k] = [v] if isinstance(v, str) and v else (v if isinstance(v, list) else [])
            self._json(200, {'emotions': etags})
            return
        if parsed.path == '/api/rode_transcripts':
            tf = WATSON_VOICES_DIR / 'rode_clips' / 'transcripts.json'
            data = json.loads(tf.read_text('utf-8')) if tf.exists() else {}
            self._json(200, {'transcripts': data})
            return
        if parsed.path == '/api/rode_not_clean_tags':
            nf = WATSON_VOICES_DIR / 'rode_clips' / 'not_clean_tags.json'
            data = json.loads(nf.read_text('utf-8')) if nf.exists() else {}
            self._json(200, {'tags': data})
            return
        # ── Rode-Clip Index (GET) ─────────────────────────────────────
        if parsed.path == '/api/rode_clips/index':
            index_file = WATSON_VOICES_DIR / 'rode_clips' / 'index.json'
            if index_file.exists():
                try:
                    self._json(200, {'clips': json.loads(index_file.read_text(encoding='utf-8'))})
                except Exception:
                    self._json(500, {'error': 'index.json nicht lesbar'})
            else:
                self._json(404, {'error': 'index.json nicht vorhanden'})
            return
        # ── Holmes-Clip Index (GET) ───────────────────────────────────────
        if parsed.path == '/api/holmes_index':
            index_file = WATSON_VOICES_DIR / 'holmes_clips' / 'index.json'
            if index_file.exists():
                try: self._json(200, {'clips': json.loads(index_file.read_text(encoding='utf-8'))})
                except Exception: self._json(500, {'error': 'index.json nicht lesbar'})
            else:
                self._json(404, {'error': 'index.json nicht vorhanden'})
            return
        if parsed.path == '/api/holmes_tags':
            tf = WATSON_VOICES_DIR / 'holmes_clips' / 'tags.json'
            data = json.loads(tf.read_text('utf-8')) if tf.exists() else {}
            self._json(200, {'tags': data})
            return
        if parsed.path == '/api/holmes_emotion_tags':
            ef = WATSON_VOICES_DIR / 'holmes_clips' / 'emotion_tags.json'
            etags_raw = json.loads(ef.read_text('utf-8')) if ef.exists() else {}
            etags = {}
            for k, v in etags_raw.items():
                etags[k] = [v] if isinstance(v, str) and v else (v if isinstance(v, list) else [])
            self._json(200, {'emotions': etags})
            return
        if parsed.path == '/api/holmes_not_clean_tags':
            nf = WATSON_VOICES_DIR / 'holmes_clips' / 'not_clean_tags.json'
            data = json.loads(nf.read_text('utf-8')) if nf.exists() else {}
            self._json(200, {'tags': data})
            return
        # ── Brückner-Clip Tags (GET) ─────────────────────────────────────
        if parsed.path == '/api/brueckner_clip_tags':
            tags_file = WATSON_VOICES_DIR / 'brueckner_clips' / 'tags.json'
            tags_data = {}
            if tags_file.exists():
                try: tags_data = json.loads(tags_file.read_text(encoding='utf-8'))
                except Exception: pass
            self._json(200, {'tags': tags_data})
            return
        # ── Brückner-Clip Index (GET) ────────────────────────────────────
        if parsed.path == '/api/brueckner_clips/index':
            index_file = WATSON_VOICES_DIR / 'brueckner_clips' / 'index.json'
            if index_file.exists():
                try: self._json(200, {'clips': json.loads(index_file.read_text(encoding='utf-8'))})
                except Exception: self._json(500, {'error': 'index.json nicht lesbar'})
            else:
                self._json(404, {'error': 'index.json nicht vorhanden'})
            return
        # ── Brückner-GF-Clip Tags (GET) ──────────────────────────────────────
        if parsed.path == '/api/brueckner_gf_clip_tags':
            tags_file = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'tags.json'
            tags_data = {}
            if tags_file.exists():
                try: tags_data = json.loads(tags_file.read_text(encoding='utf-8'))
                except Exception: pass
            self._json(200, {'tags': tags_data})
            return
        # ── Brückner-GF-Clip Index (GET) ─────────────────────────────────────
        if parsed.path == '/api/brueckner_gf_clips/index':
            index_file = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'index.json'
            if index_file.exists():
                try:
                    raw = json.loads(index_file.read_text(encoding='utf-8'))
                    # index.json hat {"clips": [...]} Format direkt
                    self._json(200, raw if isinstance(raw, dict) else {'clips': raw})
                except Exception: self._json(500, {'error': 'index.json nicht lesbar'})
            else:
                self._json(404, {'error': 'index.json nicht vorhanden'})
            return
        # ── Brückner-GF Emotion-Tags (GET) ───────────────────────────────────
        if parsed.path == '/api/brueckner_gf_emotion_tags':
            ef = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'emotion_tags.json'
            etags_raw = json.loads(ef.read_text('utf-8')) if ef.exists() else {}
            etags = {}
            for k, v in etags_raw.items():
                etags[k] = [v] if isinstance(v, str) and v else (v if isinstance(v, list) else [])
            self._json(200, {'emotions': etags})
            return
        # ── Brückner-GF Not-Clean-Tags (GET) ─────────────────────────────────
        if parsed.path == '/api/brueckner_gf_not_clean_tags':
            nf = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'not_clean_tags.json'
            data = json.loads(nf.read_text('utf-8')) if nf.exists() else {}
            self._json(200, {'tags': data})
            return
        # ── Brückner-GF Transcripts (GET) ────────────────────────────────────
        if parsed.path == '/api/brueckner_gf_transcripts':
            tf = WATSON_VOICES_DIR / 'brueckner_gf_clips' / 'transcripts.json'
            data = json.loads(tf.read_text('utf-8')) if tf.exists() else {}
            self._json(200, {'transcripts': data})
            return
        # ── Elsholtz-Clip Tags (GET) ──────────────────────────────────────
        if parsed.path == '/api/elsholtz_clip_tags':
            tags_file = WATSON_VOICES_DIR / 'elsholtz_clips' / 'tags.json'
            tags_data = {}
            if tags_file.exists():
                try: tags_data = json.loads(tags_file.read_text(encoding='utf-8'))
                except Exception: pass
            self._json(200, {'tags': tags_data})
            return
        # ── Elsholtz-Clip Index (GET) ─────────────────────────────────────
        if parsed.path == '/api/elsholtz_clips/index':
            index_file = WATSON_VOICES_DIR / 'elsholtz_clips' / 'index.json'
            if index_file.exists():
                try: self._json(200, {'clips': json.loads(index_file.read_text(encoding='utf-8'))})
                except Exception: self._json(500, {'error': 'index.json nicht lesbar'})
            else:
                self._json(404, {'error': 'index.json nicht vorhanden'})
            return
        # ── Candy output ──────────────────────────────────────────────
        if parsed.path == '/api/candy/output':
            if not _tmux_session_alive('candy'):
                self._json(200, {'lines': ''})
                return
            try:
                result = subprocess.run(
                    _tmux_cmd() + ['capture-pane', '-t', 'candy', '-p', '-S', '-80'],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.DEVNULL,
                    timeout=5
                )
                lines = result.stdout.decode(errors='replace') if result.returncode == 0 else ''
                self._json(200, {'lines': lines})
            except Exception as e:
                self._json(200, {'lines': ''})
            return
        if parsed.path == '/api/pi/wifi/keychain_status':
            try:
                kc = subprocess.run(
                    ['security', 'find-generic-password', '-s', 'Victors WLAN'],
                    capture_output=True, text=True)
                if kc.returncode == 0:
                    import re as _re3
                    m = _re3.search(r'"acct"[^<\n]*?<blob>="(.+?)"', kc.stdout)
                    if not m:
                        m = _re3.search(r'"acct".*?"(.+?)"', kc.stdout)
                    ssid = m.group(1) if m else '(unbekannt)'
                    self._json(200, {'found': True, 'ssid': ssid})
                else:
                    self._json(200, {'found': False, 'ssid': None})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return
        if parsed.path == '/api/deepgram/status':
            try:
                dg_key = _load_deepgram_key()
                has_key = bool(dg_key)
                self._json(200, {'ok': True, 'has_key': has_key, 'key_set': has_key})
            except Exception:
                self._json(200, {'ok': True, 'has_key': False, 'key_set': False})
            return
        if parsed.path.startswith('/api/anruf/status/'):
            anruf_id = parsed.path[len('/api/anruf/status/'):].strip('/')
            # Säubern — id-Format ist 'YYYYMMDD-HHMMSS-xxxx'
            import re as _re_a
            if not _re_a.match(r'^[0-9]{8}-[0-9]{6}-[a-z0-9]{4,8}$', anruf_id):
                self._json(400, {'ok': False, 'error': 'invalid id'})
                return
            cur = ANRUF_STATUS.get(anruf_id)
            if cur is None:
                # Versuche von Disk zu laden
                disk = _load_anruf_from_disk(anruf_id)
                if disk:
                    status = disk.get('status', 'done')
                    self._json(200, {
                        'status': status,
                        'result': disk if status == 'done' else None,
                        'error': disk.get('error'),
                    })
                else:
                    self._json(404, {'status': 'unknown', 'error': 'Anruf nicht gefunden'})
                return
            self._json(200, {
                'status': cur.get('status', 'processing'),
                'result': cur.get('result'),
                'error': cur.get('error'),
            })
            return
        if parsed.path == '/api/anruf/list':
            # Alle Anrufe von Disk auflisten, neueste zuerst
            items = []
            try:
                for d in ANRUFE_DIR.iterdir():
                    if not d.is_dir():
                        continue
                    j = d / f'{d.name}.json'
                    if j.exists():
                        try:
                            data = json.loads(j.read_text(encoding='utf-8'))
                            items.append({
                                'id': data.get('id', d.name),
                                'ts': data.get('ts'),
                                'duration_s': data.get('duration_s'),
                                'status': data.get('status'),
                                'summary': (data.get('summary') or '')[:160],
                            })
                        except Exception:
                            items.append({'id': d.name, 'status': 'corrupt'})
                    else:
                        # noch in Verarbeitung
                        cur = ANRUF_STATUS.get(d.name, {})
                        items.append({
                            'id': d.name,
                            'status': cur.get('status', 'unknown'),
                            'ts': cur.get('ts'),
                        })
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
                return
            items.sort(key=lambda x: x.get('ts') or x.get('id') or '', reverse=True)
            self._json(200, {'ok': True, 'items': items})
            return
        if parsed.path.startswith('/api/anruf/audio/'):
            anruf_id = parsed.path[len('/api/anruf/audio/'):].strip('/')
            import re as _re_aa
            if not _re_aa.match(r'^[0-9]{8}-[0-9]{6}-[a-z0-9]{4,8}$', anruf_id):
                self._json(400, {'ok': False, 'error': 'invalid id'})
                return
            anruf_dir = ANRUFE_DIR / anruf_id
            if not anruf_dir.exists():
                self._json(404, {'error': 'not found'})
                return
            audio_file = None
            for ext in ('webm', 'mp4', 'm4a', 'ogg'):
                p = anruf_dir / f'{anruf_id}.{ext}'
                if p.exists():
                    audio_file = p
                    break
            if not audio_file:
                self._json(404, {'error': 'no audio'})
                return
            data = audio_file.read_bytes()
            mime = {
                'webm': 'audio/webm',
                'mp4': 'audio/mp4',
                'm4a': 'audio/mp4',
                'ogg': 'audio/ogg',
            }.get(audio_file.suffix.lstrip('.'), 'application/octet-stream')
            self.send_response(200)
            self.send_header('Content-Type', mime)
            self.send_header('Content-Length', len(data))
            self.send_header('Accept-Ranges', 'bytes')
            self._cors()
            self.end_headers()
            self.wfile.write(data)
            return
        if parsed.path == '/api/jobs/queue':
            try:
                items = []
                if JOBS_QUEUE_FILE.exists():
                    for line in JOBS_QUEUE_FILE.read_text(encoding='utf-8').splitlines():
                        line = line.strip()
                        if line:
                            try:
                                items.append(json.loads(line))
                            except Exception:
                                pass
                items.reverse()  # neueste zuerst
                self._json(200, {'ok': True, 'items': items})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})
            return
        if parsed.path == '/api/voice_library/index':
            try:
                voices = []
                import re as _re2
                excluded = {'raw_sources', '__pycache__', 'brueckner_clips', 'elsholtz_clips',
                            'rode_clips', 'stewart_clips', 'originale_clips', 'alte_stimmen'}
                for entry in sorted(WATSON_VOICES_DIR.iterdir()):
                    if not entry.is_dir():
                        continue
                    name = entry.name
                    if name in excluded or name.endswith('_clips') or name.startswith('_') or name.startswith('.'):
                        continue
                    if not _re2.match(r'^[a-z0-9_]+$', name):
                        continue
                    has_source = (entry / 'source.mp3').exists()
                    if not has_source:
                        continue
                    # Clip count from <voice>_clips/index.json (top-level) OR voice/<voice>_clips/index.json
                    clip_count = 0
                    clips_dir = WATSON_VOICES_DIR / f'{name}_clips'
                    if clips_dir.exists():
                        idx_file = clips_dir / 'index.json'
                        if idx_file.exists():
                            try:
                                idx_data = json.loads(idx_file.read_text(encoding='utf-8'))
                                clip_count = len(idx_data) if isinstance(idx_data, list) else 0
                            except Exception:
                                clip_count = 0
                    # has_tests: tests/ subdir with at least one mp3
                    tests_dir = entry / 'tests'
                    has_tests = False
                    if tests_dir.exists() and tests_dir.is_dir():
                        has_tests = any(f.suffix == '.mp3' for f in tests_dir.iterdir())
                    # first test file name (for audio preview)
                    first_test = None
                    if has_tests:
                        mp3s = sorted(f.name for f in tests_dir.iterdir() if f.suffix == '.mp3')
                        first_test = mp3s[0] if mp3s else None
                    voices.append({
                        'name': name,
                        'has_source': has_source,
                        'clip_count': clip_count,
                        'has_tests': has_tests,
                        'first_test': first_test,
                        'has_clone': (entry / 'voice_id.txt').exists(),
                    })
                self._json(200, {'voices': voices})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)[:300]})
            return
        if parsed.path.startswith('/holmes_clips/'):
            import re as _re
            filename = parsed.path.split('/')[-1]
            if _re.match(r'^[a-z0-9_]+\.mp3$', filename):
                fpath = WATSON_VOICES_DIR / 'holmes_clips' / filename
                if fpath.exists():
                    data = fpath.read_bytes()
                    self.send_response(200)
                    self.send_header('Content-Type', 'audio/mpeg')
                    self.send_header('Content-Length', len(data))
                    self.send_header('Accept-Ranges', 'bytes')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(data)
                    return
            self.send_response(404)
            self.end_headers()
            return
        if parsed.path.startswith('/voices_dialog/'):
            import re as _re
            filename = parsed.path.split('/')[-1]
            if _re.match(r'^[a-z0-9_]+\.mp3$', filename):
                fpath = WATSON_VOICES_DIR / 'holmes_watson_dialog' / filename
                if fpath.exists():
                    data = fpath.read_bytes()
                    self.send_response(200)
                    self.send_header('Content-Type', 'audio/mpeg')
                    self.send_header('Content-Length', len(data))
                    self.send_header('Accept-Ranges', 'bytes')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(data)
                    return
        if parsed.path.startswith('/voices/'):
            parts = parsed.path.strip('/').split('/')
            import re as _re
            # /voices/<name>/source.mp3  or  /voices/<name>/test.mp3
            if len(parts) == 3 and parts[2] in ('source.mp3', 'test.mp3'):
                voice_name = parts[1]
                if _re.match(r'^[a-z0-9_]+$', voice_name):
                    fpath = WATSON_VOICES_DIR / voice_name / parts[2]
                    if fpath.exists():
                        data = fpath.read_bytes()
                        self.send_response(200)
                        self.send_header('Content-Type', 'audio/mpeg')
                        self.send_header('Content-Length', len(data))
                        self.send_header('Accept-Ranges', 'bytes')
                        self._cors()
                        self.end_headers()
                        self.wfile.write(data)
                        return
            # /voices/<name>/tests/<filename>.mp3
            elif len(parts) == 4 and parts[2] == 'tests' and parts[3].endswith('.mp3'):
                voice_name = parts[1]
                filename = parts[3]
                if _re.match(r'^[a-z0-9_]+$', voice_name) and _re.match(r'^[a-z0-9_]+\.mp3$', filename):
                    fpath = WATSON_VOICES_DIR / voice_name / 'tests' / filename
                    if fpath.exists():
                        data = fpath.read_bytes()
                        self.send_response(200)
                        self.send_header('Content-Type', 'audio/mpeg')
                        self.send_header('Content-Length', len(data))
                        self.send_header('Accept-Ranges', 'bytes')
                        self._cors()
                        self.end_headers()
                        self.wfile.write(data)
                        return
            # /voices/originale_clips/<filename>.mp3
            elif len(parts) == 3 and parts[1] == 'originale_clips' and parts[2].endswith('.mp3'):
                filename = parts[2]
                if _re.match(r'^[a-z0-9_]+\.mp3$', filename):
                    fpath = WATSON_VOICES_DIR / 'originale_clips' / filename
                    if fpath.exists():
                        data = fpath.read_bytes()
                        self.send_response(200)
                        self.send_header('Content-Type', 'audio/mpeg')
                        self.send_header('Content-Length', len(data))
                        self.send_header('Accept-Ranges', 'bytes')
                        self._cors()
                        self.end_headers()
                        self.wfile.write(data)
                        return
            # /voices/rode_clips/<filename>.mp3  OR  brueckner_clips  OR  elsholtz_clips  OR  stewart_clips
            elif len(parts) == 3 and parts[1].endswith('_clips') and parts[2].endswith('.mp3'):
                clip_dir = parts[1]
                filename = parts[2]
                if _re.match(r'^[a-z0-9_]+$', clip_dir) and _re.match(r'^[a-z0-9_]+\.mp3$', filename):
                    fpath = WATSON_VOICES_DIR / clip_dir / filename
                    if fpath.exists():
                        data = fpath.read_bytes()
                        self.send_response(200)
                        self.send_header('Content-Type', 'audio/mpeg')
                        self.send_header('Content-Length', len(data))
                        self.send_header('Accept-Ranges', 'bytes')
                        self._cors()
                        self.end_headers()
                        self.wfile.write(data)
                        return
            self.send_response(404)
            self.end_headers()
            return
        # ── Reise-Beweise: Foto-Thumbnails aus Apple Photos ───────────────
        if parsed.path == '/api/evidence/photos':
            import sqlite3 as _sqlite3, datetime as _dt, os as _os
            qs = parse_qs(parsed.query)
            try:
                lat    = float(qs.get('lat', ['0'])[0])
                lon    = float(qs.get('lon', ['0'])[0])
                start  = qs.get('start', [''])[0]
                end    = qs.get('end', [start])[0]
                limit  = min(int(qs.get('limit', ['16'])[0]), 40)
                radius = float(qs.get('radius', ['3.5'])[0])
            except Exception:
                self._json(400, {'error': 'bad params'}); return
            if not start:
                self._json(400, {'error': 'start required'}); return
            _epoch2001 = _dt.datetime(2001, 1, 1)
            def _to_ts(d):
                if len(d) == 7: d = d + '-01'
                return (_dt.datetime.fromisoformat(d) - _epoch2001).total_seconds()
            try:
                ts_start = _to_ts(start)
                ts_end   = _to_ts(end) + 86400 if end else ts_start + 86400
            except Exception:
                self._json(400, {'error': 'bad date'}); return
            photos_db  = _os.path.expanduser(
                '~/Pictures/Fotos-Mediathek.photoslibrary/database/Photos.sqlite')
            deriv_base = _os.path.expanduser(
                '~/Pictures/Fotos-Mediathek.photoslibrary/resources/derivatives')

            def _thumb(uuid):
                """Return (key, url) if a local thumbnail exists (cache or derivatives), else None."""
                # 1. Check persistent iCloud cache first
                cached = PHOTO_CACHE_DIR / f'{uuid}.jpg'
                if cached.exists(): return uuid + '__cache', f'/api/photo_thumb/{uuid}__cache'
                # 2. Fall back to Photos derivatives
                first = uuid[0] if uuid else '0'
                p105 = _os.path.join(deriv_base, first, uuid + '_1_105_c.jpeg')
                pthm = _os.path.join(deriv_base, first, uuid + '.THM')
                if _os.path.exists(p105): return uuid, f'/api/photo_thumb/{uuid}'
                if _os.path.exists(pthm): return uuid + '__thm', f'/api/photo_thumb/{uuid}__thm'
                return None

            def _temporal_buckets(candidates, n):
                """
                Divide candidates into n equal time buckets.
                Returns list of buckets (each is a list of candidates),
                with favorites sorted to the front within each bucket.
                """
                if not candidates: return []
                ts_all = [c[1] for c in candidates]
                t0, t1 = min(ts_all), max(ts_all)
                span = t1 - t0
                buckets = [[] for _ in range(n)]
                for c in candidates:
                    idx = min(int((c[1] - t0) / span * n), n - 1) if span > 0 else 0
                    buckets[idx].append(c)
                # Sort each bucket: favorites first, then by date
                for b in buckets:
                    b.sort(key=lambda c: (0 if c[4] else 1, c[1]))
                return buckets

            results = []
            total_db = 0
            gps_fallback = False
            try:
                db = _sqlite3.connect(f'file:{photos_db}?mode=ro', uri=True)
                HOME_EXCL = 'NOT (ZLATITUDE BETWEEN 52.3 AND 52.7 AND ZLONGITUDE BETWEEN 13.0 AND 13.7)'

                if lat == 0 and lon == 0:
                    # No GPS filter — date range only (cap at 3000 to avoid huge Berlin scans)
                    total_db = db.execute(
                        'SELECT COUNT(*) FROM ZASSET WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0',
                        (ts_start, ts_end)).fetchone()[0]
                    cands_raw = db.execute(
                        '''SELECT ZUUID, ZDATECREATED, ZLATITUDE, ZLONGITUDE, ZFAVORITE
                           FROM ZASSET
                           WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0
                           ORDER BY ZDATECREATED
                           LIMIT 3000''',
                        (ts_start, ts_end)).fetchall()
                else:
                    total_db = db.execute(
                        '''SELECT COUNT(*) FROM ZASSET
                           WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0
                             AND ZLATITUDE BETWEEN ? AND ? AND ZLONGITUDE BETWEEN ? AND ?''',
                        (ts_start, ts_end,
                         lat-radius, lat+radius, lon-radius, lon+radius)).fetchone()[0]
                    # No row limit for GPS-filtered queries — travel events have bounded photo counts
                    cands_raw = db.execute(
                        '''SELECT ZUUID, ZDATECREATED, ZLATITUDE, ZLONGITUDE, ZFAVORITE
                           FROM ZASSET
                           WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0
                             AND ZLATITUDE BETWEEN ? AND ? AND ZLONGITUDE BETWEEN ? AND ?
                           ORDER BY ZDATECREATED''',
                        (ts_start, ts_end,
                         lat-radius, lat+radius, lon-radius, lon+radius)).fetchall()
                    # GPS fallback: if nothing found in DB, try date-range excluding home
                    if total_db == 0:
                        gps_fallback = True
                        total_db = db.execute(
                            f'SELECT COUNT(*) FROM ZASSET WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0 AND {HOME_EXCL}',
                            (ts_start, ts_end)).fetchone()[0]
                        cands_raw = db.execute(
                            f'''SELECT ZUUID, ZDATECREATED, ZLATITUDE, ZLONGITUDE, ZFAVORITE
                               FROM ZASSET
                               WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0 AND {HOME_EXCL}
                               ORDER BY ZDATECREATED
                               LIMIT 3000''',
                            (ts_start, ts_end)).fetchall()
                db.close()

                # Divide into limit time buckets, each bucket exhausted for thumbnails
                buckets = _temporal_buckets(list(cands_raw), limit)
                for bucket in buckets:
                    if len(results) >= limit: break
                    for uuid, ts, flat, flon, is_fav in bucket:
                        if not uuid: continue
                        t = _thumb(uuid)
                        if t:
                            thumb_uuid, thumb_url = t
                            results.append({'uuid': thumb_uuid, 'url': thumb_url,
                                            'lat': flat, 'lon': flon,
                                            'is_favorite': bool(is_fav),
                                            'date': _dt.datetime.fromtimestamp(
                                                ts + 978307200,
                                                tz=_dt.timezone.utc).strftime('%Y-%m-%d')})
                            break  # one photo per bucket is enough

                # Second fallback: GPS photos found in DB but ALL in iCloud (no local thumb)
                # → widen to full date-range excluding home to find any locally cached photo
                if len(results) == 0 and not gps_fallback and total_db > 0 and lat != 0:
                    gps_fallback = True
                    db2 = _sqlite3.connect(f'file:{photos_db}?mode=ro', uri=True)
                    fb_count = db2.execute(
                        f'SELECT COUNT(*) FROM ZASSET WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0 AND {HOME_EXCL}',
                        (ts_start, ts_end)).fetchone()[0]
                    fb_cands = db2.execute(
                        f'''SELECT ZUUID, ZDATECREATED, ZLATITUDE, ZLONGITUDE, ZFAVORITE
                           FROM ZASSET
                           WHERE ZDATECREATED BETWEEN ? AND ? AND ZDATECREATED>0 AND {HOME_EXCL}
                           ORDER BY ZDATECREATED
                           LIMIT 3000''',
                        (ts_start, ts_end)).fetchall()
                    db2.close()
                    if fb_count > 0:
                        total_db = fb_count
                        fb_buckets = _temporal_buckets(list(fb_cands), limit)
                        for bucket in fb_buckets:
                            if len(results) >= limit: break
                            for uuid, ts, flat, flon, is_fav in bucket:
                                if not uuid: continue
                                t = _thumb(uuid)
                                if t:
                                    thumb_uuid, thumb_url = t
                                    results.append({'uuid': thumb_uuid, 'url': thumb_url,
                                                    'lat': flat, 'lon': flon,
                                                    'is_favorite': bool(is_fav),
                                                    'date': _dt.datetime.fromtimestamp(
                                                        ts + 978307200,
                                                        tz=_dt.timezone.utc).strftime('%Y-%m-%d')})
                                    break

                # Background iCloud download: if we still have fewer than limit photos,
                # queue the GPS-matched candidates (favor favorites) for iCloud fetch
                if len(results) < limit and total_db > 0 and _FETCH_SCRIPT.exists():
                    bg_uuids = [c[0] for c in sorted(
                        cands_raw, key=lambda c: (0 if c[4] else 1, c[1])
                    ) if c[0] and not (PHOTO_CACHE_DIR / f'{c[0]}.jpg').exists()][:32]
                    if bg_uuids:
                        _icloud_fetch_bg(bg_uuids)

            except Exception as ex:
                self._json(500, {'error': str(ex)}); return
            self._json(200, {'photos': results, 'total': len(results),
                             'total_db': total_db, 'gps_fallback': gps_fallback,
                             'fetching': sum(1 for v in _FETCH_QUEUE.values() if v == 'pending')})
            return

        if parsed.path.startswith('/api/photo_thumb/'):
            import os as _os
            raw = parsed.path[len('/api/photo_thumb/'):]
            is_cache = raw.endswith('__cache')
            is_thm = raw.endswith('__thm')
            if is_cache:
                uuid = raw[:-7]
                cache_path = str(PHOTO_CACHE_DIR / f'{uuid}.jpg')
                if _os.path.exists(cache_path):
                    with open(cache_path, 'rb') as f: data = f.read()
                    self.send_response(200)
                    self.send_header('Content-Type', 'image/jpeg')
                    self.send_header('Cache-Control', 'public, max-age=31536000')
                    self.end_headers()
                    self.wfile.write(data)
                else:
                    self.send_response(404); self.end_headers()
                return
            uuid = raw[:-5] if is_thm else raw
            # Safety: UUID only contains hex chars and dashes
            if not all(c in '0123456789ABCDEFabcdef-' for c in uuid):
                self._json(400, {'error': 'invalid uuid'})
                return
            deriv_base = _os.path.expanduser(
                '~/Pictures/Fotos-Mediathek.photoslibrary/resources/derivatives')
            first = uuid[0] if uuid else '0'
            thumb_path = _os.path.join(deriv_base, first, uuid + '_1_105_c.jpeg')
            thm_path   = _os.path.join(deriv_base, first, uuid + '.THM')
            if is_thm and _os.path.exists(thm_path):
                thumb_path = thm_path
            elif not _os.path.exists(thumb_path):
                thumb_path = thm_path if _os.path.exists(thm_path) else None
            if not thumb_path or not _os.path.exists(thumb_path):
                self.send_response(404)
                self.end_headers()
                return
            data = open(thumb_path, 'rb').read()
            self.send_response(200)
            self.send_header('Content-Type', 'image/jpeg')
            self.send_header('Content-Length', len(data))
            self.send_header('Cache-Control', 'public, max-age=86400')
            self._cors()
            self.end_headers()
            self.wfile.write(data)
            return
        # ── Ende Reise-Beweise ────────────────────────────────────────────

        # ── Generischer Voice-Clips Index (GET) ───────────────────────
        if parsed.path == '/api/voice_clips/index':
            import re as _re
            qs = parse_qs(parsed.query)
            voice = qs.get('voice', [''])[0].strip()
            if not voice or not _re.match(r'^[a-z0-9_]+$', voice):
                self._json(400, {'error': 'voice parameter fehlt oder ungültig'})
                return
            index_file = WATSON_VOICES_DIR / f'{voice}_clips' / 'index.json'
            if index_file.exists():
                try:
                    raw = json.loads(index_file.read_text(encoding='utf-8'))
                    # raw kann Liste oder {'clips': [...]} sein
                    clips = raw if isinstance(raw, list) else raw.get('clips', raw)
                    self._json(200, {'clips': clips})
                except Exception:
                    self._json(500, {'error': 'index.json nicht lesbar'})
            else:
                self._json(404, {'error': 'index.json nicht vorhanden'})
            return

        # ── Generische Voice-Clip Tags (GET) ─────────────────────────
        if parsed.path == '/api/voice_clip_tags':
            import re as _re
            qs = parse_qs(parsed.query)
            voice = qs.get('voice', [''])[0].strip()
            if not voice or not _re.match(r'^[a-z0-9_]+$', voice):
                self._json(400, {'error': 'voice parameter fehlt oder ungültig'})
                return
            tags_file = WATSON_VOICES_DIR / f'{voice}_clips' / 'tags.json'
            if tags_file.exists():
                try:
                    self._json(200, {'tags': json.loads(tags_file.read_text(encoding='utf-8'))})
                except Exception:
                    self._json(200, {'tags': {}})
            else:
                self._json(200, {'tags': {}})
            return

        # ── Lebensstationen JSON (GET) ───────────────────────────────
        if parsed.path == '/lebensstationen.json':
            p = COCKPIT_DIR / 'lebensstationen.json'
            if p.exists():
                try:
                    data = p.read_bytes()
                    self.send_response(200)
                    self.send_header('Content-Type', 'application/json')
                    self.send_header('Content-Length', len(data))
                    self._cors()
                    self.end_headers()
                    self.wfile.write(data)
                except Exception as e:
                    self._json(500, {'error': str(e)})
            else:
                self._json(404, {'error': 'lebensstationen.json nicht vorhanden'})
            return

        if parsed.path == '/api/reisebericht/archiv':
            # Liest die 10 neuesten MP3-Dateien (ohne _Musik) aus dem Reiseberichte-Ordner
            reise_dir = Path.home() / 'Desktop' / 'Reiseberichte'
            try:
                mp3_files = [
                    f for f in reise_dir.iterdir()
                    if f.suffix == '.mp3' and '_Musik' not in f.name
                ] if reise_dir.exists() else []
                mp3_files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
                mp3_files = mp3_files[:10]
                result = []
                for f in mp3_files:
                    st = f.stat()
                    sidecar_name = f.stem + '_quellen.json'
                    sidecar_path = reise_dir / sidecar_name
                    sidecar = None
                    if sidecar_path.exists():
                        try:
                            sidecar = json.loads(sidecar_path.read_text(encoding='utf-8'))
                        except Exception:
                            sidecar = None
                    result.append({
                        'filename': f.name,
                        'mtime': st.st_mtime,
                        'size': st.st_size,
                        'sidecar': sidecar,
                    })
                self._json(200, {'files': result})
            except Exception as e:
                self._json(500, {'error': str(e)})
            return

        # ── Standalone HTML Export ────────────────────────────────────
        if parsed.path == '/api/export/standalone':
            import re as _re_exp
            import base64 as _b64
            qs = parse_qs(parsed.query)
            page = qs.get('page', [''])[0].strip()
            if not page:
                self._json(400, {'ok': False, 'error': 'page parameter fehlt'})
                return
            # Path traversal Schutz: nur einfache Dateinamen ohne / oder ..
            if '/' in page or '\\' in page or '..' in page:
                body_err = b'403 Forbidden'
                self.send_response(403)
                self.send_header('Content-Type', 'text/plain')
                self.send_header('Content-Length', len(body_err))
                self._cors()
                self.end_headers()
                self.wfile.write(body_err)
                return
            html_path = COCKPIT_DIR / page
            # Sicherstellen dass die Datei wirklich im cockpit/-Verzeichnis liegt
            try:
                resolved = html_path.resolve()
                cockpit_resolved = COCKPIT_DIR.resolve()
                if not str(resolved).startswith(str(cockpit_resolved) + '/') and resolved != cockpit_resolved:
                    raise ValueError('outside cockpit dir')
            except Exception:
                body_err = b'403 Forbidden'
                self.send_response(403)
                self.send_header('Content-Type', 'text/plain')
                self.send_header('Content-Length', len(body_err))
                self._cors()
                self.end_headers()
                self.wfile.write(body_err)
                return
            if not html_path.exists() or not html_path.is_file():
                body_err = b'404 Not Found'
                self.send_response(404)
                self.send_header('Content-Type', 'text/plain')
                self.send_header('Content-Length', len(body_err))
                self._cors()
                self.end_headers()
                self.wfile.write(body_err)
                return
            try:
                html = html_path.read_text(encoding='utf-8')

                # 1) network-aware.js Referenz entfernen (macht Server-Calls)
                html = _re_exp.sub(
                    r'<script[^>]+src=["\'][^"\']*network-aware\.js["\'][^>]*>\s*</script>',
                    '', html, flags=_re_exp.IGNORECASE
                )

                # 2) Lokale CSS-Dateien inlinen
                def _inline_css(m):
                    href = m.group(1)
                    if href.startswith('http://') or href.startswith('https://') or href.startswith('//'):
                        return m.group(0)  # externer Link bleibt
                    css_path = COCKPIT_DIR / href.lstrip('/')
                    try:
                        css_content = css_path.read_text(encoding='utf-8')
                        return f'<style>\n{css_content}\n</style>'
                    except Exception:
                        return m.group(0)

                html = _re_exp.sub(
                    r'<link[^>]+rel=["\']stylesheet["\'][^>]+href=["\']([^"\']+)["\'][^>]*/?>\s*(?:</link>)?',
                    _inline_css, html, flags=_re_exp.IGNORECASE
                )
                # Auch href vor rel
                html = _re_exp.sub(
                    r'<link[^>]+href=["\']([^"\']+)["\'][^>]+rel=["\']stylesheet["\'][^>]*/?>\s*(?:</link>)?',
                    _inline_css, html, flags=_re_exp.IGNORECASE
                )

                # 3) Lokale JS-Dateien inlinen (network-aware.js bereits entfernt)
                def _inline_js(m):
                    src = m.group(1)
                    if src.startswith('http://') or src.startswith('https://') or src.startswith('//'):
                        return m.group(0)  # externer Link bleibt
                    js_path = COCKPIT_DIR / src.lstrip('/')
                    try:
                        js_content = js_path.read_text(encoding='utf-8')
                        return f'<script>\n{js_content}\n</script>'
                    except Exception:
                        return m.group(0)

                html = _re_exp.sub(
                    r'<script[^>]+src=["\']([^"\']+)["\'][^>]*>\s*</script>',
                    _inline_js, html, flags=_re_exp.IGNORECASE
                )

                # 4) Lokale Bilder als base64 inlinen
                def _inline_img(m):
                    prefix = m.group(1)
                    src = m.group(2)
                    suffix = m.group(3)
                    if src.startswith('http://') or src.startswith('https://') or src.startswith('//') or src.startswith('data:'):
                        return m.group(0)
                    img_path = COCKPIT_DIR / src.lstrip('/')
                    try:
                        img_data = img_path.read_bytes()
                        ext = img_path.suffix.lower().lstrip('.')
                        mime_map = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
                                    'png': 'image/png', 'gif': 'image/gif',
                                    'svg': 'image/svg+xml', 'webp': 'image/webp',
                                    'ico': 'image/x-icon'}
                        mime = mime_map.get(ext, 'image/png')
                        b64 = _b64.b64encode(img_data).decode('ascii')
                        return f'{prefix}data:{mime};base64,{b64}{suffix}'
                    except Exception:
                        return m.group(0)

                html = _re_exp.sub(
                    r'(<img[^>]+src=["\'])([^"\']+)(["\'])',
                    _inline_img, html, flags=_re_exp.IGNORECASE
                )

                # 5) Seitentitel für den Dateinamen extrahieren
                title_m = _re_exp.search(r'<title>([^<]+)</title>', html, _re_exp.IGNORECASE)
                title = title_m.group(1).strip() if title_m else page.replace('.html', '')
                # Dateinamen-sicher machen
                safe_title = _re_exp.sub(r'[^\w\-äöüÄÖÜß ]', '_', title)
                safe_title = safe_title.strip().replace(' ', '_')[:60]
                filename = f'{safe_title}_standalone.html'

                out_bytes = html.encode('utf-8')
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
                self.send_header('Content-Length', len(out_bytes))
                self._cors()
                self.end_headers()
                self.wfile.write(out_bytes)
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        # ── Hub PWA — no-cache headers + manifest content-type ──────────────
        if parsed.path in ('/hub.html', '/hub.manifest.json', '/hub-icon.png'):
            file_path = COCKPIT_DIR / parsed.path.lstrip('/')
            if not file_path.exists():
                self.send_response(404)
                self.end_headers()
                return
            data = file_path.read_bytes()
            if parsed.path == '/hub.manifest.json':
                mime = 'application/manifest+json'
            elif parsed.path == '/hub-icon.png':
                mime = 'image/png'
            else:
                mime = 'text/html; charset=utf-8'
            self.send_response(200)
            self.send_header('Content-Type', mime)
            self.send_header('Content-Length', len(data))
            self.send_header('Cache-Control', 'no-cache, must-revalidate')
            self._cors()
            self.end_headers()
            self.wfile.write(data)
            return

        # ── No-Cache für HTML-Dateien ──────────────────────────────────────
        if parsed.path.endswith('.webmanifest'):
            file_name = parsed.path.lstrip('/')
            file_path = COCKPIT_DIR / file_name
            if file_path.exists() and file_path.is_file() and '..' not in file_name:
                data = file_path.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'application/manifest+json')
                self.send_header('Content-Length', len(data))
                self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
                self._cors()
                self.end_headers()
                self.wfile.write(data)
                return

        if parsed.path.endswith('.html') or parsed.path == '/' or parsed.path == '':
            file_name = 'index.html' if parsed.path in ('/', '', '/index.html') else parsed.path.lstrip('/')
            file_path = COCKPIT_DIR / file_name
            if file_path.exists() and file_path.is_file():
                data = file_path.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Length', len(data))
                self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
                self.send_header('Pragma', 'no-cache')
                self.send_header('Expires', '0')
                self._cors()
                self.end_headers()
                self.wfile.write(data)
                return

        # ── Jackett ───────────────────────────────────────────────────────
        if parsed.path == '/api/jackett/search':
            qs = parse_qs(parsed.query)
            q = qs.get('q', [''])[0].strip()
            if not q:
                self._json(400, {'ok': False, 'error': 'q required'})
                return
            results = _jackett_search(q)
            self._json(200, {'ok': True, 'results': results, 'count': len(results)})
            return

        # ── SABnzbd / Usenet ──────────────────────────────────────────────
        if parsed.path == '/api/sabnzbd/status':
            data = _sabnzbd_api('queue')
            ver  = _sabnzbd_api('version')
            self._json(200, {'ok': 'error' not in data, 'version': ver.get('version','?'), 'queue': data})
            return

        if parsed.path == '/api/sabnzbd/config':
            # Return whether provider + indexer are configured
            cfg = json.loads(NZBGEEK_CFG.read_text()) if NZBGEEK_CFG.exists() else {}
            srv = _sabnzbd_api('get_config', {'section': 'servers'})
            has_server = bool((srv.get('config') or {}).get('servers'))
            self._json(200, {'nzbgeek_key': bool(cfg.get('api_key')), 'usenet_server': has_server})
            return

        if parsed.path == '/api/nzbgeek/search':
            qs = parse_qs(parsed.query)
            q = qs.get('q', [''])[0].strip()
            if not q:
                self._json(400, {'ok': False, 'error': 'q required'}); return
            results = _nzbgeek_search(q)
            self._json(200, {'ok': True, 'results': results, 'count': len(results)})
            return

        # ── yt-dlp ────────────────────────────────────────────────────────
        if parsed.path.startswith('/api/ytdlp/status/'):
            jid = parsed.path[len('/api/ytdlp/status/'):].strip('/')
            with _YTDLP_LOCK:
                job = _YTDLP_JOBS.get(jid)
            self._json(200 if job else 404, job or {'error': 'not found'})
            return

        # ── Audible ───────────────────────────────────────────────────────
        if parsed.path == '/api/audible/status':
            import subprocess as _sp2
            ok = False
            if AUDIBLE_CLI.exists():
                r = _sp2.run([str(AUDIBLE_CLI), 'library', 'list', '--format', 'tsv'],
                             capture_output=True, text=True, timeout=15)
                ok = r.returncode == 0 and len(r.stdout.strip()) > 0
            self._json(200, {'configured': ok, 'cli_exists': AUDIBLE_CLI.exists()})
            return

        if parsed.path == '/api/audible/library':
            books = _audible_library()
            # Filter for Pratchett/Scheibenwelt
            pratchett = [b for b in books if 'pratchett' in (b.get('author','') + b.get('title','')).lower()
                         or 'scheibenwelt' in (b.get('title','')).lower()
                         or 'discworld' in (b.get('title','')).lower()]
            self._json(200, {'all': books, 'pratchett': pratchett, 'total': len(books)})
            return

        if parsed.path.startswith('/api/ytdlp/files'):
            files = []
            for p in sorted(STIMMEN_YTDLP.glob('*'), key=lambda f: f.stat().st_mtime, reverse=True)[:20]:
                files.append({'name': p.name, 'size_mb': round(p.stat().st_size/1024/1024, 1)})
            for p in sorted(STIMMEN_AUDIO.glob('*.wav'), key=lambda f: f.stat().st_mtime, reverse=True)[:20]:
                files.append({'name': p.name, 'size_mb': round(p.stat().st_size/1024/1024, 1), 'type': 'wav'})
            self._json(200, {'files': files})
            return

        # ── Real-Debrid ────────────────────────────────────────────────────
        if parsed.path == '/api/realdebrid/config':
            has_key = bool(_rd_token())
            self._json(200, {'configured': has_key})
            return

        if parsed.path == '/api/realdebrid/downloads':
            with RD_LOCK:
                jobs = list(RD_JOBS.values())
            self._json(200, {'jobs': jobs})
            return

        if parsed.path.startswith('/api/realdebrid/status/'):
            job_id = parsed.path[len('/api/realdebrid/status/'):].strip('/')
            with RD_LOCK:
                job = RD_JOBS.get(job_id)
            if job:
                self._json(200, job)
            else:
                self._json(404, {'error': 'job not found'})
            return

        # INSTRUMENT-AUSNAHME: Victor-Go — voices_audit Clip-Auslieferung und ElevenLabs Endpoints
        # ── Voice Audit GET Endpoints ──────────────────────────────────────
        if parsed.path.startswith('/api/voice_audit/clip/'):
            parts = parsed.path[len('/api/voice_audit/clip/'):].strip('/').split('/')
            if len(parts) == 3:
                _va_vid, _va_lang, _va_ct = parts
                _va_clip = WATSON_VOICES_DIR / 'voice_audit_clips' / _va_vid / f'{_va_lang}_{_va_ct}.mp3'
                if _va_clip.exists():
                    data = _va_clip.read_bytes()
                    self.send_response(200)
                    self.send_header('Content-Type', 'audio/mpeg')
                    self.send_header('Content-Length', len(data))
                    self.send_header('Cache-Control', 'no-cache')
                    self._cors()
                    self.end_headers()
                    self.wfile.write(data)
                    return
            self.send_response(404); self.end_headers(); return

        if parsed.path.startswith('/api/voice_audit/status/'):
            _va_vid_s = parsed.path[len('/api/voice_audit/status/'):].strip('/')
            _va_clips_dir_s = WATSON_VOICES_DIR / 'voice_audit_clips' / _va_vid_s
            _va_result = {}
            for lang in ('de', 'en'):
                for ct in ('typical_1', 'typical_2', 'typical_3', 'aufgeregt', 'sarkastisch', 'verwundert', 'flehend'):
                    key = f'{lang}_{ct}'
                    p = _va_clips_dir_s / f'{key}.mp3'
                    _va_result[key] = p.exists() and p.stat().st_size > 1000
            self._json(200, {'clips': _va_result})
            return

        if parsed.path == '/api/voice_audit/preview_urls':
            _va_pu_file = WATSON_VOICES_DIR / 'voice_audit_preview_urls.json'
            if _va_pu_file.exists():
                self._json(200, json.loads(_va_pu_file.read_text(encoding='utf-8')))
            else:
                self._json(200, {})
            return

        if parsed.path == '/api/voice_audit/weg_batch':
            _va_wf = WATSON_VOICES_DIR / 'voice_audit_weg_batch.json'
            _va_wdata = json.loads(_va_wf.read_text(encoding='utf-8')) if _va_wf.exists() else []
            self._json(200, {'voice_ids': _va_wdata})
            return

        if parsed.path == '/api/voice_audit/sort':
            _va_sf = WATSON_VOICES_DIR / 'voice_audit_sort.json'
            _va_raw = json.loads(_va_sf.read_text(encoding='utf-8')) if _va_sf.exists() else {}
            # Normalize: always return arrays (convert old string format)
            _va_sdata = {}
            for _va_k, _va_v in _va_raw.items():
                _va_sdata[_va_k] = _va_v if isinstance(_va_v, list) else ([_va_v] if _va_v else [])
            self._json(200, _va_sdata)
            return

        if parsed.path == '/api/voice_audit/sentences':
            _va_senf = WATSON_VOICES_DIR / 'voice_audit_sentences.json'
            if _va_senf.exists():
                self._json(200, json.loads(_va_senf.read_text(encoding='utf-8')))
            else:
                self._json(200, {})
            return

        if parsed.path == '/api/voice_audit/dates':
            _va_df = WATSON_VOICES_DIR / 'voice_audit_dates.json'
            if _va_df.exists():
                self._json(200, json.loads(_va_df.read_text(encoding='utf-8')))
            else:
                self._json(200, {})
            return

        if parsed.path == '/api/voice_demo_clips':
            _vdc_dir = Path(__file__).parent / 'watson_demo_clips'
            _vdc_files = []
            if _vdc_dir.exists():
                _vdc_files = [f.name for f in _vdc_dir.iterdir() if f.suffix == '.mp3' and f.stat().st_size > 500]
                _vdc_files.sort()
            self._json(200, {'files': _vdc_files, 'count': len(_vdc_files)})
            return

        # ── ccusage — Claude Code Kostenübersicht ────────────────────────────
        if parsed.path == '/api/ccusage':
            import subprocess as _sp_cc
            CCUSAGE = '/opt/homebrew/bin/ccusage'
            result = {'monthly': [], 'daily': [], 'ok': True}
            try:
                r_monthly = _sp_cc.run(
                    [CCUSAGE, 'monthly', '--json'],
                    capture_output=True, text=True, timeout=15,
                    env={**__import__('os').environ, 'PATH': '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'}
                )
                if r_monthly.returncode == 0 and r_monthly.stdout.strip():
                    result['monthly'] = json.loads(r_monthly.stdout).get('monthly', [])
            except Exception as e_monthly:
                result['monthly_error'] = str(e_monthly)
            try:
                r_daily = _sp_cc.run(
                    [CCUSAGE, 'daily', '--json'],
                    capture_output=True, text=True, timeout=15,
                    env={**__import__('os').environ, 'PATH': '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin'}
                )
                if r_daily.returncode == 0 and r_daily.stdout.strip():
                    result['daily'] = json.loads(r_daily.stdout).get('daily', [])
            except Exception as e_daily:
                result['daily_error'] = str(e_daily)
            self._json(200, result)
            return

        # ── receipts.json — echte Rechnungen aus Apple Mail ─────────────────
        if parsed.path == '/api/receipts':
            import os as _os_r
            receipts_path = _os_r.path.join(_os_r.path.dirname(__file__), 'receipts.json')
            try:
                with open(receipts_path, 'r', encoding='utf-8') as _f_r:
                    self._json(200, json.loads(_f_r.read()))
            except Exception as _e_r:
                self._json(200, {'receipts': [], 'error': str(_e_r)})
            return

        # ── Anthropic Cost Report — API-Verbrauch nach Modell ─────────────────
        if parsed.path == '/api/anthropic_costs':
            self._json(200, _anthropic_costs_get())
            return

        # ── Sancho Spawns — letzte 24h ─────────────────────────────────────────
        if parsed.path == '/api/spawns':
            self._json(200, _spawns_get())
            return

        # ── Cloudflare API ─────────────────────────────────────────────────────
        if parsed.path == '/api/cloudflare/test-token':
            try:
                import urllib.request as _ureq
                token = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'cloudflare-api', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not token:
                    self._json(200, {'ok': False, 'error': 'Kein Token im Schlüsselbund'}); return
                CF_ACCOUNT = '4063400905adb9cae4a6de2a32841545'
                CF_ZONE_NAME = 'beachorchestra.com'
                headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
                req = _ureq.Request(f'https://api.cloudflare.com/client/v4/zones?name={CF_ZONE_NAME}', headers=headers)
                with _ureq.urlopen(req, timeout=10) as resp:
                    data = json.loads(resp.read())
                if not data.get('success') or not data.get('result'):
                    self._json(200, {'ok': False, 'error': 'Token ungültig oder Zone nicht gefunden'}); return
                zone = data['result'][0]
                self._json(200, {'ok': True, 'zone_id': zone['id'], 'zone_name': zone['name'], 'account_name': zone.get('account', {}).get('name', CF_ACCOUNT)})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Token im Schlüsselbund'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/task/api-token':
            # Nur LAN/Localhost — Token für Cherry-Setup anzeigen
            client_ip = self.client_address[0]
            is_local = (client_ip.startswith('192.168.') or client_ip.startswith('10.') or
                        client_ip in ('127.0.0.1', '::1', 'localhost'))
            if not is_local:
                self._json(403, {'ok': False, 'error': 'Nur im lokalen Netzwerk verfügbar'})
                return
            token = _get_task_token()
            if not token:
                self._json(500, {'ok': False, 'error': 'Token-Datei fehlt'})
                return
            self._json(200, {'ok': True, 'token': token, 'endpoint': 'https://tasks.beachorchestra.com/api/task/submit'})
            return

        if parsed.path == '/api/task/status':
            try:
                import datetime as _dt
                base = COCKPIT_DIR.parent
                def _count_and_list(folder):
                    p = base / folder
                    p.mkdir(exist_ok=True)
                    files = sorted(p.glob('*.json'), key=lambda f: f.stat().st_mtime, reverse=True)
                    items = []
                    for f in files[:20]:
                        try:
                            d = json.loads(f.read_text(encoding='utf-8'))
                            items.append({'id': d.get('id', f.stem), 'title': d.get('title', ''), 'type': d.get('type', ''), 'from': d.get('from', ''), 'ts': d.get('ts', ''), 'filename': f.name})
                        except Exception:
                            items.append({'id': f.stem, 'title': f.name, 'type': '', 'from': '', 'ts': '', 'filename': f.name})
                    return {'count': len(files), 'items': items}
                # Tageslimit-Status
                today = _dt.date.today().isoformat()
                try:
                    limits = json.loads(TASK_LIMITS_FILE.read_text()) if TASK_LIMITS_FILE.exists() else {}
                except Exception:
                    limits = {}
                if limits.get('date') != today:
                    limits = {'date': today, 'count': 0, 'blocked': 0}
                self._json(200, {
                    'ok': True,
                    'outbox': _count_and_list('OUTBOX'),
                    'processing': _count_and_list('PROCESSING'),
                    'done': _count_and_list('DONE'),
                    'error': _count_and_list('ERROR'),
                    'security': {
                        'token_configured': bool(_get_task_token()),
                        'daily_count': limits.get('count', 0),
                        'daily_limit': TASK_DAILY_LIMIT,
                        'blocked_today': limits.get('blocked', 0),
                    },
                    'checked_at': _dt.datetime.utcnow().isoformat() + 'Z',
                })
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/cloudflare/tunnel-config':
            try:
                import urllib.request as _ureq
                token = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'cloudflare-api', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not token:
                    self._json(200, {'ok': False, 'error': 'Kein Token gespeichert'}); return
                CF_ACCOUNT = '4063400905adb9cae4a6de2a32841545'
                CF_TUNNEL  = '75d3bc4e-18b5-442e-ac28-ff4850efbd4f'
                headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
                url = f'https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT}/cfd_tunnel/{CF_TUNNEL}/configurations'
                req = _ureq.Request(url, headers=headers)
                with _ureq.urlopen(req, timeout=10) as resp:
                    data = json.loads(resp.read())
                if not data.get('success'):
                    errors = data.get('errors', [])
                    self._json(200, {'ok': False, 'error': errors[0].get('message') if errors else 'Fehler'}); return
                ingress = data.get('result', {}).get('config', {}).get('ingress', [])
                self._json(200, {'ok': True, 'ingress': ingress})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Token gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        # ── REISEBERICHT MAP KEYS (tile keys müssen im Browser sein) ─────────
        if parsed.path == '/api/reisebericht/map-keys':
            def _kc(svc, acc='reisebericht'):
                try:
                    return subprocess.check_output(
                        ['security', 'find-generic-password', '-s', svc, '-a', acc, '-w'],
                        text=True, stderr=subprocess.DEVNULL).strip()
                except Exception:
                    return ''
            self._json(200, {
                'thunderforest': _kc('thunderforest-api'),
                'stadia':        '20d481d7-b68b-4c45-9424-5e0357f6a298',
            })
            return

        # ── REISEBERICHT API-KEY TESTS ────────────────────────────────────────
        if parsed.path == '/api/reisebericht/test-europeana':
            try:
                import urllib.request as _ureq
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'europeana-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                url = f'https://api.europeana.eu/record/v2/search.json?wskey={key}&query=Berlin&rows=1&profile=minimal'
                req = _ureq.Request(url, headers={'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                total = data.get('totalResults', 0)
                items = data.get('items', [])
                sample = items[0].get('title', [''])[0] if items else ''
                self._json(200, {'ok': True, 'total': total, 'sample': sample})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-foursquare':
            try:
                import urllib.request as _ureq
                # fsq3-Key (im Dashboard "Legacy" genannt) ist der richtige für Places API
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'foursquare-legacy-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'fsq3-Key nicht gespeichert (Legacy-Feld in Setup)'}); return
                # Neue Places API (ab 2025) — alte v3 deprecated seit Mai 2026
                url = 'https://places-api.foursquare.com/places/search?ll=52.52%2C13.40&limit=1&fields=name%2Ccategories'
                req = _ureq.Request(url, headers={
                    'Authorization': f'Bearer {key}',
                    'X-Places-Api-Version': '2025-06-17',
                    'Accept': 'application/json',
                    'User-Agent': 'ReiseberichtSystem/1.0',
                })
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                results = data.get('results', [])
                sample = results[0].get('name', '') if results else ''
                self._json(200, {'ok': True, 'count': len(results), 'sample': sample})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-thunderforest':
            try:
                import urllib.request as _ureq
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'thunderforest-api', '-a', 'beachorchestra', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                url = f'https://api.thunderforest.com/pioneer/1/1/0.png?apikey={key}'
                req = _ureq.Request(url, headers={'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    code = resp.status
                self._json(200, {'ok': code == 200, 'http_status': code})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-flickr':
            try:
                import urllib.request as _ureq
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'flickr-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                url = f'https://api.flickr.com/services/rest/?method=flickr.test.echo&api_key={key}&format=json&nojsoncallback=1'
                req = _ureq.Request(url, headers={'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                ok = data.get('stat') == 'ok'
                self._json(200, {'ok': ok, 'stat': data.get('stat')})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-unsplash':
            try:
                import urllib.request as _ureq
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'unsplash-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                url = f'https://api.unsplash.com/photos/random?query=Berlin&client_id={key}'
                req = _ureq.Request(url, headers={
                    'User-Agent': 'ReiseberichtSystem/1.0',
                    'Accept-Version': 'v1',
                })
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                sample = (data.get('description') or data.get('alt_description') or 'Foto gefunden')[:60]
                self._json(200, {'ok': True, 'sample': sample})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-opentripmap':
            try:
                import urllib.request as _ureq
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'opentripmap-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                url = (f'https://api.opentripmap.com/0.1/en/places/radius'
                       f'?radius=1000&lon=13.40&lat=52.52&format=json&limit=1&apikey={key}')
                req = _ureq.Request(url, headers={'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                features = data.get('features', [])
                sample = features[0]['properties'].get('name', '') if features else ''
                self._json(200, {'ok': True, 'count': len(features), 'sample': sample})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-mapillary':
            try:
                import urllib.request as _ureq
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'mapillary-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                # lat/lng/radius (max 50m) — bbox/closeto funktionieren nicht
                url = (f'https://graph.mapillary.com/images'
                       f'?access_token={key}&lat=52.520&lng=13.405&radius=50&fields=id&limit=1')
                req = _ureq.Request(url, headers={'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                items = data.get('data', [])
                self._json(200, {'ok': True, 'count': len(items),
                                 'sample': items[0].get('id', '') if items else 'keine Bilder im Testbereich'})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-freesound':
            try:
                import urllib.request as _ureq
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'freesound-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                url = (f'https://freesound.org/apiv2/search/text/'
                       f'?query=berlin+street&token={key}&page_size=1&fields=name')
                req = _ureq.Request(url, headers={'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                count = data.get('count', 0)
                results = data.get('results', [])
                sample = (results[0].get('name', '') if results else '')[:60]
                self._json(200, {'ok': True, 'total': count, 'sample': sample})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-tomtom':
            try:
                import urllib.request as _ureq
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'tomtom-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                url = f'https://api.tomtom.com/search/2/search/Berlin.json?key={key}&limit=1'
                req = _ureq.Request(url, headers={'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                results = data.get('results', [])
                sample = ''
                if results:
                    poi = results[0].get('poi', {})
                    addr = results[0].get('address', {})
                    sample = poi.get('name') or addr.get('freeformAddress', '')
                self._json(200, {'ok': True, 'count': len(results), 'sample': sample[:60]})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-ddb':
            try:
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'ddb-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                import urllib.request as _ureq
                url = f'https://api.deutsche-digitale-bibliothek.de/search?query=Berlin&rows=1&oauth_consumer_key={key}'
                req = _ureq.Request(url, headers={'Accept': 'application/json', 'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=10) as resp:
                    data = json.loads(resp.read())
                total = (data.get('numberOfResults') or
                         data.get('results', {}).get('numFound') or
                         len(data.get('results', {}).get('docs', [])))
                self._json(200, {'ok': True, 'total': total})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-mapbox':
            try:
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'mapbox-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                import urllib.request as _ureq
                url = f'https://api.mapbox.com/geocoding/v5/mapbox.places/Berlin.json?access_token={key}&limit=1'
                req = _ureq.Request(url, headers={'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                features = data.get('features', [])
                sample = features[0].get('place_name', '')[:60] if features else ''
                self._json(200, {'ok': True, 'count': len(features), 'sample': sample})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/reisebericht/test-genius':
            try:
                key = subprocess.check_output(
                    ['security', 'find-generic-password', '-s', 'genius-api', '-a', 'reisebericht', '-w'],
                    text=True, stderr=subprocess.DEVNULL).strip()
                if not key:
                    self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'}); return
                import urllib.request as _ureq
                req = _ureq.Request(
                    'https://api.genius.com/search?q=Berlin&per_page=1',
                    headers={'Authorization': f'Bearer {key}', 'User-Agent': 'ReiseberichtSystem/1.0'})
                with _ureq.urlopen(req, timeout=8) as resp:
                    data = json.loads(resp.read())
                hits = data.get('response', {}).get('hits', [])
                sample = ''
                if hits:
                    r = hits[0].get('result', {})
                    sample = f"{r.get('title','')[:40]} — {r.get('primary_artist',{}).get('name','')[:30]}"
                self._json(200, {'ok': True, 'count': len(hits), 'sample': sample})
            except subprocess.CalledProcessError:
                self._json(200, {'ok': False, 'error': 'Kein Key gespeichert'})
            except Exception as e:
                self._json(200, {'ok': False, 'error': str(e)})
            return

        # ── HTML-Dateien immer mit no-store liefern (PWA-Pflicht) ────────────
        if parsed.path.endswith('.html') or parsed.path in ('/', ''):
            rel = parsed.path.lstrip('/') or 'index.html'
            fpath = COCKPIT_DIR / rel
            if fpath.exists() and fpath.is_file():
                data = fpath.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Length', str(len(data)))
                self.send_header('Cache-Control', 'no-store')
                self._cors()
                self.end_headers()
                try:
                    self.wfile.write(data)
                except (BrokenPipeError, ConnectionResetError, OSError):
                    pass
                return

        # ── watson_demo_clips — MP3s immer frisch ausliefern, kein Cloudflare-Cache ──
        if parsed.path.startswith('/watson_demo_clips/') and parsed.path.endswith('.mp3'):
            _clip_file = Path(__file__).parent / parsed.path.lstrip('/')
            if _clip_file.exists():
                _clip_data = _clip_file.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'audio/mpeg')
                self.send_header('Content-Length', str(len(_clip_data)))
                self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate')
                self._cors()
                self.end_headers()
                try:
                    self.wfile.write(_clip_data)
                except (BrokenPipeError, ConnectionResetError, OSError):
                    pass
                return

        # ── ChatGPT Importer — GET Endpoints ─────────────────────────────
        if parsed.path == '/api/chatgpt/status':
            try:
                import sys as _sys
                _sys.path.insert(0, str(Path(__file__).parent.parent))
                from chatgpt_importer import core as _cgi
                self._json(200, _cgi.db_status())
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/chatgpt/search':
            try:
                import sys as _sys
                _sys.path.insert(0, str(Path(__file__).parent.parent))
                from chatgpt_importer import core as _cgi
                from urllib.parse import parse_qs as _pqs
                qs = _pqs(parsed.query)
                q     = (qs.get('q',    [''])[0]).strip()
                mode  = (qs.get('mode', ['fts'])[0]).strip()
                role  = (qs.get('role', [''])[0]).strip()
                limit = int(qs.get('limit', ['20'])[0])
                if mode == 'semantic':
                    results = _cgi.semantic_search(q, limit=limit)
                else:
                    results = _cgi.search(q, limit=limit, role_filter=role)
                self._json(200, {'ok': True, 'results': results, 'count': len(results)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        # ── Cherry Rückkanal GET ─────────────────────────────────────────
        if parsed.path == '/api/cherry/inbox':
            try:
                from urllib.parse import parse_qs as _pqs
                qs     = _pqs(parsed.query)
                status = qs.get('status', [''])[0].strip() or None
                limit  = int(qs.get('limit', ['50'])[0])
                msgs   = _cherry_inbox.get_messages(status=status, limit=limit) if _CHERRY_OK else []
                self._json(200, {'ok': True, 'messages': msgs, 'count': len(msgs)})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/cherry/stats':
            try:
                stats = _cherry_inbox.get_stats() if _CHERRY_OK else {'error': 'Cherry-Modul nicht geladen'}
                self._json(200, {'ok': True, **stats})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/cherry/notify/config':
            try:
                cfg = _cherry_notify.get_config() if _CHERRY_OK else {}
                self._json(200, {'ok': True, 'config': cfg})
            except Exception as e:
                self._json(500, {'ok': False, 'error': str(e)})
            return

        if parsed.path == '/api/cherry/bridge/status':
            import urllib.request as _ur, urllib.error as _ue
            try:
                with _ur.urlopen('http://localhost:9225/json', timeout=2) as _r:
                    _tabs = json.loads(_r.read())
                _chatgpt = next((t for t in _tabs if 'chatgpt.com' in t.get('url', '') or 'chat.openai.com' in t.get('url', '')), None)
                _lst = _cherry_listener.get_status() if _CHERRY_OK else {}
                _rct = _watson_reactor.get_status() if _CHERRY_OK else {}
                self._json(200, {
                    'running': True,
                    'chatgpt_tab': _chatgpt is not None,
                    'tab_title': _chatgpt.get('title', '') if _chatgpt else '',
                    'listener': _lst,
                    'reactor': _rct,
                })
            except Exception:
                self._json(200, {'running': False, 'chatgpt_tab': False, 'tab_title': '', 'listener': {}})
            return

        if parsed.path == '/api/cherry/inbox/stream':
            # SSE — hält Verbindung offen, pusht neue Nachrichten sofort
            import queue as _q
            self.send_response(200)
            self.send_header('Content-Type', 'text/event-stream')
            self.send_header('Cache-Control', 'no-cache')
            self.send_header('Connection', 'keep-alive')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('X-Accel-Buffering', 'no')
            self.end_headers()
            sub_q = _cherry_inbox.subscribe_sse() if _CHERRY_OK else None
            try:
                # Initial ping
                self.wfile.write(b': connected\n\n')
                self.wfile.flush()
                while True:
                    if sub_q is None:
                        import time as _t; _t.sleep(30)
                        self.wfile.write(b': heartbeat\n\n')
                        self.wfile.flush()
                        continue
                    try:
                        event = sub_q.get(timeout=30)
                        payload = json.dumps(event, ensure_ascii=False)
                        self.wfile.write(f'data: {payload}\n\n'.encode('utf-8'))
                        self.wfile.flush()
                    except _q.Empty:
                        self.wfile.write(b': heartbeat\n\n')
                        self.wfile.flush()
            except (BrokenPipeError, ConnectionResetError, OSError):
                pass
            finally:
                if sub_q is not None and _CHERRY_OK:
                    _cherry_inbox.unsubscribe_sse(sub_q)
            return

        # ── ZEFYS DDR-Presseportal ─────────────────────────────────────────
        if parsed.path == '/api/zefys/status':
            try:
                session_file = COCKPIT_DIR / 'zefys_session.json'
                if not session_file.exists():
                    self._json(200, {'has_session': False, 'cookie_count': 0, 'saved_at': '', 'session_valid': False})
                    return
                data = json.loads(session_file.read_text())
                zefys_cookies = data.get('zefys_cookies', [])
                session_c = next((c for c in zefys_cookies if c['name'] == 'session'), None)
                session_valid = False
                if session_c:
                    import urllib.request as _uq
                    _cookie = f"session={session_c['value']}"
                    _req = _uq.Request(
                        'https://zefys.staatsbibliothek-berlin.de/search/?query=test&facet_name=DDR-Presse',
                        headers={'Cookie': _cookie, 'User-Agent': 'Mozilla/5.0'}
                    )
                    try:
                        with _uq.urlopen(_req, timeout=8) as _r:
                            _html = _r.read().decode('utf-8', errors='replace')
                        session_valid = 'Bitte loggen Sie sich ein' not in _html
                    except Exception:
                        session_valid = False
                self._json(200, {
                    'has_session': bool(session_c),
                    'cookie_count': len(zefys_cookies),
                    'saved_at': data.get('saved_at', ''),
                    'session_valid': session_valid,
                })
            except Exception as e:
                self._json(500, {'error': str(e)})
            return

        if parsed.path == '/api/zefys/harvest':
            try:
                import websocket as _ws_sync
                import urllib.request as _urlreq_z
                import json as _jz
                zefys_cdp_port = int(os.environ.get('ZEFYS_CDP_PORT', '9226'))
                # 1) Browser-level cookies
                with _urlreq_z.urlopen(f'http://localhost:{zefys_cdp_port}/json/version', timeout=5) as r:
                    cdp_info = _jz.loads(r.read())
                ws_url = cdp_info.get('webSocketDebuggerUrl', '')
                if not ws_url:
                    self._json(500, {'status': 'error', 'error': 'Kein webSocketDebuggerUrl'}); return
                ws = _ws_sync.create_connection(ws_url, timeout=5, suppress_origin=True)
                ws.send(_jz.dumps({'id': 1, 'method': 'Storage.getCookies', 'params': {}}))
                browser_cookies = _jz.loads(ws.recv()).get('result', {}).get('cookies', [])
                ws.close()
                # 2) Tab-level cookies — alle ZEFYS-Tabs abfragen
                tab_cookies = []
                with _urlreq_z.urlopen(f'http://localhost:{zefys_cdp_port}/json', timeout=5) as r:
                    tabs = _jz.loads(r.read())
                for tab in tabs:
                    if any(x in tab.get('url','').lower() for x in ['zefys','staatsbibliothek']):
                        tab_ws = tab.get('webSocketDebuggerUrl','')
                        if not tab_ws: continue
                        try:
                            tw = _ws_sync.create_connection(tab_ws, timeout=5, suppress_origin=True)
                            tw.send(_jz.dumps({'id':1,'method':'Network.getAllCookies'}))
                            tab_cookies += _jz.loads(tw.recv()).get('result',{}).get('cookies',[])
                            tw.close()
                        except Exception:
                            pass
                # 3) Merge — deduplizieren nach name+domain
                seen = set()
                all_cookies = []
                for c in browser_cookies + tab_cookies:
                    key = (c.get('name'), c.get('domain'))
                    if key not in seen:
                        seen.add(key)
                        all_cookies.append(c)
                zefys_domains = ['staatsbibliothek', 'zefys', 'xlogon']
                google_domains = ['google']
                zefys_cookies = [c for c in all_cookies if any(d in c.get('domain','') for d in zefys_domains)]
                google_cookies = [c for c in all_cookies if any(d in c.get('domain','') for d in google_domains)]
                from datetime import datetime as _dt_z
                session_data = {
                    'cookies': all_cookies,
                    'zefys_cookies': zefys_cookies,
                    'google_cookies': google_cookies,
                    'saved_at': _dt_z.now().isoformat(),
                }
                (COCKPIT_DIR / 'zefys_session.json').write_text(
                    _jz.dumps(session_data, indent=2, ensure_ascii=False)
                )
                self._json(200, {'status': 'ok', 'cookie_count': len(all_cookies), 'zefys_count': len(zefys_cookies)})
            except Exception as e:
                self._json(500, {'status': 'error', 'error': str(e)})
            return

        if parsed.path == '/api/zefys/scrape':
            try:
                script = Path(__file__).parent.parent / 'reisebericht' / 'fetch_zefys_ddr.py'
                subprocess.Popen(
                    ['python3', str(script)],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                self._json(200, {'status': 'started'})
            except Exception as e:
                self._json(500, {'status': 'error', 'error': str(e)})

        if parsed.path == '/api/oderberger/refresh':
            # Scrape new DDR-Presse pages + rebuild archive HTML
            try:
                reisebericht_dir = Path(__file__).parent.parent / 'reisebericht'
                log_file = Path('/tmp/oderberger_refresh.log')
                refresh_script = reisebericht_dir / '_oderberger_refresh.py'
                # Write refresh script inline
                refresh_code = '''#!/usr/bin/env python3
import subprocess, json, sys
from pathlib import Path

log = open('/tmp/oderberger_refresh.log', 'w', buffering=1)
def out(msg):
    print(msg, file=log, flush=True)
    print(msg, flush=True)

out('Starte Oderberger-Archiv-Refresh...')

# Step 1: Scrape ZEFYS (all pages)
scraper = Path(__file__).parent / 'scrape_zefys_pages20_129.py'
if scraper.exists():
    out('Scrappe ZEFYS DDR-Presse...')
    r = subprocess.run(['python3', '-u', str(scraper)], capture_output=True, text=True, timeout=600)
    out(r.stdout[-500:] if r.stdout else 'kein Output')
    if r.returncode != 0:
        out(f'FEHLER: {r.stderr[-200:]}')

# Step 2: Merge new articles
data_dir = Path(__file__).parent / 'data'
existing_file = data_dir / 'zefys_ddr_articles.json'
new_file = data_dir / 'zefys_ddr_pages20_129.json'
if new_file.exists():
    existing = json.loads(existing_file.read_text()) if existing_file.exists() else []
    new_arts = json.loads(new_file.read_text())
    seen = set()
    merged = []
    for a in existing + new_arts:
        key = (a.get('date',''), a.get('headline','')[:40])
        if key not in seen:
            seen.add(key)
            merged.append(a)
    merged.sort(key=lambda a: a.get('date',''))
    existing_file.write_text(json.dumps(merged, ensure_ascii=False, indent=2))
    out(f'Merged: {len(merged)} Artikel')

# Step 3: Rebuild archive HTML
builder = Path(__file__).parent / 'build_oderberger_archiv.py'
out('Baue Archiv-HTML...')
r = subprocess.run(['python3', str(builder)], capture_output=True, text=True, timeout=120)
out(r.stdout)
if r.returncode != 0:
    out(f'Bau-FEHLER: {r.stderr[-200:]}')
    sys.exit(1)
out('Fertig.')
'''
                refresh_script.write_text(refresh_code)
                subprocess.Popen(
                    ['python3', '-u', str(refresh_script)],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                self._json(200, {'status': 'started', 'log': '/tmp/oderberger_refresh.log'})
            except Exception as e:
                self._json(500, {'status': 'error', 'error': str(e)})
            return

        if parsed.path == '/api/oderberger/refresh_status':
            log_file = Path('/tmp/oderberger_refresh.log')
            log_content = log_file.read_text() if log_file.exists() else 'Kein Log vorhanden.'
            self._json(200, {'log': log_content[-2000:]})
            return
            return

        if parsed.path == '/api/transkript/audio':
            import re as _re_audio
            from urllib.parse import parse_qs as _pqs
            _params = _pqs(parsed.query)
            _which = _params.get('file', ['track2'])[0]
            _AUDIO_MAP = {
                'track2': Path('/Volumes/Hot Disk/Experten 2026 - Vorgespräche/Originale/Teams_2026-06-03_085824/EXTRACT_Track2_Systemton.wav'),
                'track3': Path('/Volumes/Hot Disk/Experten 2026 - Vorgespräche/Originale/Teams_2026-06-03_085824/EXTRACT_Track3_Mikrofon.wav'),
            }
            _af = _AUDIO_MAP.get(_which)
            if not _af or not _af.exists():
                self._json(404, {'error': 'Audio nicht gefunden'})
                return
            _fsize = _af.stat().st_size
            _range = self.headers.get('Range', '')
            if _range:
                _m = _re_audio.match(r'bytes=(\d*)-(\d*)', _range)
                _start = int(_m.group(1)) if _m and _m.group(1) else 0
                _end   = int(_m.group(2)) if _m and _m.group(2) else _fsize - 1
                _end   = min(_end, _fsize - 1)
                _length = _end - _start + 1
                self.send_response(206)
                self.send_header('Content-Type', 'audio/wav')
                self.send_header('Content-Range', f'bytes {_start}-{_end}/{_fsize}')
                self.send_header('Content-Length', _length)
                self.send_header('Accept-Ranges', 'bytes')
                self.send_header('Cache-Control', 'no-cache')
                self._cors()
                self.end_headers()
                with open(_af, 'rb') as _fh:
                    _fh.seek(_start)
                    _rem = _length
                    while _rem > 0:
                        _chunk = _fh.read(min(65536, _rem))
                        if not _chunk:
                            break
                        self.wfile.write(_chunk)
                        _rem -= len(_chunk)
            else:
                self.send_response(200)
                self.send_header('Content-Type', 'audio/wav')
                self.send_header('Content-Length', _fsize)
                self.send_header('Accept-Ranges', 'bytes')
                self.send_header('Cache-Control', 'no-cache')
                self._cors()
                self.end_headers()
                with open(_af, 'rb') as _fh:
                    while True:
                        _chunk = _fh.read(65536)
                        if not _chunk:
                            break
                        self.wfile.write(_chunk)
            return

        if parsed.path == '/api/transkript/load':
            _state_file = COCKPIT_DIR / 'transkript_state.json'
            if _state_file.exists():
                _data = _state_file.read_bytes()
                self.send_response(200)
                self.send_header('Content-Type', 'application/json')
                self.send_header('Content-Length', len(_data))
                self.send_header('Cache-Control', 'no-store')
                self._cors()
                self.end_headers()
                self.wfile.write(_data)
            else:
                self._json(200, {'ok': False})
            return

        super().do_GET()

    def _flaneur_token_ok(self):
        try:
            supplied = self.headers.get('X-Flaneur-Token', '').strip()
            if not supplied or not FLANEUR_TOKEN_FILE.exists():
                return False
            data = json.loads(FLANEUR_TOKEN_FILE.read_text(encoding='utf-8'))
            tokens = data.get('tokens', {})
            if isinstance(tokens, dict):
                return supplied in set(tokens.values())
            if isinstance(tokens, list):
                return supplied in tokens
        except Exception:
            return False
        return False

    def _json(self, code, data):
        body = json.dumps(data, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', len(body))
        self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
        self._cors()
        self.end_headers()
        self.wfile.write(body)

    def _cors(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-Audio-Ext, X-Vocabulary, X-Flaneur-Token')

    def log_message(self, fmt, *args):
        pass  # quiet logs


# ── WebSocket Voice T2 — Port 8094 ────────────────────────────────────
_WS_PORT = 8094

async def _voice_t2_ws_handler(websocket):
    """T2 Voice WebSocket — Server ist Wahrheit, Browser ist dumm."""
    import uuid as _uuid
    import asyncio as _asyncio
    import tempfile as _tf
    import aiohttp as _aiohttp
    from datetime import datetime as _dt
    from pathlib import Path as _Path

    session_id = _dt.now().strftime('%Y-%m-%d_%H-%M-%S') + '_' + str(_uuid.uuid4())[:4]
    archive_dir = _Path('/tmp/voice_archive')
    archive_dir.mkdir(exist_ok=True)
    wav_path = archive_dir / f'{session_id}.webm'
    transcript_path = _Path('/tmp/voice_transcripts') / f'transcript_{_dt.now().strftime("%Y-%m-%d")}.txt'
    transcript_path.parent.mkdir(exist_ok=True)

    chunk_buffer = []

    try:
        await websocket.send(json.dumps({'type': 'ready', 'session': session_id}))
    except Exception:
        return

    try:
        async for message in websocket:
            if isinstance(message, bytes):
                chunk = message
                if len(chunk) < 100:
                    continue

                # SCHRITT 1: Audio sofort auf Disk
                with open(wav_path, 'ab') as f:
                    f.write(chunk)
                chunk_buffer.append(chunk)

                try:
                    await websocket.send(json.dumps({'type': 'audio_saved', 'bytes': len(chunk)}))
                except Exception:
                    break

                # SCHRITT 2: Transkribieren
                try:
                    openai_key = _load_openai_key()
                    if not openai_key:
                        # Schlüsselbund-Fallback
                        result = subprocess.run(
                            ['security', 'find-generic-password', '-s', 'openai', '-w'],
                            capture_output=True, text=True, timeout=3
                        )
                        openai_key = result.stdout.strip()

                    if openai_key:
                        chunk_blob = b''.join(chunk_buffer[-3:])

                        with _tf.NamedTemporaryFile(suffix='.webm', delete=False) as tmp:
                            tmp.write(chunk_blob)
                            tmp_path = tmp.name

                        try:
                            data = _aiohttp.FormData()
                            data.add_field('file', open(tmp_path, 'rb'),
                                           filename='audio.webm', content_type='audio/webm')
                            data.add_field('model', 'gpt-4o-transcribe')
                            data.add_field('language', 'de')
                            data.add_field('response_format', 'text')

                            async with _aiohttp.ClientSession() as session:
                                async with session.post(
                                    'https://api.openai.com/v1/audio/transcriptions',
                                    headers={'Authorization': f'Bearer {openai_key}'},
                                    data=data,
                                    timeout=_aiohttp.ClientTimeout(total=30)
                                ) as resp:
                                    if resp.status == 200:
                                        text = (await resp.text()).strip()
                                        if text:
                                            # SCHRITT 3: Text sofort auf Disk
                                            with open(transcript_path, 'a', encoding='utf-8') as f:
                                                f.write(f'[{_dt.now().isoformat()}] {text}\n')
                                            # SCHRITT 4: Erst JETZT an Browser
                                            try:
                                                await websocket.send(json.dumps({
                                                    'type': 'transcript',
                                                    'text': text,
                                                    'saved': True
                                                }))
                                            except Exception:
                                                break
                        finally:
                            try:
                                os.unlink(tmp_path)
                            except Exception:
                                pass
                except Exception as e:
                    try:
                        await websocket.send(json.dumps({'type': 'error', 'msg': str(e)}))
                    except Exception:
                        break

            elif isinstance(message, str):
                try:
                    data = json.loads(message)
                    if data.get('type') == 'finalize':
                        chunk_buffer = []
                        try:
                            await websocket.send(json.dumps({
                                'type': 'finalized',
                                'session': session_id,
                                'audio': str(wav_path)
                            }))
                        except Exception:
                            break
                    elif data.get('type') == 'ping':
                        try:
                            await websocket.send(json.dumps({'type': 'pong'}))
                        except Exception:
                            break
                except Exception:
                    pass
    except Exception:
        pass


def _start_voice_t2_ws(ws_port, ssl_ctx=None):
    """Startet den WebSocket-Server in einem eigenen asyncio-Loop (Daemon-Thread)."""
    import asyncio as _asyncio
    try:
        import websockets as _ws_lib
    except ImportError:
        print('[cockpit] websockets nicht installiert — WS T2 inaktiv', flush=True)
        return

    async def _run():
        proto = 'wss' if ssl_ctx else 'ws'
        print(f'[cockpit] {proto}://0.0.0.0:{ws_port}/ws/voice_t2', flush=True)
        async with _ws_lib.serve(
            _voice_t2_ws_handler,
            '0.0.0.0',
            ws_port,
            ssl=ssl_ctx,
            max_size=10 * 1024 * 1024,  # 10 MB chunks erlaubt
            ping_interval=30,
            ping_timeout=10,
        ):
            await _asyncio.Future()  # läuft für immer

    loop = _asyncio.new_event_loop()
    loop.run_until_complete(_run())


# ── WebSocket Voice Realtime — Port 8097 ─────────────────────────────
_WS_RT_PORT = 8097


async def _voice_realtime_ws_handler(websocket):
    """Relay: Browser → lokaler WS → OpenAI Realtime API (Port 8097)."""
    import uuid as _uuid
    import json as _json
    import asyncio as _asyncio
    import base64 as _base64
    import subprocess as _sp
    from datetime import datetime as _dt
    from pathlib import Path as _Path

    session_id = _dt.now().strftime('%Y-%m-%d_%H-%M-%S') + '_rt_' + str(_uuid.uuid4())[:4]
    archive_dir = _Path('/tmp/voice_archive')
    archive_dir.mkdir(exist_ok=True)
    transcript_path = _Path('/tmp/voice_transcripts') / f'transcript_{_dt.now().strftime("%Y-%m-%d")}.txt'
    transcript_path.parent.mkdir(exist_ok=True)

    # OpenAI Key
    key = os.environ.get('OPENAI_API_KEY', '')
    if not key:
        r = _sp.run(['security', 'find-generic-password', '-s', 'openai', '-w'],
                    capture_output=True, text=True, timeout=5)
        key = r.stdout.strip()

    if not key:
        try:
            await websocket.send(_json.dumps({'type': 'error', 'msg': 'Kein OpenAI Key gefunden'}))
        except Exception:
            pass
        return

    try:
        await websocket.send(_json.dumps({'type': 'connecting', 'session': session_id}))
    except Exception:
        return

    try:
        import aiohttp as _aiohttp
        from aiohttp import WSMsgType as _WSMsgType

        rt_url = 'wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17'
        rt_headers = {
            'Authorization': f'Bearer {key}',
            'OpenAI-Beta': 'realtime=v1'
        }

        async with _aiohttp.ClientSession() as http_session:
            async with http_session.ws_connect(rt_url, headers=rt_headers) as ws_openai:
                try:
                    await websocket.send(_json.dumps({'type': 'ready', 'session': session_id}))
                except Exception:
                    return

                # Session konfigurieren
                await ws_openai.send_str(_json.dumps({
                    "type": "session.update",
                    "session": {
                        "modalities": ["text"],
                        "input_audio_format": "pcm16",
                        "input_audio_transcription": {
                            "model": "whisper-1"
                        },
                        "turn_detection": {
                            "type": "server_vad",
                            "threshold": 0.5,
                            "prefix_padding_ms": 300,
                            "silence_duration_ms": 500
                        }
                    }
                }))

                async def relay_from_openai():
                    """OpenAI → Browser"""
                    async for msg in ws_openai:
                        if msg.type == _WSMsgType.TEXT:
                            try:
                                data = _json.loads(msg.data)
                                t = data.get('type', '')

                                if t == 'conversation.item.input_audio_transcription.completed':
                                    text = data.get('transcript', '').strip()
                                    if text:
                                        with open(transcript_path, 'a', encoding='utf-8') as f:
                                            f.write(f'[{_dt.now().isoformat()}][RT] {text}\n')
                                        try:
                                            await websocket.send(_json.dumps({
                                                'type': 'transcript',
                                                'text': text,
                                                'saved': True
                                            }))
                                        except Exception:
                                            break
                                elif t == 'input_audio_buffer.speech_started':
                                    try:
                                        await websocket.send(_json.dumps({'type': 'speech_start'}))
                                    except Exception:
                                        break
                                elif t == 'input_audio_buffer.speech_stopped':
                                    try:
                                        await websocket.send(_json.dumps({'type': 'speech_stop'}))
                                    except Exception:
                                        break
                                elif t == 'error':
                                    try:
                                        await websocket.send(_json.dumps({
                                            'type': 'error',
                                            'msg': str(data.get('error', ''))
                                        }))
                                    except Exception:
                                        break
                            except Exception:
                                pass
                        elif msg.type in (_WSMsgType.ERROR, _WSMsgType.CLOSE):
                            break

                relay_task = _asyncio.create_task(relay_from_openai())

                # Browser → OpenAI
                try:
                    async for message in websocket:
                        if isinstance(message, bytes):
                            # PCM16 Audio: auf Disk sichern + an OpenAI
                            audio_path = archive_dir / f'{session_id}.pcm'
                            with open(audio_path, 'ab') as f:
                                f.write(message)

                            audio_b64 = _base64.b64encode(message).decode()
                            await ws_openai.send_str(_json.dumps({
                                "type": "input_audio_buffer.append",
                                "audio": audio_b64
                            }))
                        elif isinstance(message, str):
                            try:
                                data = _json.loads(message)
                                if data.get('type') == 'commit':
                                    await ws_openai.send_str(_json.dumps(
                                        {"type": "input_audio_buffer.commit"}
                                    ))
                                elif data.get('type') == 'ping':
                                    await websocket.send(_json.dumps({'type': 'pong'}))
                            except Exception:
                                pass
                except Exception:
                    pass
                finally:
                    relay_task.cancel()
                    try:
                        await relay_task
                    except _asyncio.CancelledError:
                        pass

    except Exception as e:
        try:
            await websocket.send(_json.dumps({'type': 'error', 'msg': f'Relay-Fehler: {str(e)}'}))
        except Exception:
            pass


def _start_voice_realtime_ws(ws_port, ssl_ctx=None):
    """Startet den Realtime-WebSocket-Server in eigenem asyncio-Loop (Daemon-Thread)."""
    import asyncio as _asyncio
    try:
        import websockets as _ws_lib
    except ImportError:
        print('[cockpit] websockets nicht installiert — WS Realtime inaktiv', flush=True)
        return

    async def _run():
        proto = 'wss' if ssl_ctx else 'ws'
        print(f'[cockpit] {proto}://0.0.0.0:{ws_port}/ws/voice_realtime', flush=True)
        async with _ws_lib.serve(
            _voice_realtime_ws_handler,
            '0.0.0.0',
            ws_port,
            ssl=ssl_ctx,
            max_size=10 * 1024 * 1024,
            ping_interval=20,
            ping_timeout=10,
        ):
            await _asyncio.Future()

    loop = _asyncio.new_event_loop()
    loop.run_until_complete(_run())


def _migrate_icloud_mp3s():
    """Kopiert lokal verfügbare MP3s von ~/Desktop/Reiseberichte nach RB_OUTPUT_DIR.
    SF_DATALESS-Dateien (iCloud-ausgelagert) werden übersprungen."""
    import stat as _stat_mod
    legacy = Path.home() / 'Desktop' / 'Reiseberichte'
    if not legacy.is_dir():
        return
    copied = 0
    for mp3 in legacy.glob('*.mp3'):
        dest = RB_OUTPUT_DIR / mp3.name
        if dest.exists():
            continue
        try:
            st = os.stat(str(mp3))
            if st.st_flags & 0x40000000:  # SF_DATALESS — noch in iCloud
                continue
            import shutil as _sh
            _sh.copy2(str(mp3), str(dest))
            # Sidecar mitkopieren
            sidecar = mp3.with_name(mp3.stem + '_quellen.json')
            if sidecar.exists():
                _sh.copy2(str(sidecar), str(RB_OUTPUT_DIR / sidecar.name))
            copied += 1
        except Exception:
            pass
    if copied:
        print(f'[reisebericht] {copied} Dateien aus iCloud-Desktop migriert', flush=True)

if __name__ == '__main__':
    os.chdir(COCKPIT_DIR)
    RB_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    threading.Thread(target=_migrate_icloud_mp3s, daemon=True, name='rb-migrate').start()
    class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
        daemon_threads = True
    server = ThreadingHTTPServer(('0.0.0.0', PORT), CockpitHandler)
    print(f'[cockpit] http://0.0.0.0:{PORT}', flush=True)

    # HTTPS auf Port 8090 — benötigt mkcert-Zertifikate in cockpit/
    _https_port = 8090
    # Zertifikat suchen — unterstützt mehrere Namenskonventionen
    _cert = next((p for p in [
        COCKPIT_DIR / '192.168.0.244+2.pem',
        COCKPIT_DIR / 'Minimac.local+1.pem',
        COCKPIT_DIR / '192.168.0.192+1.pem',
        COCKPIT_DIR / '192.168.0.192+2.pem',
    ] if p.exists()), None)
    _key = next((p for p in [
        COCKPIT_DIR / '192.168.0.244+2-key.pem',
        COCKPIT_DIR / 'Minimac.local+1-key.pem',
        COCKPIT_DIR / '192.168.0.192+1-key.pem',
        COCKPIT_DIR / '192.168.0.192+2-key.pem',
    ] if p.exists()), None)
    if _cert and _key:
        import ssl as _ssl
        ctx = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
        ctx.load_cert_chain(certfile=str(_cert), keyfile=str(_key))
        httpsd = ThreadingHTTPServer(('0.0.0.0', _https_port), CockpitHandler)
        httpsd.socket = ctx.wrap_socket(httpsd.socket, server_side=True)
        threading.Thread(target=httpsd.serve_forever, daemon=True, name='https').start()
        print(f'[cockpit] https://0.0.0.0:{_https_port}', flush=True)

        # WebSocket T2 auf Port 8094 (WSS — gleiche Zertifikate)
        _ws_ssl = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
        _ws_ssl.load_cert_chain(certfile=str(_cert), keyfile=str(_key))
        threading.Thread(
            target=_start_voice_t2_ws, args=(_WS_PORT, _ws_ssl),
            daemon=True, name='ws-voice-t2'
        ).start()
        # WebSocket Realtime auf Port 8095 (WSS — gleiche Zertifikate)
        _ws_rt_ssl = _ssl.SSLContext(_ssl.PROTOCOL_TLS_SERVER)
        _ws_rt_ssl.load_cert_chain(certfile=str(_cert), keyfile=str(_key))
        threading.Thread(
            target=_start_voice_realtime_ws, args=(_WS_RT_PORT, _ws_rt_ssl),
            daemon=True, name='ws-voice-realtime'
        ).start()
    else:
        print(f'[cockpit] Keine mkcert-Zertifikate — HTTPS-Port {_https_port} inaktiv', flush=True)
        # WebSocket T2 ohne SSL (nur LAN-Fallback)
        threading.Thread(
            target=_start_voice_t2_ws, args=(_WS_PORT, None),
            daemon=True, name='ws-voice-t2'
        ).start()
        # WebSocket Realtime ohne SSL (nur LAN-Fallback)
        threading.Thread(
            target=_start_voice_realtime_ws, args=(_WS_RT_PORT, None),
            daemon=True, name='ws-voice-realtime'
        ).start()

    server.serve_forever()
