/**
 * ChatScreen - Hauptchat-Oberflaeche
 *
 * Zeigt die Konversation mit ARIA, Texteingabe, Sprach-Button,
 * Datei- und Kamera-Upload.
 */

import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
  View,
  Text,
  TextInput,
  TouchableOpacity,
  FlatList,
  KeyboardAvoidingView,
  Platform,
  StyleSheet,
  Image,
  ScrollView,
  Modal,
  ToastAndroid,
  AppState,
  NativeModules,
  Alert,
  Pressable,
  Share,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import RNFS from 'react-native-fs';
import { SvgUri } from 'react-native-svg';
import { Dimensions } from 'react-native';
import ZoomableImage from '../components/ZoomableImage';
import MemoryDetailModal from '../components/MemoryDetailModal';
import MemoryBrowser from '../components/MemoryBrowser';
import ErrorBoundary from '../components/ErrorBoundary';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import phoneCallService from '../services/phoneCall';
import { playWakeReadySound } from '../services/wakeReadySound';
import {
  acquireBackgroundAudio,
  releaseBackgroundAudio,
} from '../services/backgroundAudio';
import updateService from '../services/updater';
import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';

// --- Typen ---

interface Attachment {
  type: 'image' | 'file' | 'audio';
  name: string;
  size?: number;
  uri?: string;         // Lokaler Pfad (file://) fuer Anzeige
  mimeType?: string;
  serverPath?: string;  // Pfad auf dem Server (/shared/uploads/...) fuer Re-Download
  deleted?: boolean;    // Datei wurde nachtraeglich geloescht (Diagnostic-Manager)
}

interface ChatMessage {
  id: string;
  sender: 'user' | 'aria';
  text: string;
  timestamp: number;
  attachments?: Attachment[];
  /** Projekt-Zuordnung — leer = Hauptchat. Wird genutzt um Bubbles zu
   *  Projekt-Bloecken zu gruppieren (auf/einklappbar). */
  projectId?: string;
  /** Bridge-Message-ID zur Zuordnung von TTS-Audio */
  messageId?: string;
  /** Lokaler Pfad zur gecachten TTS-Audio-Datei (file://...) */
  audioPath?: string;
  /** Korrelations-ID fuer Sprachnachrichten — wird mit dem STT-Result zurueck-
   *  gespiegelt damit wir die EXAKT richtige Placeholder-Bubble ersetzen,
   *  auch wenn mehrere Aufnahmen parallel offen sind. */
  audioRequestId?: string;
  /** Skill-Created-Bubble: ARIA hat einen neuen Skill angelegt */
  skillCreated?: {
    name: string;
    description: string;
    execution: string;
    active: boolean;
    setupError?: string;
  };
  /** Trigger-Created-Bubble: ARIA hat einen neuen Trigger angelegt */
  triggerCreated?: {
    name: string;
    type: 'timer' | 'watcher' | string;
    message: string;
    fires_at?: string;
    condition?: string;
  };
  /** Memory-Saved-Bubble: ARIA hat etwas via memory_save in die Qdrant-DB gepackt */
  memorySaved?: {
    id?: string;
    title: string;
    type: string;
    category?: string;
    pinned: boolean;
    preview?: string;
    /** Was passiert ist: angelegt / geaendert / geloescht. Default created
     * fuer Rueckwaerts-Kompatibilitaet mit aelteren Events. */
    action?: 'created' | 'updated' | 'deleted';
    attachments?: Array<{
      name: string;
      mime?: string;
      size?: number;
      path?: string;     // Server-Pfad /shared/memory-attachments/<id>/<name>
      localUri?: string; // Nach file_request gefuelltes file://-URI
    }>;
  };
  /** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge — Voraussetzung
   *  zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs
   *  sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
   *  wenn das chat_backup-Event vom Bridge zurueck kommt. */
  backupTs?: number;
  /** Client-seitige Eindeutigs-ID fuer Delivery-Tracking (offline-Queue,
   *  ACK von Bridge, Idempotenz bei Retry). Wird beim Senden generiert und
   *  durch die Bridge zurueck-gespiegelt. */
  clientMsgId?: string;
  /** Delivery-Status der User-Bubble (WhatsApp-style): queued = noch nicht
   *  raus (offline), sending = an Bridge unterwegs, sent = Bridge hat ACK
   *  gesendet, delivered = Brain hat geantwortet, failed = Retry-Limit. */
  deliveryStatus?: 'queued' | 'sending' | 'sent' | 'delivered' | 'failed';
  /** Anzahl der bisherigen Sende-Versuche (fuer Retry-Limit). */
  sendAttempts?: number;
}

/** Ein Eintrag im Gedanken-Stream — chronologisches Log dessen was ARIA
 *  intern macht (Brain-`agent_activity`-Events). Bleibt zwischen Denk-
 *  Phasen stehen, wird in AsyncStorage persistiert. */
interface ThoughtEntry {
  ts: number;
  /** Roh-Activity vom Brain: thinking, tool, assistant, idle (= ✓ fertig). */
  activity: string;
  /** Bei activity='tool' der Tool-Name, sonst leer. */
  tool?: string;
}

// --- Konstanten ---

const CHAT_STORAGE_KEY = 'aria_chat_messages';
const THOUGHT_STORAGE_KEY = 'aria_thought_stream';
const MAX_STORED_MESSAGES = 500;
const MAX_MEMORY_MESSAGES = 500;
const MAX_THOUGHTS = 500;

// Hilfe: Messages-Array auf Max kappen (aelteste raus) — verhindert OOM
// im Gespraechsmodus bei sehr vielen Nachrichten.
const capMessages = (msgs: ChatMessage[]): ChatMessage[] =>
  msgs.length > MAX_MEMORY_MESSAGES ? msgs.slice(-MAX_MEMORY_MESSAGES) : msgs;

// Bridge fuegt User-Texten Praefixe in eckigen Klammern hinzu damit Brain
// Kontext hat (GPS-Position, Barge-In-Hint etc.). Diese sollen nicht in der
// Bubble auftauchen — nur Brain sieht sie. Filtert alle aufeinanderfolgenden
// [...]-Bloecke am Textanfang weg, inkl. der Trennleerzeichen dahinter.
function stripSystemHints(text: string): string {
  if (!text) return text;
  let out = text;
  // Mehrere Hints koennen aneinanderhaengen — "[A] [B] Hallo" → "Hallo"
  while (true) {
    const m = out.match(/^\s*\[[^\]]*\]\s*/);
    if (!m) break;
    out = out.slice(m[0].length);
  }
  return out;
}
const DEFAULT_ATTACHMENT_DIR = `${RNFS.DocumentDirectoryPath}/chat_attachments`;
const STORAGE_PATH_KEY = 'aria_attachment_storage_path';

const { FileOpener } = NativeModules as {
  FileOpener?: { open: (filePath: string, mimeType: string) => Promise<boolean> };
};

/** Datei mit Android-Intent-Picker oeffnen (System waehlt App nach MIME). */
async function openFileWithIntent(filePath: string, mimeType: string): Promise<void> {
  if (!FileOpener) {
    ToastAndroid.show('FileOpener Native Module fehlt', ToastAndroid.SHORT);
    return;
  }
  try {
    await FileOpener.open(filePath, mimeType || 'application/octet-stream');
  } catch (err: any) {
    ToastAndroid.show(`Oeffnen fehlgeschlagen: ${err?.message || err}`, ToastAndroid.LONG);
  }
}

/** Image-Vorschau in der Chat-Bubble. Misst die echte Bild-Dimension via
 *  Image.getSize + setzt aspectRatio dynamisch — dadurch passt sich die
 *  Bubble ans Bild an (kein "Strich" mehr bei breiten oder hohen Bildern). */
const CHAT_IMAGE_STYLE = {
  width: 260,
  borderRadius: 8,
  marginBottom: 6,
  backgroundColor: '#0D0D1A',
} as const;
const ChatImage: React.FC<{
  uri: string;
  onPress: () => void;
  onError: () => void;
}> = ({ uri, onPress, onError }) => {
  const [aspectRatio, setAspectRatio] = useState<number>(4 / 3);
  const isSvg = /\.svg(?:\?|$)/i.test(uri);
  useEffect(() => {
    if (isSvg) return; // SvgUri hat kein getSize
    let cancelled = false;
    Image.getSize(uri, (w, h) => {
      if (!cancelled && w > 0 && h > 0) {
        // Aspect-Ratio capen damit sehr lange Panorama-Bilder oder hohe
        // Screenshot-Streifen die Bubble nicht sprengen
        const r = Math.max(0.5, Math.min(2.5, w / h));
        setAspectRatio(r);
      }
    }, () => {});
    return () => { cancelled = true; };
  }, [uri, isSvg]);
  if (isSvg) {
    return (
      <TouchableOpacity onPress={onPress} activeOpacity={0.8}>
        <View style={[CHAT_IMAGE_STYLE, { height: 260, alignItems: 'center', justifyContent: 'center' }]}>
          <SvgUri uri={uri} width="100%" height="100%" onError={onError} />
        </View>
      </TouchableOpacity>
    );
  }
  return (
    <TouchableOpacity onPress={onPress} activeOpacity={0.8}>
      <Image
        source={{ uri }}
        style={[CHAT_IMAGE_STYLE, { aspectRatio }]}
        resizeMode="cover"
        onError={onError}
      />
    </TouchableOpacity>
  );
};

async function getAttachmentDir(): Promise<string> {
  try {
    const saved = await AsyncStorage.getItem(STORAGE_PATH_KEY);
    return saved || DEFAULT_ATTACHMENT_DIR;
  } catch { return DEFAULT_ATTACHMENT_DIR; }
}

/** Speichert Base64-Daten als Datei, gibt file:// Pfad zurueck */
async function persistAttachment(base64Data: string, msgId: string, fileName: string): Promise<string> {
  const cacheDir = await getAttachmentDir();
  await RNFS.mkdir(cacheDir);
  // Dateiendung aus originalem Dateinamen oder Fallback
  const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
  const safeName = `${msgId}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
  const filePath = `${cacheDir}/${safeName}`;
  await RNFS.writeFile(filePath, base64Data, 'base64');
  return `file://${filePath}`;
}

/** Prueft ob eine lokale Datei noch existiert */
async function checkFileExists(uri: string): Promise<boolean> {
  if (!uri || !uri.startsWith('file://')) return false;
  return RNFS.exists(uri.replace('file://', ''));
}

// --- Komponente ---

const ChatScreen: React.FC = () => {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [inputText, setInputText] = useState('');
  const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
  const [showFileUpload, setShowFileUpload] = useState(false);
  const [showCameraUpload, setShowCameraUpload] = useState(false);
  const [gpsEnabled, setGpsEnabled] = useState(false);
  const [wakeWordActive, setWakeWordActive] = useState(false);
  // Genauer State (off/armed/conversing) fuer UI-Feedback am Button
  const [wakeWordState, setWakeWordState] = useState<'off' | 'armed' | 'conversing' | 'listening'>('off');
  const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
  const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
  const [inboxVisible, setInboxVisible] = useState(false);
  const [showJumpDown, setShowJumpDown] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');
  const [searchVisible, setSearchVisible] = useState(false);
  const [projectsVisible, setProjectsVisible] = useState(false);
  // Focus-One-View: welchen Chat sieht Stefan gerade?
  // Leer = Hauptchat, sonst die project_id. Multi-Threading:
  // Wechsel des Focus stoppt NICHT ARIAs Arbeit in anderen Projekten —
  // die laufen im Brain weiter, wir sehen sie hier nur nicht.
  const [focusedProjectId, setFocusedProjectId] = useState<string>('');
  // Lookup-Map id → Projekt (fuer Drawer + Referenzen)
  const [projectNameById, setProjectNameById] = useState<Record<string, string>>({});
  // Queue-Status pro Kontext — polled alle 2s, fuer Status-Dots im Drawer
  const [queueStatus, setQueueStatus] = useState<Record<string, { busy: boolean; queue_size: number }>>({});
  // Ref-Spiegel fuer Callbacks (interruptAriaIfBusy liest den aktuellen
  // Busy-Status des fokussierten Kontexts ohne stale Closure).
  const queueStatusRef = useRef<Record<string, { busy: boolean; queue_size: number }>>({});
  const [searchIndex, setSearchIndex] = useState(0);  // welcher Treffer aktiv ist
  const [pendingAttachments, setPendingAttachments] = useState<{file: any, isPhoto: boolean}[]>([]);
  const [agentActivity, setAgentActivity] = useState<{activity: string, tool: string}>({activity: 'idle', tool: ''});
  // Multi-Threading: Activity pro Kontext (key = projectId, '' = Hauptchat).
  // Der Indikator zeigt nur den fokussierten Kontext — nicht global.
  const [agentActivityByCtx, setAgentActivityByCtx] = useState<Record<string, {activity: string; tool: string}>>({});
  // Gedanken-Stream: chronologisches Log dessen was ARIA intern macht.
  // Wird aus agent_activity-Events gefuettert und in AsyncStorage persistiert.
  const [thoughts, setThoughts] = useState<ThoughtEntry[]>([]);
  const [thoughtsVisible, setThoughtsVisible] = useState(false);
  // Spiegel der letzten Activity in einer Ref — verhindert dass aufeinander-
  // folgende identische Events (z.B. zwei 'thinking' hintereinander) den
  // Stream zumuellen. Eigentlich seltener Fall, aber billig zu pruefen.
  const lastThoughtKeyRef = useRef<string>('');
  // Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
  const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string, downloading?: boolean, freshlyDownloaded?: boolean}>>({});
  const [serviceBannerDismissed, setServiceBannerDismissed] = useState(false);
  // Gerätelokale TTS-Config: globaler Toggle (aus Settings) + temporäres Muten (Mund-Button)
  const [ttsDeviceEnabled, setTtsDeviceEnabled] = useState(true);
  const [ttsMuted, setTtsMuted] = useState(false);
  // System-Hints in Bubble: Bridge fuegt User-Text Praefixe wie
  // "[Stefans aktuelle GPS-Position: ...]" oder "[Hinweis: Stefan hat
  // dich gerade unterbrochen...]" hinzu damit Brain Kontext hat. Die
  // App soll sie standardmaessig NICHT anzeigen — Stefan sieht sonst
  // jeden Hint mit. Toggle in Settings.
  const [showSystemHints, setShowSystemHints] = useState(false);
  // Gerätelokale XTTS-Voice-Wahl (bevorzugt gegenueber dem globalen Default)
  const localXttsVoiceRef = useRef<string>('');
  // Geraetelokale TTS-Wiedergabegeschwindigkeit (speed-Param an F5-TTS)
  const ttsSpeedRef = useRef<number>(TTS_SPEED_DEFAULT);
  // Spiegelung der TTS-Settings in einer Ref — damit die onMessage-Closure
  // (useEffect mit []-deps) IMMER die aktuellen Werte sieht. Ohne Ref
  // bliebe canPlay auf dem Mount-Initial-Wert haengen (mute ignoriert,
  // oder AsyncStorage-Load nicht beruecksichtigt).
  const ttsCanPlayRef = useRef<boolean>(true);

  const flatListRef = useRef<FlatList>(null);
  const messageIdCounter = useRef(0);
  // Spiegel der messages-Liste in einer Ref — Closures (z.B. dispatchWithAck-
  // Retry) brauchen Zugriff auf den aktuellen Status einer Bubble.
  const messagesRef = useRef<ChatMessage[]>([]);
  // Watchdog gegen "ARIA denkt"-Hang: wird bei jedem agent_activity-Event mit
  // nicht-idle Status neu armiert. Feuert er, sind 180s lang KEINE Updates
  // vom Brain mehr gekommen → wir gehen davon aus dass die Verbindung
  // verloren ist oder das Brain abgestuerzt — Timeout-Bubble + Reset.
  const stuckWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
  const clearStuckWatchdog = () => {
    if (stuckWatchdog.current) {
      clearTimeout(stuckWatchdog.current);
      stuckWatchdog.current = null;
    }
  };
  // ServerPaths fuer die der User auf "oeffnen" geklickt hat — beim
  // file_response wird die Datei nach dem Speichern direkt mit dem System-
  // Intent geoeffnet (PDF-Viewer, Galerie, etc.).
  const autoOpenPaths = useRef<Set<string>>(new Set());

  // Eindeutige Message-ID generieren
  const nextId = (): string => {
    messageIdCounter.current += 1;
    return `msg_${Date.now()}_${messageIdCounter.current}`;
  };

  // Eindeutige clientMsgId fuer Delivery-Tracking (Bridge-Echo, Retry,
  // Idempotenz). Format: cmsg_<ms>_<rand> — eindeutig genug fuer eine
  // 100er-Dedup-Window auf der Bridge.
  const nextClientMsgId = (): string =>
    `cmsg_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;

  // Wie lange wir auf das ACK warten bevor wir retryen. Bridge sollte
  // unmittelbar zurueckmelden — 30s ist grosszuegig fuer schlechte Netze.
  // 60s — grosszuegiger als 30s, weil langsame Brain-Calls (Multi-Tool) sonst
  // 90s × 3 Retries lang die User-Bubble auf ⏳ stehen lassen wuerden. Der
  // wichtige Pfad ist sowieso: agent_activity = thinking → markiert die
  // Bubble sofort als 'sent' (siehe handler). Das hier ist Fallback wenn
  // weder ACK noch agent_activity ankommt.
  const ACK_TIMEOUT_MS = 60_000;
  // Wie oft re-tryen wir bevor wir "failed" anzeigen.
  const MAX_SEND_ATTEMPTS = 3;
  // Pending ACK-Timer pro clientMsgId — fuer cancel beim ACK.
  const ackTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
  const clearAckTimer = (cmid: string) => {
    const t = ackTimers.current.get(cmid);
    if (t) {
      clearTimeout(t);
      ackTimers.current.delete(cmid);
    }
  };

  // Pending-Payloads pro clientMsgId — wir brauchen sie fuer Retry nach
  // ACK-Timeout oder nach Reconnect (offline-Queue). Liegt in einer Ref
  // damit der Inhalt Closures ueberlebt.
  const pendingPayloads = useRef<Map<string, { type: 'chat' | 'audio'; payload: Record<string, unknown> }>>(new Map());

  // ConnectionState in Ref spiegeln — fuer Closures (onMessage, Send-Pfade)
  // die sonst auf einen veralteten Wert zugreifen wuerden.
  const connectionStateRef = useRef<ConnectionState>('disconnected');

  // Status einer Bubble per clientMsgId aendern (Helper)
  const updateMessageStatus = useCallback(
    (cmid: string, patch: Partial<Pick<ChatMessage, 'deliveryStatus' | 'sendAttempts'>>) => {
      setMessages(prev => prev.map(m => (m.clientMsgId === cmid ? { ...m, ...patch } : m)));
    },
    [],
  );

  // Sende eine 'chat'- oder 'audio'-Nachricht an die Bridge mit ACK-Tracking.
  // - Wenn offline → status='queued', wird beim Reconnect rausgeschickt.
  // - Wenn online → status='sending', Timer fuer ACK-Erwartung.
  // - Bei ACK-Timeout: retry (bis MAX_SEND_ATTEMPTS) oder 'failed'.
  // - Wenn die Bubble inzwischen 'delivered' ist (z.B. ARIA hat geantwortet
  //   bevor das ACK durchkam) → komplett abbrechen, keinen Retry mehr.
  const dispatchWithAck = useCallback(
    (cmid: string, type: 'chat' | 'audio', payload: Record<string, unknown>, attempt = 1) => {
      // Schutz: wenn die Bubble inzwischen delivered ist, Retry-Loop stoppen
      // (kann bei verspaeteten ACKs oder manuellem Retry passieren wenn ARIA
      // schon laengst geantwortet hat).
      const current = messagesRef.current.find(m => m.clientMsgId === cmid);
      if (current?.deliveryStatus === 'delivered') {
        clearAckTimer(cmid);
        pendingPayloads.current.delete(cmid);
        return;
      }
      pendingPayloads.current.set(cmid, { type, payload });
      const online = connectionStateRef.current === 'connected';
      if (!online) {
        updateMessageStatus(cmid, { deliveryStatus: 'queued', sendAttempts: attempt });
        return;
      }
      // RVS.send mit clientMsgId — Bridge spiegelt das im chat_ack zurueck
      rvs.send(type, { ...payload, clientMsgId: cmid });
      updateMessageStatus(cmid, { deliveryStatus: 'sending', sendAttempts: attempt });
      clearAckTimer(cmid);
      ackTimers.current.set(
        cmid,
        setTimeout(() => {
          ackTimers.current.delete(cmid);
          // Vor dem Retry erneut pruefen ob die Bubble nicht inzwischen
          // delivered wurde — sonst spawnen wir endlose Retries.
          const fresh = messagesRef.current.find(m => m.clientMsgId === cmid);
          if (fresh?.deliveryStatus === 'delivered') {
            pendingPayloads.current.delete(cmid);
            return;
          }
          if (attempt >= MAX_SEND_ATTEMPTS) {
            updateMessageStatus(cmid, { deliveryStatus: 'failed', sendAttempts: attempt });
            console.warn('[Chat] Send fehlgeschlagen nach %d Versuchen: %s', attempt, cmid);
          } else {
            console.warn('[Chat] kein ACK fuer %s — Retry #%d', cmid, attempt + 1);
            dispatchWithAck(cmid, type, payload, attempt + 1);
          }
        }, ACK_TIMEOUT_MS),
      );
    },
    [updateMessageStatus],
  );

  // Alle 'queued'-Nachrichten beim Reconnect rausschicken
  const flushQueuedMessages = useCallback(() => {
    setMessages(prev => {
      for (const m of prev) {
        if (m.deliveryStatus !== 'queued' || !m.clientMsgId) continue;
        const pending = pendingPayloads.current.get(m.clientMsgId);
        if (!pending) continue;
        // Versuchszaehler beibehalten (oder mit 1 starten falls leer)
        dispatchWithAck(m.clientMsgId, pending.type, pending.payload, m.sendAttempts || 1);
      }
      return prev;
    });
  }, [dispatchWithAck]);

  // Manueller Retry nach 'failed' (tap auf das ⚠️-Icon)
  const retryFailedMessage = useCallback((cmid: string) => {
    const pending = pendingPayloads.current.get(cmid);
    if (!pending) return;
    dispatchWithAck(cmid, pending.type, pending.payload, 1);
  }, [dispatchWithAck]);

  // TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
  // Projekt-Namen laden (Lookup-Map) + focusedProjectId aus AsyncStorage
  // wiederherstellen (Default = Hauptchat wenn nichts gespeichert). Der
  // Brain hat mit Multi-Threading keinen global-aktiven Projekt-State mehr;
  // Focus ist reine App-lokale UI-Info.
  useEffect(() => {
    const loadNames = () => {
      brainApi.listProjects(true)
        .then(list => {
          const map: Record<string, string> = {};
          for (const p of list) map[p.id] = p.name;
          setProjectNameById(prev => ({ ...prev, ...map }));
        })
        .catch(() => {});
    };
    loadNames();
    // Letzten Focus aus Storage restoren
    AsyncStorage.getItem('aria_focused_project_id').then(v => {
      if (v && typeof v === 'string') setFocusedProjectId(v);
    }).catch(() => {});
    const unsub = rvs.onStateChange(state => { if (state === 'connected') loadNames(); });
    return () => unsub();
  }, []);

  // Focus in Storage spiegeln damit der letzte Kontext nach Neustart wieder
  // da ist. Kein zwingender UX-Fix (Default = Hauptchat waere auch ok), aber
  // fuer den Auto-Fall angenehm.
  useEffect(() => {
    AsyncStorage.setItem('aria_focused_project_id', focusedProjectId).catch(() => {});
    focusedProjectIdRef.current = focusedProjectId;
  }, [focusedProjectId]);

  // Ref-Spiegel damit useCallback-Handler die aktuelle Focus-ID lesen
  // ohne dass wir die Deps in jedes Callback muessen (sonst re-createn
  // die sich bei jedem Wechsel).
  const focusedProjectIdRef = useRef<string>('');

  // Queue-Status alle 2s pollen — fuers Status-Dot im Focus-Header und
  // fuer die Drawer-Anzeige. Nur wenn RVS verbunden ist (sonst 30s Timeout).
  useEffect(() => {
    let cancelled = false;
    const poll = async () => {
      if (rvs.getState() !== 'connected') return;
      try {
        const s = await brainApi.getProjectQueueStatus();
        if (cancelled) return;
        setQueueStatus(s.contexts || {});
        queueStatusRef.current = s.contexts || {};
      } catch {}
    };
    poll();
    const iv = setInterval(poll, 2000);
    return () => { cancelled = true; clearInterval(iv); };
  }, []);

  useEffect(() => {
    const loadSettings = async () => {
      const enabled = await AsyncStorage.getItem('aria_tts_enabled');
      setTtsDeviceEnabled(enabled !== 'false'); // default true
      const muted = await AsyncStorage.getItem('aria_tts_muted');
      const isMuted = muted === 'true';
      setTtsMuted(isMuted); // default false
      audioService.setMuted(isMuted);  // service-internen Flag synchronisieren
      const voice = await AsyncStorage.getItem('aria_xtts_voice');
      localXttsVoiceRef.current = voice || '';
      ttsSpeedRef.current = await loadTtsSpeed();
      const gps = await AsyncStorage.getItem('aria_gps_enabled');
      setGpsEnabled(gps === 'true');
      const hints = await AsyncStorage.getItem('aria_show_hints');
      setShowSystemHints(hints === 'true'); // default false
    };
    loadSettings();
    const interval = setInterval(loadSettings, 2000);
    return () => clearInterval(interval);
  }, []);

  // Wake Word: einmalig laden + Porcupine vorbereiten (wenn Access Key gesetzt)
  useEffect(() => {
    wakeWordService.loadFromStorage().catch(() => {});
    const unsub = wakeWordService.onStateChange((s) => {
      setWakeWordState(s);
      setWakeWordActive(s !== 'off');
      // Conversation-Focus an Wake-Word-State koppeln: solange wir aktiv im
      // Dialog sind, soll Spotify dauerhaft gepaust bleiben (auch ueber
      // Render-Pausen + zwischen Antworten hinweg). Sobald wir zurueck nach
      // 'armed' oder 'off' fallen, darf Spotify wieder. 'listening' soll
      // Spotify ebenfalls leise halten (User darf jederzeit weitersprechen).
      if (s === 'conversing' || s === 'listening') audioService.acquireConversationFocus();
      else audioService.releaseConversationFocus();
      // Beim Verlassen von 'listening' (Timer abgelaufen) eine ggf. noch
      // laufende passive Streaming-Aufnahme killen, sonst hat OpenWakeWord
      // keinen Zugriff aufs Mic beim Re-Arm.
      if ((s === 'armed' || s === 'off') && audioService.isStreamingRecording()) {
        audioService.cancelStreamingRecording('wakeword-state-' + s);
      }
      // Foreground-Service-Slot 'wake' — solange das Ohr ueberhaupt aktiv ist
      // (armed oder conversing), soll der App-Prozess im Hintergrund am Leben
      // bleiben damit Mikro-Lauschen + Aufnahme weiterlaufen.
      if (s !== 'off') acquireBackgroundAudio('wake').catch(() => {});
      else releaseBackgroundAudio('wake').catch(() => {});
    });
    return () => unsub();
  }, []);

  // Anruf-Erkennung: TTS pausieren wenn das Telefon klingelt
  useEffect(() => {
    phoneCallService.start().catch(err =>
      console.warn('[Chat] phoneCall.start fehlgeschlagen', err));
    return () => { phoneCallService.stop().catch(() => {}); };
  }, []);

  // App-Resume: drei Schutzmaßnahmen gegen verirrte Wake-Word-Trigger
  // beim Wechsel Background→Foreground:
  //   (a) Cooldown 3s — Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack
  //       re-route) sollen openWakeWord nicht faelschlich triggern
  //   (b) Wenn die App laenger im Hintergrund war und in 'conversing'
  //       zurueckkommt: vermutlich false-positive durch ein Hintergrund-
  //       Geraeusch (TV, Husten etc.) waehrend Stefan gar nicht da war.
  //       Wir verwerfen den Trigger und gehen zurueck zu 'armed'.
  //   (c) Aktuelle Aufnahme abbrechen falls sie aus dem false-positive
  //       gerade gestartet wurde.
  useEffect(() => {
    let lastState: string = AppState.currentState;
    let lastBackgroundAt = 0;
    const sub = AppState.addEventListener('change', (next) => {
      if (next === 'background' || next === 'inactive') {
        lastBackgroundAt = Date.now();
        wakeWordService.setBackground();
      } else if (lastState !== 'active' && next === 'active') {
        wakeWordService.setForeground();
        const bgDur = lastBackgroundAt > 0 ? Date.now() - lastBackgroundAt : 0;
        // Bei laengerer Hintergrund-Zeit (>30s): pruefen ob ein frisches
        // Wake-Word getriggert wurde wahrend die App weg war — wenn ja,
        // verwerfen + laufende Aufnahme stoppen.
        if (bgDur > 30_000) {
          wakeWordService.discardIfFreshlyTriggered(15_000).then(discarded => {
            if (discarded) {
              // Sowohl legacy als auch Streaming-Pfad abdecken
              try {
                if (audioService.isStreamingRecording()) {
                  audioService.cancelStreamingRecording('wake-discarded');
                } else {
                  audioService.cancelRecording();
                }
              } catch {}
            }
          }).catch(() => {});
        }
        // PhoneCall-Listener pruefen: kann passieren dass der nach laengerer
        // Hintergrund-Zeit verloren geht (Bridge-Context recreated). Refresh
        // versucht ihn neu zu attachen falls noetig — sonst kriegt die App
        // bei display-aus / minimized keine Anruf-Events mit.
        phoneCallService.refresh().catch(() => {});
      }
      lastState = next;
    });
    return () => sub.remove();
  }, []);

  // Recording-State an Background-Service-Slot 'rec' koppeln — damit das Mikro
  // auch im Hintergrund weiter aufnehmen darf (Android killt den App-Prozess
  // sonst und die Aufnahme bricht ab).
  useEffect(() => {
    const unsub = audioService.onStateChange((s) => {
      if (s === 'recording') acquireBackgroundAudio('rec').catch(() => {});
      else releaseBackgroundAudio('rec').catch(() => {});
    });
    return () => unsub();
  }, []);

  // ttsCanPlayRef live aktuell halten — Closure in onMessage unten liest
  // darueber statt direkt ttsDeviceEnabled/ttsMuted (sonst stale).
  useEffect(() => {
    ttsCanPlayRef.current = ttsDeviceEnabled && !ttsMuted;
  }, [ttsDeviceEnabled, ttsMuted]);

  const toggleMute = useCallback(() => {
    setTtsMuted(prev => {
      const next = !prev;
      AsyncStorage.setItem('aria_tts_muted', String(next));
      // Ref synchron updaten — sonst kommen noch Chunks im selben Tick
      // mit canPlay=true durch (Race vor dem useEffect-Update).
      ttsCanPlayRef.current = ttsDeviceEnabled && !next;
      // Globalen Mute-Flag im audioService setzen — uebersteuert auch
      // payload.silent in handlePcmChunk und stoppt laufende Wiedergabe.
      audioService.setMuted(next);
      return next;
    });
  }, [ttsDeviceEnabled]);

  // Chat-Verlauf aus AsyncStorage laden
  const isInitialLoad = useRef(true);
  useEffect(() => {
    const loadMessages = async () => {
      try {
        const stored = await AsyncStorage.getItem(CHAT_STORAGE_KEY);
        console.log('[Chat] AsyncStorage geladen:', stored ? `${stored.length} Bytes` : 'leer');
        if (stored) {
          const parsed: ChatMessage[] = JSON.parse(stored);
          if (Array.isArray(parsed) && parsed.length > 0) {
            console.log('[Chat] ${parsed.length} Nachrichten geladen');
            // MERGE statt Overwrite: zwischen Mount und Load-Done koennen
            // bereits Nachrichten ankommen (User schreibt sofort, WS-Events
            // kommen vor Load-Ende). Vorher hat setMessages(parsed) diese
            // ueberschrieben → "Nachricht weg ohne Spur". Jetzt mergen wir
            // per id; lokal-gerade-hinzugefuegte schlagen Gespeichertes
            // (die sind frischer).
            setMessages(prev => {
              if (prev.length === 0) return parsed;
              const byId = new Map<string, ChatMessage>();
              for (const m of parsed) byId.set(m.id, m);
              for (const m of prev) byId.set(m.id, m);
              return [...byId.values()].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
            });
            const maxId = parsed.reduce((max, msg) => {
              const num = parseInt(msg.id.split('_').pop() || '0', 10);
              return num > max ? num : max;
            }, 0);
            messageIdCounter.current = Math.max(messageIdCounter.current, maxId);
          }
        }
      } catch (err) {
        console.error('[Chat] Fehler beim Laden des Verlaufs:', err);
      } finally {
        isInitialLoad.current = false;
      }
    };
    loadMessages().then(async () => {
      // Auto-Re-Download: fehlende Anhänge vom Server nachladen (wenn aktiviert)
      const autoDownload = await AsyncStorage.getItem('aria_auto_download');
      if (autoDownload === 'false') return;
      setTimeout(() => {
        setMessages(prev => {
          const missing: {id: string, serverPath: string}[] = [];
          for (const msg of prev) {
            for (const att of msg.attachments || []) {
              if (att.serverPath && !att.uri) {
                missing.push({ id: msg.id, serverPath: att.serverPath });
              }
            }
          }
          if (missing.length > 0) {
            console.log(`[Chat] ${missing.length} fehlende Anhaenge — lade nach...`);
            for (const m of missing) {
              rvs.send('file_request' as any, { serverPath: m.serverPath, requestId: m.id });
            }
          }
          return prev;
        });
      }, 2000); // Warten bis RVS verbunden ist
    });
  }, []);

  // RVS-Nachrichten abonnieren
  useEffect(() => {
    const unsubMessage = rvs.onMessage((message: RVSMessage) => {
      // chat_ack: Bridge bestaetigt Empfang einer chat/audio-Nachricht.
      // Wir markieren die Bubble als 'sent' (✓) und stoppen den ACK-Timer.
      if (message.type === ('chat_ack' as any)) {
        const cmid = (message.payload as any).clientMsgId as string | undefined;
        if (cmid) {
          clearAckTimer(cmid);
          pendingPayloads.current.delete(cmid);
          setMessages(prev => prev.map(m =>
            m.clientMsgId === cmid && m.deliveryStatus !== 'delivered'
              ? { ...m, deliveryStatus: 'sent' }
              : m
          ));
        }
        return;
      }

      // file_saved: Bridge meldet Server-Pfad — in Attachment merken fuer Re-Download
      if (message.type === 'file_saved') {
        const serverPath = (message.payload.serverPath as string) || '';
        const name = (message.payload.name as string) || '';
        if (serverPath) {
          setMessages(prev => prev.map(m => ({
            ...m,
            attachments: m.attachments?.map(a =>
              a.name === name && !a.serverPath ? { ...a, serverPath } : a
            ),
          })));
        }
        return;
      }

      // skill_created: ARIA hat einen neuen Skill angelegt → eigene Bubble
      // chat_cleared: Diagnostic hat die History komplett geleert
      // → lokal auch loeschen (visuell + Persistenz)
      if (message.type === 'chat_cleared') {
        console.log('[Chat] chat_cleared — leere lokale Anzeige + Storage');
        setMessages([]);
        AsyncStorage.removeItem(CHAT_STORAGE_KEY).catch(() => {});
        AsyncStorage.removeItem('aria_chat_last_sync').catch(() => {});
        return;
      }

      // chat_message_deleted: Bridge hat eine Bubble aus chat_backup + Brain
      // entfernt. Wir loeschen sie lokal per backupTs-Match.
      if (message.type === 'chat_message_deleted') {
        const ts = (message.payload || {}).ts;
        if (typeof ts !== 'number') return;
        console.log(`[Chat] chat_message_deleted ts=${ts}`);
        setMessages(prev => prev.filter(m => m.backupTs !== ts));
        return;
      }

      // chat_history_response: kompletter Server-Stand. App ersetzt ihre
      // persistierte Chat-History damit. Lokal-only Bubbles (laufende
      // Voice-Aufnahmen ohne STT-Result, Skill-Created-Events ohne
      // text) bleiben erhalten — die sind durch fehlendes 'text' oder
      // skillCreated/audioRequestId klar als "lokal" erkennbar.
      if (message.type === 'chat_history_response') {
        const p = (message.payload || {}) as any;
        const incoming = (p.messages || []) as Array<any>;
        console.log(`[Chat] Server-Sync: ${incoming.length} Nachrichten vom Server`);
        const fromServer: ChatMessage[] = incoming.map(m => {
          const role = m.role === 'user' ? 'user' : 'aria';
          const files = Array.isArray(m.files) ? m.files : [];
          const attachments = files.map((f: any) => ({
            type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
            name: f.name || 'datei',
            size: f.size || 0,
            mimeType: f.mimeType || '',
            serverPath: f.serverPath || '',
          })) as Attachment[];
          // clientMsgId weiterreichen — Bridge spiegelt sie im chat_backup,
          // damit wir lokale Bubbles per ID dedupen koennen statt nur per
          // Text/Timestamp-Heuristik.
          const cmid = typeof m.clientMsgId === 'string' ? m.clientMsgId : undefined;
          return {
            id: nextId(),
            sender: role as 'user' | 'aria',
            text: m.text || '',
            timestamp: m.ts || Date.now(),
            attachments: attachments.length ? attachments : undefined,
            backupTs: typeof m.ts === 'number' ? m.ts : undefined,
            projectId: typeof m.project_id === 'string' ? m.project_id : '',
            ...(cmid && { clientMsgId: cmid }),
            // Server-Bubble = vom Brain verarbeitet → 'delivered' (✓✓)
            ...(role === 'user' && cmid && { deliveryStatus: 'delivered' as const }),
          };
        });
        const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
        setMessages(prev => {
          // ClientMsgIds die der Server kennt — lokale Bubbles mit der
          // gleichen ID werden durch die Server-Version ersetzt.
          const serverCmids = new Set(
            fromServer.map(s => s.clientMsgId).filter((x): x is string => !!x)
          );
          // Lokal-only Bubbles erkennen + behalten:
          //   - Skill-Created-Notifications (skillCreated gesetzt)
          //   - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
          //     gesetzt UND text leer/Placeholder)
          //   - User-Bubbles deren clientMsgId der Server noch nicht kennt:
          //     z.B. waehrend Reconnect-Race oder solange flushQueuedMessages
          //     noch laeuft. ABER: wenn der Server eine textgleiche User-
          //     Bubble hat (egal mit welcher cmid oder ohne — z.B. wenn die
          //     Bubble aus einer Bridge-Version vor dem clientMsgId-Patch
          //     stammt oder wenn die ts kaputt sind), werten wir das als
          //     Treffer und verwerfen die lokale Kopie. Sonst Doppelpost:
          //     einmal als Server-Bubble (delivered) und einmal als lokale
          //     failed/queued mit Retry-Knopf.
          const serverUserTexts = new Set(
            fromServer.filter(s => s.sender === 'user').map(s => s.text || '')
          );
          const localOnly = prev.filter(m => {
            if (m.skillCreated || m.triggerCreated || m.memorySaved) return true;
            if (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) return true;
            if (m.sender === 'user' && m.clientMsgId && !serverCmids.has(m.clientMsgId)) {
              // Text-Match-Fallback: wenn der Server irgendwo eine textgleiche
              // User-Bubble hat, ist es dieselbe Nachricht (vor cmid-Aera, ts
              // kaputt etc.) — wir verwerfen die lokale Kopie. Leerer Text
              // (z.B. nur Anhang) faellt nicht in den Vergleich.
              const text = m.text || '';
              if (text && serverUserTexts.has(text)) return false;
              return true;
            }
            return false;
          });
          // Server-Stand + lokal-only (chronologisch sortiert)
          const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp);
          return capMessages(merged);
        });
        if (maxTs > 0) {
          AsyncStorage.setItem('aria_chat_last_sync', String(maxTs)).catch(() => {});
        } else {
          // Server leer → unsere lastSync auch zuruecksetzen
          AsyncStorage.removeItem('aria_chat_last_sync').catch(() => {});
        }
        return;
      }

      // project_changed: ARIA hat via Tool ein Projekt erstellt/betreten/exited/beendet.
      // App entscheidet ob sie den Focus wechselt basierend auf action + payload.
      if (message.type === 'project_changed') {
        const p: any = message.payload || {};
        const action = p.action || '';
        // Neuer Projekt-Name in Lookup-Map merken
        if (p.id && p.name) {
          setProjectNameById(prev => ({ ...prev, [p.id]: p.name }));
        }
        if (action === 'entered' || action === 'created') {
          if (p.id) setFocusedProjectId(p.id);
        } else if (action === 'exited') {
          setFocusedProjectId('');
        }
        return;
      }

      if (message.type === 'skill_created') {
        const p = (message.payload || {}) as any;
        const skillMsg: ChatMessage = {
          id: nextId(),
          sender: 'aria',
          text: '',
          timestamp: Date.now(),
          skillCreated: {
            name: String(p.name || '(unbenannt)'),
            description: String(p.description || ''),
            execution: String(p.execution || 'bash'),
            active: p.active !== false,
            setupError: p.setup_error ? String(p.setup_error) : undefined,
          },
        };
        setMessages(prev => capMessages([...prev, skillMsg]));
        return;
      }

      // trigger_created: ARIA hat einen Trigger angelegt → eigene Bubble
      if (message.type === 'trigger_created') {
        const p = (message.payload || {}) as any;
        const triggerMsg: ChatMessage = {
          id: nextId(),
          sender: 'aria',
          text: '',
          timestamp: Date.now(),
          triggerCreated: {
            name: String(p.name || '(unbenannt)'),
            type: String(p.type || 'timer'),
            message: String(p.message || ''),
            fires_at: p.fires_at ? String(p.fires_at) : undefined,
            condition: p.condition ? String(p.condition) : undefined,
          },
        };
        setMessages(prev => capMessages([...prev, triggerMsg]));
        return;
      }

      // memory_saved: ARIA hat etwas via memory_save Tool in die Qdrant-DB
      // gepackt — eigene Bubble (gelb wie trigger/skill).
      if (message.type === 'memory_saved') {
        const p = (message.payload || {}) as any;
        const atts = Array.isArray(p.attachments) ? p.attachments.map((a: any) => ({
          name: String(a?.name || 'datei'),
          mime: a?.mime ? String(a.mime) : undefined,
          size: typeof a?.size === 'number' ? a.size : undefined,
          path: a?.path ? String(a.path) : undefined,
        })) : [];
        const memoryMsg: ChatMessage = {
          id: nextId(),
          sender: 'aria',
          text: '',
          timestamp: Date.now(),
          memorySaved: {
            id: p.id ? String(p.id) : undefined,
            title: String(p.title || '(ohne Titel)'),
            type: String(p.type || 'fact'),
            category: p.category ? String(p.category) : undefined,
            pinned: !!p.pinned,
            preview: p.content_preview ? String(p.content_preview) : undefined,
            action: (p.action === 'updated' || p.action === 'deleted') ? p.action : 'created',
            attachments: atts.length ? atts : undefined,
          },
        };
        setMessages(prev => capMessages([...prev, memoryMsg]));
        return;
      }

      // file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
      if (message.type === 'file_deleted') {
        const p = (message.payload?.path as string) || '';
        if (!p) return;
        setMessages(prev => prev.map(m => ({
          ...m,
          attachments: m.attachments?.map(a =>
            a.serverPath === p ? { ...a, deleted: true } : a
          ),
        })));
        return;
      }

      // file_list_response: wird vom Datei-Manager im SettingsScreen verarbeitet.

      // file_from_aria: ARIA hat eine Datei rausgegeben → als ARIA-Bubble anzeigen
      if (message.type === 'file_from_aria') {
        const p = message.payload || {};
        const ariaMsg: ChatMessage = {
          id: nextId(),
          sender: 'aria',
          text: '',
          timestamp: Date.now(),
          attachments: [{
            type: (typeof p.mimeType === 'string' && p.mimeType.startsWith('image/')) ? 'image' : 'file',
            name: (p.name as string) || 'datei',
            size: (p.size as number) || 0,
            mimeType: (p.mimeType as string) || '',
            serverPath: (p.serverPath as string) || '',
          }],
        };
        setMessages(prev => capMessages([...prev, ariaMsg]));
        return;
      }

      // file_response: Re-Download von Server — lokal speichern
      if (message.type === 'file_response') {
        const reqId = (message.payload.requestId as string) || '';
        const b64 = (message.payload.base64 as string) || '';
        const serverPath = (message.payload.serverPath as string) || '';
        const mimeType = (message.payload.mimeType as string) || '';
        // Fehler-Response (z.B. Datei zu gross, nicht gefunden) → Toast,
        // kein erneuter Versuch. Hauptverdacht: 40+ MB Videos die ueber
        // den 70 MB Bridge-Limit gehen.
        const fileErr = (message.payload as any).error as string | undefined;
        if (fileErr) {
          const fname = (message.payload.name as string) || serverPath.split('/').pop() || 'Datei';
          console.warn('[Chat] file_response Fehler fuer %s: %s', fname, fileErr);
          ToastAndroid.show(`${fname}: ${fileErr}`, ToastAndroid.LONG);
          return;
        }
        if (b64 && reqId) {
          const fileName = (message.payload.name as string) || 'download';
          persistAttachment(b64, reqId, fileName).then(filePath => {
            setMessages(prev => prev.map(m => {
              // Hauptattachments updaten (Bilder/Files am User-Send / ARIA-File-Bubble)
              const updatedAtts = m.attachments?.map(a =>
                a.serverPath === serverPath ? { ...a, uri: filePath } : a
              );
              // Memory-Anhang-Match (Bubble vom memory_saved-Event)
              const ms = m.memorySaved;
              let updatedMs = ms;
              if (ms && Array.isArray(ms.attachments)) {
                const hit = ms.attachments.some(a => a.path === serverPath);
                if (hit) {
                  updatedMs = {
                    ...ms,
                    attachments: ms.attachments.map(a =>
                      a.path === serverPath ? { ...a, localUri: filePath } : a
                    ),
                  };
                }
              }
              return { ...m, attachments: updatedAtts, memorySaved: updatedMs };
            }));
            // Wenn der User dieses File explizit oeffnen wollte → Intent-Picker
            // (Bilder werden separat via setFullscreenImage in der memorySaved-
            // Bubble geoeffnet, das laeuft nicht ueber autoOpenPaths)
            if (serverPath && autoOpenPaths.current.has(serverPath)) {
              autoOpenPaths.current.delete(serverPath);
              const isImage = (mimeType || '').startsWith('image/');
              if (isImage) {
                setFullscreenImage(filePath);
              } else {
                openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
              }
            }
          }).catch(() => {});
        }
        return;
      }

      if (message.type === 'chat') {
        const sender = (message.payload.sender as string) || '';
        const dbgText = ((message.payload.text as string) || '').slice(0, 60);
        console.log('[Chat] chat-event sender=%s text=%s', sender || '(none)', dbgText);

        // last-sync tracken — so dass beim Reconnect nicht wieder dieselbe
        // Nachricht aus dem Server-Backup nachgeladen wird
        if (sender === 'aria' || sender === 'user' || sender === 'stt') {
          const ts = message.timestamp || Date.now();
          AsyncStorage.setItem('aria_chat_last_sync', String(ts)).catch(() => {});
        }

        // STT-Ergebnis: Transkribierten Text in die Sprach-Bubble schreiben.
        // WICHTIG: Nur die ERSTE noch unaufgeloeste Aufnahme matchen — sonst
        // wuerde bei zwei kurz hintereinander gesendeten Audios beide Bubbles
        // den gleichen Text bekommen (Bug: zweite Antwort ueberschreibt erste).
        if (sender === 'stt') {
          const sttText = (message.payload.text as string) || '';
          const sttAudioReqId = (message.payload.audioRequestId as string) || '';
          // Autoritative Projekt-Zuordnung vom Server (Voice-Router). Die App
          // hatte die lokale Bubble optimistisch mit dem App-Focus getaggt;
          // wenn der Router anders entschieden hat (Sticky, \u201Efuer X:"-Prefix,
          // oder Fallback), uebernehmen wir hier den Server-Wert \u2014 sonst
          // divergieren App- und Diagnostic-Ansicht (Frage im einen Kontext,
          // Antwort im anderen). Nur uebernehmen wenn das Feld mitgeliefert
          // wurde (leer/undefined = altes Bridge-Format \u2192 App-Focus behalten).
          const hasServerPid = typeof (message.payload as any).projectId === 'string';
          const sttProjectId = ((message.payload as any).projectId as string) || '';
          const applyPid = (m: ChatMessage): ChatMessage =>
            hasServerPid ? { ...m, projectId: sttProjectId } : m;
          if (!sttText) {
            return;
          }
          setMessages(prev => {
            const newText = `\uD83C\uDFA4 ${sttText}`;
            // Primaer: matche per audioRequestId (eindeutig pro Aufnahme).
            // So gibt's keine Verwechslung wenn zwei Audios kurz hintereinander
            // gesendet wurden und ihre STT-Results ueberlappen.
            if (sttAudioReqId) {
              const idxById = prev.findIndex(m => m.audioRequestId === sttAudioReqId);
              if (idxById >= 0) {
                const next = prev.slice();
                next[idxById] = applyPid({ ...next[idxById], text: newText });
                return next;
              }
            }
            // Fallback: alte Bridge-Version ohne audioRequestId \u2014 match per Substring,
            // nimmt die ERSTE noch unaufgeloeste Placeholder.
            const idx = prev.findIndex(m =>
              m.sender === 'user' && m.text.includes('Spracheingabe wird verarbeitet')
            );
            if (idx >= 0) {
              const next = prev.slice();
              next[idx] = applyPid({ ...next[idx], text: newText });
              return next;
            }
            // Letzter Fallback: gar keine Placeholder \u2192 neue Bubble einfuegen
            return capMessages([...prev, {
              id: nextId(),
              sender: 'user',
              text: newText,
              timestamp: message.timestamp,
              attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
              projectId: hasServerPid ? sttProjectId : focusedProjectIdRef.current,
            }]);
          });
          return;
        }

        // Eigene App-Nachrichten ignorieren (werden lokal hinzugefuegt)
        if (sender === 'user') return;

        // Diagnostic-Nachrichten als User-Nachricht anzeigen
        if (sender === 'diagnostic') {
          const diagText = (message.payload.text as string) || '';
          if (diagText) {
            setMessages(prev => capMessages([...prev, {
              id: nextId(),
              sender: 'user',
              text: diagText,
              timestamp: message.timestamp,
            }]));
          }
          return;
        }

        const text = (message.payload.text as string) || '';
        const ts = message.timestamp;
        // Duplikat-Schutz: gleicher Text innerhalb 5s ignorieren
        setMessages(prev => {
          const isDuplicate = prev.some(m =>
            m.sender === 'aria' && m.text === text && Math.abs(m.timestamp - ts) < 5000
          );
          if (isDuplicate) return prev;
          const ariaMsg: ChatMessage = {
            id: nextId(),
            sender: 'aria',
            text,
            timestamp: ts,
            attachments: message.payload.attachments as Attachment[] | undefined,
            messageId: (message.payload.messageId as string) || undefined,
            backupTs: (message.payload.backupTs as number) || undefined,
            projectId: ((message.payload as any).projectId as string) || '',
          };
          // ARIA hat geantwortet → alle User-Bubbles davor als 'delivered'
          // markieren (WhatsApp-Doppelhaken ✓✓). Brain hat sie verarbeitet.
          return capMessages([...prev, ariaMsg]).map(m =>
            m.sender === 'user'
              && (m.deliveryStatus === 'sent' || m.deliveryStatus === 'sending')
              ? { ...m, deliveryStatus: 'delivered' }
              : m
          );
        });
        // ARIA hat geantwortet → Watchdog clearen, falls noch armiert
        clearStuckWatchdog();
        // ALLE noch laufenden ACK-Timer clearen — Bridge hat unsere Messages
        // ja offensichtlich verarbeitet (sonst keine ARIA-Antwort). Wenn
        // ein ACK aus Netzgruenden verloren ging, soll der Retry nicht
        // nachtraeglich loslaufen und die Bubble auf 'failed' setzen.
        for (const cmid of Array.from(ackTimers.current.keys())) {
          clearAckTimer(cmid);
          pendingPayloads.current.delete(cmid);
        }
      }

      // TTS-Audio abspielen wenn vorhanden — respektiert geraetelokalen Mute/Disable
      // WICHTIG: via Ref statt direkt state lesen, sonst ist's stale (Closure-Bug).
      const canPlay = ttsCanPlayRef.current;
      if (message.type === 'audio_pcm' || (message.type === 'audio' && message.payload.base64)) {
        console.log('[Chat] audio-msg canPlay=%s (enabled=%s muted=%s)',
                    canPlay, ttsDeviceEnabled, ttsMuted);
      }
      if (message.type === 'audio' && message.payload.base64) {
        const b64 = message.payload.base64 as string;
        const refId = (message.payload.messageId as string) || '';
        if (canPlay) audioService.playAudio(b64);
        // Cache IMMER schreiben — Play-Button soll auch bei Mute spaeter funktionieren
        if (refId) {
          audioService.cacheAudio(b64, refId).then(audioPath => {
            if (!audioPath) return;
            setMessages(prev => prev.map(m =>
              m.messageId === refId ? { ...m, audioPath } : m
            ));
          }).catch(() => {});
        }
      }

      // XTTS PCM-Stream: Cache IMMER bauen, Playback nur wenn nicht gemutet
      if (message.type === ('audio_pcm' as any)) {
        const p = { ...(message.payload as any), silent: !canPlay };
        const refId = (p.messageId as string) || '';
        audioService.handlePcmChunk(p).then((audioPath: any) => {
          if (p.final && audioPath && refId) {
            setMessages(prev => prev.map(m =>
              m.messageId === refId ? { ...m, audioPath } : m
            ));
          }
        }).catch(() => {});
      }

      // Thinking-Indicator Status von der Bridge
      if (message.type === 'agent_activity') {
        const activity = (message.payload.activity as string) || 'idle';
        const tool = (message.payload.tool as string) || '';
        const actPid = ((message.payload as any).projectId as string) || '';
        // Global (fuer die bestehende ACK-/Watchdog-Logik) UND per-Kontext
        // (fuer den fokussierten Indikator) fuehren.
        setAgentActivity({ activity, tool });
        setAgentActivityByCtx(prev => ({ ...prev, [actPid]: { activity, tool } }));
        // Implizite ACK-Bestaetigung: Brain hat angefangen zu arbeiten →
        // unsere Nachricht ist offensichtlich angekommen, auch wenn das
        // chat_ack aus irgendeinem Grund nicht durchkam. Alle laufenden
        // ACK-Timer canceln + sending-Bubbles auf 'sent' setzen.
        // Vermeidet das Symptom "Sanduhr bleibt + Timeout" bei langsamen
        // Brain-Antworten (>90 s, also nach 3 ACK-Retries auf failed).
        if (activity !== 'idle' && ackTimers.current.size > 0) {
          for (const cmid of Array.from(ackTimers.current.keys())) {
            clearAckTimer(cmid);
          }
          // Reference-stable: wenn keine Bubble zu aendern ist, geben wir
          // prev unveraendert zurueck. Sonst triggert .map() ein neues
          // Array + Re-Render, was waehrend einer aktiven Such-Scroll-
          // Sequenz die FlatList-Layouts invalidiert → permanenter
          // onScrollToIndexFailed-Loop.
          setMessages(prev => {
            const needs = prev.some(m => m.sender === 'user' && m.deliveryStatus === 'sending');
            if (!needs) return prev;
            return prev.map(m =>
              m.sender === 'user' && m.deliveryStatus === 'sending'
                ? { ...m, deliveryStatus: 'sent' }
                : m,
            );
          });
        }
        // In den Gedanken-Stream einfuegen. Dedup gegen identische Folge-
        // Events (z.B. zwei mal 'thinking' direkt hintereinander). Tool-
        // Events NIE dedupen — wenn ARIA dreimal Bash hintereinander ruft,
        // sollen alle drei sichtbar sein.
        const key = `${activity}|${tool}`;
        const isTool = activity === 'tool';
        if (isTool || key !== lastThoughtKeyRef.current) {
          lastThoughtKeyRef.current = key;
          setThoughts(prev => {
            const next = [...prev, { ts: Date.now(), activity, tool }];
            return next.length > MAX_THOUGHTS ? next.slice(-MAX_THOUGHTS) : next;
          });
        }
        // Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
        // nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
        // Watchdog: solange Brain noch Lebenszeichen sendet (jedes neue
        // activity-Event), Timer neu starten. 21 Min ohne Update → Hang.
        // Knapp ueber Brain-Timeout (20 Min) damit nur bei echten
        // Verbindungsabbruechen / Brain-Crashes gefeuert wird, nicht waehrend
        // legitimer langer Multi-Tool-Sessions die das Brain selbst kappt.
        clearStuckWatchdog();
        if (activity !== 'idle') {
          stuckWatchdog.current = setTimeout(() => {
            stuckWatchdog.current = null;
            setAgentActivity({ activity: 'idle', tool: '' });
            setMessages(prev => capMessages([...prev, {
              id: nextId(),
              sender: 'aria',
              text: '⚠️ Habe gerade keine Verbindung zurueck bekommen (Timeout nach 21 Min). Deine letzte Nachricht ist evtl. nicht durchgekommen — schick sie nochmal.',
              timestamp: Date.now(),
            }]));
          }, 1_260_000);
        }
      }

      // Voice-Config aus Diagnostic — setzt die lokale App-Stimme auf den
      // gerade in Diagnostic gewaehlten Wert zurueck. User-Wahl in der App
      // wird dadurch ueberschrieben.
      if (message.type === ('config' as any)) {
        const newVoice = ((message.payload as any).xttsVoice as string) ?? '';
        localXttsVoiceRef.current = newVoice;
        AsyncStorage.setItem('aria_xtts_voice', newVoice);
      }

      // XTTS-Bridge meldet Stimme fertig geladen (kurzer Status-Toast)
      if (message.type === ('voice_ready' as any)) {
        const v = ((message.payload as any).voice as string) ?? '';
        const err = (message.payload as any).error as string | undefined;
        if (err) {
          ToastAndroid.show(`Stimme "${v}" Fehler: ${err}`, ToastAndroid.LONG);
        } else {
          ToastAndroid.show(`Stimme "${v || 'Standard'}" bereit`, ToastAndroid.SHORT);
        }
      }

      // Gamebox-Bridges (f5tts/whisper/flux) melden Lade-Status — Banner oben.
      // Toast bei Download-Ende: erstmaliger HF-Download (mehrere GB) → User
      // soll wissen dass er Bilder/Stimmen jetzt nutzen kann ohne in den
      // Banner gucken zu muessen.
      if (message.type === ('service_status' as any)) {
        const p = message.payload as any;
        const svc = (p?.service as string) || '';
        if (!svc) return;
        const newState = (p?.state as string) || 'unknown';
        const freshlyDownloaded = p?.freshlyDownloaded === true;
        setServiceStatus(prev => ({
          ...prev,
          [svc]: {
            state: newState,
            model: p?.model as string | undefined,
            loadSeconds: p?.loadSeconds as number | undefined,
            error: p?.error as string | undefined,
            downloading: p?.downloading === true,
            freshlyDownloaded,
          },
        }));
        // Bei neuer Loading-Phase Banner wieder aktivieren
        if (newState === 'loading') setServiceBannerDismissed(false);
        // Download-Fertig-Toast: Bridge setzt freshlyDownloaded=true bei dem
        // 'ready'-Broadcast direkt nach einem Cache-Miss-Load. Ein einziger
        // Toast pro Modell-Download, kein State-Tracking auf App-Seite noetig.
        if (newState === 'ready' && freshlyDownloaded) {
          const niceName = svc === 'flux' ? 'FLUX' : svc === 'f5tts' ? 'F5-TTS' : svc === 'whisper' ? 'Whisper' : svc;
          const model = p?.model ? ` (${p.model})` : '';
          try {
            ToastAndroid.show(`${niceName}-Modell heruntergeladen${model} — jetzt einsatzbereit`, ToastAndroid.LONG);
          } catch {}
        }
      }
    });

    const unsubState = rvs.onStateChange((state) => {
      setConnectionState(state);
      connectionStateRef.current = state;
      // Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
      // Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
      // zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
      // war als das passiert ist. since=0 + limit=200 → die letzten 200
      // Nachrichten vom Server, oder leeres Array wenn Server leer.
      if (state === 'connected') {
        rvs.send('chat_history_request' as any, { since: 0, limit: 200 });
        // Offline-Queue flushen — alle 'queued'-Bubbles raussschicken
        flushQueuedMessages();
      } else if (state === 'disconnected') {
        // ACK-Timer cancellen, betroffene Bubbles auf 'queued' zurueck
        for (const [cmid, t] of ackTimers.current.entries()) {
          clearTimeout(t);
          ackTimers.current.delete(cmid);
          setMessages(prev => prev.map(m =>
            m.clientMsgId === cmid && m.deliveryStatus === 'sending'
              ? { ...m, deliveryStatus: 'queued' }
              : m
          ));
        }
      }
    });

    // Initalen Status setzen
    const initialState = rvs.getState();
    setConnectionState(initialState);
    connectionStateRef.current = initialState;

    return () => {
      unsubMessage();
      unsubState();
    };
  }, []);

  // Auto-Update: Bei App-Start pruefen
  useEffect(() => {
    const unsubUpdate = updateService.onUpdateAvailable((info) => {
      updateService.promptUpdate(info);
    });
    // Nach 5s pruefen (RVS muss erst verbunden sein)
    const timer = setTimeout(() => updateService.checkForUpdate(), 5000);
    return () => { unsubUpdate(); clearTimeout(timer); };
  }, []);

  // Gespraechsmodus: Nach TTS-Wiedergabe weiter im Multi-Turn (Conversation-
  // Window) oder zurueck zu armed (Wake-Word lauscht wieder)?
  //
  //   Foreground   → resume() oeffnet das Mikro fuer N Sekunden Follow-Up
  //                  (natuerlicher Dialog moeglich ohne erneutes "Computer")
  //   Background   → endConversation() — Wake-Word direkt wieder armed.
  //
  // Grund: der setTimeout(800ms) in resume() wird im Doze stark verzoegert
  // (siehe Wake-Detect-Bug von 0.1.7.0). Das hat zwei nervige Folgen:
  //   1) Wake-Word ist solange "tot" — User kann ARIA nicht mehr triggern
  //      bis er die App vorholt
  //   2) Wenn er die App dann vorholt, oeffnet der verspaetete Timer das
  //      Mikro — sieht aus wie ein Phantom-Wake-Word-Trigger
  // Background = User nutzt das Handy anderweitig, das Multi-Turn-Konzept
  // ist da eh nicht nuetzlich. Direkt re-armen ist robust und erwartungs-
  // konform.
  useEffect(() => {
    const unsubPlayback = audioService.onPlaybackFinished(() => {
      if (!wakeWordService.isActive()) return;
      if (AppState.currentState === 'active') {
        wakeWordService.resume();
      } else {
        console.log('[Chat] TTS fertig im Background → endConversation (kein Multi-Turn)');
        wakeWordService.endConversation().catch(() => {});
      }
    });
    return () => unsubPlayback();
  }, []);

  // Wake Word / Gespraechsmodus: Auto-Aufnahme starten (Streaming-Modus)
  useEffect(() => {
    const unsubWake = wakeWordService.onWakeWord(async () => {
      console.log('[Chat] Gespraechsmodus — starte Streaming-Aufnahme');
      import('../services/logger').then(m => m.reportAppDebug('wake.cb', 'callback fired, calling startStreamingRecording')).catch(()=>{});

      // Bubble SOFORT bauen — bevor Whisper-Bridge antwortet — damit der User
      // sieht "Es passiert was". stt_endpoint kommt typisch <1s spaeter mit
      // dem finalen Text, dann wird die Bubble ueber audioRequestId-Match
      // aktualisiert (siehe chat-Handler oben).
      const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
      const wasInterrupted = interruptAriaIfBusy();
      const location = await getCurrentLocation();
      const windowMs = await loadConvWindowMs();

      const userMsg: ChatMessage = {
        id: nextId(),
        sender: 'user',
        text: '🎙 Spracheingabe wird verarbeitet...',
        timestamp: Date.now(),
        attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
        audioRequestId,
      };
      setMessages(prev => capMessages([...prev, userMsg]));

      const { ok } = await audioService.startStreamingRecording({
        audioRequestId,
        voice: localXttsVoiceRef.current,
        speed: ttsSpeedRef.current,
        interrupted: wasInterrupted,
        location: location || null,
        noSpeechTimeoutMs: windowMs,
        endpointMs: 1500,
        hardCapMs: 60000,
        projectId: focusedProjectIdRef.current,
      });
      import('../services/logger').then(m => m.reportAppDebug('wake.cb', `startStreamingRecording returned ok=${ok}`)).catch(()=>{});
      if (ok) {
        ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
        playWakeReadySound().catch(() => {});
        scheduleStaleAudioCleanup(audioRequestId, 60000);
        import('../services/logger').then(m => m.reportAppDebug('wake.cb', 'gong played + streaming started')).catch(()=>{});
      } else {
        // Mikrofon nicht verfuegbar → Bubble wieder weg, naechster Versuch
        setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
        wakeWordService.resume();
      }
    });

    // STT-Endpoint-Callback ersetzt den alten onSilenceDetected.
    // Feuert in 2 Faellen:
    //   - text != ''  → Whisper-Bridge hat ML-Endpoint erkannt, Text liegt vor.
    //                   aria-bridge bekommt das gleiche Event und triggert Brain
    //                   direkt. App muss nix mehr senden.
    //   - text == ''  → cancelStreamingRecording (no-speech / hardcap / error /
    //                   speaker_mismatch). Konversation beenden, oder bei
    //                   passive-listening: nochmal lauschen.
    const unsubEndpoint = audioService.onSttEndpoint((ev) => {
      if (ev.text && ev.text.trim()) {
        console.log('[Chat] STT-Endpoint: %r (reason=%s, %dms, %.1fs Audio)',
                    ev.text.slice(0, 80), ev.reason, ev.sttMs, ev.durationS);
        // Wenn passive lauschend: User hat tatsaechlich was gesagt → uebergang
        // zu 'conversing' damit der normale Flow greift (TTS, resume, etc.)
        if (wakeWordService.getState() === 'listening') {
          wakeWordService.exitPassiveListening('speech').catch(() => {});
        }
        // Brain laeuft via aria-bridge — wir warten auf chat(sender=stt) +
        // chat(sender=aria) wie im Legacy-Pfad.
      } else {
        // Kein Speech im Window → Konversation beenden
        console.log('[Chat] STT-Endpoint ohne Text (reason=%s) — endConversation', ev.reason);
        // Placeholder-Bubble wieder weg
        if (ev.audioRequestId) {
          setMessages(prev => prev.filter(m => m.audioRequestId !== ev.audioRequestId));
        }
        // Bei Passive-Listen + speaker_mismatch oder no-speech: erneut passiv
        // lauschen (Timer im wakeword-service laeuft weiter, regelt das Ende).
        // Sonst endConversation wie bisher.
        if (wakeWordService.getState() === 'listening') {
          console.log('[Chat] Passive-Listen: leeres Endpoint — naechste passive Aufnahme');
          startPassiveStreamingRecording();
        } else {
          wakeWordService.endConversation();
          if (!wakeWordService.isActive()) setWakeWordActive(false);
        }
      }
    });

    // Passive-Listen-Callback: Wake-Word-Service hat in den passiven Modus
    // geschaltet (nach endConversation). Wir starten eine streaming-Aufnahme
    // OHNE User-Bubble + ohne wake-ready-Sound. Speaker-ID-Gating in der
    // Whisper-Bridge filtert fremde Stimmen weg.
    const unsubPassive = wakeWordService.onPassiveListen(() => {
      console.log('[Chat] Passive-Listen aktiviert — starte stille Streaming-Aufnahme');
      startPassiveStreamingRecording();
    });

    // Barge-In via Wake-Word: User sagt "Computer" waehrend ARIA spricht.
    // Wake-Word-Service hat bei TTS-Start parallel zu lauschen begonnen
    // (mit AcousticEchoCanceler damit ARIAs eigene Stimme nicht triggert).
    const unsubBarge = wakeWordService.onBargeIn(async () => {
      console.log('[Chat] Barge-In via Wake-Word — TTS abbrechen + neue Streaming-Aufnahme');
      audioService.haltAllPlayback('barge-in via wake-word');
      setAgentActivity({ activity: 'idle', tool: '' });
      rvs.send('cancel_request' as any, {});
      // Kurze Pause damit halt durchgreift, dann neue Aufnahme starten
      await new Promise(r => setTimeout(r, 150));
      const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
      const location = await getCurrentLocation();
      const windowMs = await loadConvWindowMs();

      const userMsg: ChatMessage = {
        id: nextId(),
        sender: 'user',
        text: '🎙 Spracheingabe wird verarbeitet...',
        timestamp: Date.now(),
        attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
        audioRequestId,
      };
      setMessages(prev => capMessages([...prev, userMsg]));

      const { ok } = await audioService.startStreamingRecording({
        audioRequestId,
        voice: localXttsVoiceRef.current,
        speed: ttsSpeedRef.current,
        interrupted: true,   // Barge-In → Brain weiss "User hat unterbrochen"
        location: location || null,
        noSpeechTimeoutMs: windowMs,
        endpointMs: 1500,
        hardCapMs: 60000,
        projectId: focusedProjectIdRef.current,
      });
      if (ok) {
        ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
        playWakeReadySound().catch(() => {});
        scheduleStaleAudioCleanup(audioRequestId, 60000);
      } else {
        setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
      }
    });

    // TTS-Lifecycle: solange ARIA spricht und Wake-Word verfuegbar ist,
    // parallel mitlauschen — User kann "Computer" sagen statt manuell tappen.
    // PLUS: Foreground-Service-Slot 'tts' belegen damit Android den App-
    // Prozess nicht killt wenn die App im Hintergrund ist.
    const unsubTtsStart = audioService.onPlaybackStarted(() => {
      acquireBackgroundAudio('tts').catch(() => {});
      if (wakeWordService.isConversing() && wakeWordService.hasWakeWord()) {
        wakeWordService.startBargeListening().catch(() => {});
      }
    });
    const unsubTtsEnd = audioService.onPlaybackFinished(() => {
      releaseBackgroundAudio('tts').catch(() => {});
      // Vor naechster Aufnahme: barge-listening aus damit der AudioRecorder
      // das Mikro greifen kann.
      wakeWordService.stopBargeListening().catch(() => {});
    });

    return () => {
      unsubWake();
      unsubEndpoint();
      unsubBarge();
      unsubPassive();
      unsubTtsStart();
      unsubTtsEnd();
    };
  }, [wakeWordActive]);

  // Passive-Listen-Aufnahme: ohne User-Bubble, ohne Wake-Sound, Speaker-ID-
  // Gating in der Whisper-Bridge entscheidet ob Stefan spricht oder z.B.
  // die Frau / TV. Bei text != '' → wakeWordService.exitPassiveListening('speech')
  // schaltet auf conversing, Brain antwortet, TTS spielt, resume → endConv →
  // ... und passive listening startet von vorne (mit frischem Timer).
  // useCallback damit der useEffect oben die Funktion stabil capturen kann.
  const startPassiveStreamingRecording = useCallback(async () => {
    const audioRequestId = `audio_passive_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
    const location = await getCurrentLocation();
    const passiveMs = await loadPassiveListenMs();
    const { ok } = await audioService.startStreamingRecording({
      audioRequestId,
      voice: localXttsVoiceRef.current,
      speed: ttsSpeedRef.current,
      interrupted: false,
      location: location || null,
      noSpeechTimeoutMs: Math.min(passiveMs, 30000),
      endpointMs: 1500,
      hardCapMs: Math.max(passiveMs + 5000, 35000),
      projectId: focusedProjectIdRef.current,
    });
    if (!ok) {
      console.warn('[Chat] passive streaming start failed — exit passive listening');
      wakeWordService.exitPassiveListening('manual').catch(() => {});
    }
  }, []);

  // Wake Word Toggle Handler
  const toggleWakeWord = useCallback(async () => {
    if (wakeWordActive) {
      // Vor Wake-Word-Stop: eventuelle laufende Aufnahme abbrechen. Sonst
      // bleibt audioService.recordingState=='recording' haengen und der
      // normale Aufnahme-Button wirkt nicht mehr (startRecording lehnt
      // ab weil "Aufnahme laeuft bereits"). Beide Pfade abdecken — legacy
      // file-Aufnahme + neue Streaming-Aufnahme.
      try {
        if (audioService.isStreamingRecording()) {
          await audioService.cancelStreamingRecording('wake-toggle-off');
        } else {
          await audioService.stopRecording();
        }
      } catch {}
      await wakeWordService.stop();
      setWakeWordActive(false);
    } else {
      const started = await wakeWordService.start();
      setWakeWordActive(started);
    }
  }, [wakeWordActive]);

  // Chat-Verlauf in AsyncStorage speichern (debounced, nur nach initialem Laden)
  const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => {
    if (messages.length === 0 || isInitialLoad.current) return;
    // Debounce: 1s warten damit persistAttachment fertig werden kann
    if (saveTimer.current) clearTimeout(saveTimer.current);
    saveTimer.current = setTimeout(() => {
      const toStore = messages.slice(-MAX_STORED_MESSAGES).map(msg => ({
        ...msg,
        attachments: msg.attachments?.map(att => ({
          ...att,
          // Nur file:// URIs speichern, data: URIs rausfiltern (zu gross fuer AsyncStorage)
          uri: att.uri?.startsWith('file://') ? att.uri : undefined,
        })),
      }));
      const json = JSON.stringify(toStore);
      // Sicherheitscheck: nicht speichern wenn >4MB (AsyncStorage Limit)
      if (json.length > 4 * 1024 * 1024) {
        console.warn('[Chat] Speicher zu gross, kuerze auf 100 Nachrichten');
        const shortened = JSON.stringify(toStore.slice(-100));
        AsyncStorage.setItem(CHAT_STORAGE_KEY, shortened).catch(() => {});
      } else {
        AsyncStorage.setItem(CHAT_STORAGE_KEY, json).catch(err =>
          console.error('[Chat] Speichern fehlgeschlagen:', err),
        );
      }
    }, 1000);
    return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
  }, [messages]);

  // Gedanken-Stream beim Mount aus AsyncStorage laden
  useEffect(() => {
    AsyncStorage.getItem(THOUGHT_STORAGE_KEY)
      .then(raw => {
        if (!raw) return;
        try {
          const parsed = JSON.parse(raw);
          if (Array.isArray(parsed)) setThoughts(parsed.slice(-MAX_THOUGHTS));
        } catch {}
      })
      .catch(() => {});
  }, []);

  // Gedanken-Stream persistieren (debounced)
  const thoughtSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => {
    if (thoughts.length === 0) {
      AsyncStorage.removeItem(THOUGHT_STORAGE_KEY).catch(() => {});
      return;
    }
    if (thoughtSaveTimer.current) clearTimeout(thoughtSaveTimer.current);
    thoughtSaveTimer.current = setTimeout(() => {
      AsyncStorage.setItem(
        THOUGHT_STORAGE_KEY,
        JSON.stringify(thoughts.slice(-MAX_THOUGHTS)),
      ).catch(() => {});
    }, 500);
    return () => { if (thoughtSaveTimer.current) clearTimeout(thoughtSaveTimer.current); };
  }, [thoughts]);

  // messagesRef immer aktuell halten — wird von dispatchWithAck/Retry gelesen
  // damit Retries auf den aktuellen deliveryStatus reagieren koennen.
  useEffect(() => { messagesRef.current = messages; }, [messages]);

  // Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
  // Spezial-Bubbles (memorySaved/triggerCreated/skillCreated) sollen im Chat
  // NICHT mehr erscheinen — sie werden in der Notizen-Inbox angezeigt.
  // Das verhindert dass sie chronologisch unten im Chat haengen und der
  // eigentliche Chat-Verlauf darunter verschwindet.
  const chatVisibleMessages = useMemo(
    () => messages.filter(m => !m.memorySaved && !m.triggerCreated && !m.skillCreated),
    [messages],
  );

  // Focus-One-View (Multi-Threading, 06/2026): Chat zeigt NUR die Nachrichten
  // des gerade fokussierten Kontexts. Hauptchat (focusedProjectId leer) →
  // alle ungeтагtgeд Nachrichten. Projekt X aktiv → nur Nachrichten mit
  // projectId === X. ARIA arbeitet weiterhin in allen Kontexten parallel;
  // wir sehen nur den einen.
  const messagesForRender = useMemo(() => {
    return chatVisibleMessages.filter(m => (m.projectId || '') === focusedProjectId);
  }, [chatVisibleMessages, focusedProjectId]);

  const invertedMessages = useMemo(() => [...messagesForRender].reverse(), [messagesForRender]);

  // Such-Treffer: alle Message-IDs die zur Query passen. NEUESTE ZUERST —
  // analog zu WhatsApp/Telegram: User ist visuell unten im Chat, der erste
  // Treffer ist meist schon im Viewport (kein weiter Pre-Scroll, kein
  // Cold-Start-Sprung-Fail). „Naechster" geht in die Vergangenheit.
  // WICHTIG: nur in chatVisibleMessages suchen — Spezial-Bubbles (Memory/
  // Skill/Trigger) sind im Chat-Stream nicht sichtbar und Treffer auf die
  // wuerden zu „ID nicht im FlatList → findIndex=-1 → kein Scroll"-Fail
  // fuehren.
  const searchMatchIds = useMemo(() => {
    const q = searchQuery.trim().toLowerCase();
    if (!q) return [] as string[];
    return chatVisibleMessages
      .filter(m => (m.text || '').toLowerCase().includes(q))
      .map(m => m.id)
      .reverse();
  }, [chatVisibleMessages, searchQuery]);

  useEffect(() => {
    setSearchIndex(0);
  }, [searchQuery]);

  // Tracking damit wir nicht zur selben Bubble mehrfach scrollen (z.B. wenn
  // neue Nachrichten kommen waehrend Suche aktiv ist → invertedMessages
  // aendert sich, soll aber nicht den Scroll erneut triggern).
  const lastSearchScrollKey = useRef<string>('');
  // Pending Retry-Timer fuer onScrollToIndexFailed — wird gecancelt sobald
  // ein neuer Search-Hit kommt, damit alte Retries nicht den neuen
  // Scroll-Versuch durcheinanderbringen ("permanent springen"-Bug).
  const pendingScrollRetry = useRef<ReturnType<typeof setTimeout> | null>(null);
  // Zaehler fuer fehlgeschlagene Scroll-Retries. Hartes Limit gegen
  // Endlos-Loops wenn das Item-Layout aus irgendwelchen Gruenden nie
  // verfuegbar wird (z.B. weil setMessages mitten in der Sequenz die
  // FlatList re-rendert).
  const scrollRetryCount = useRef<number>(0);
  // 6 Retries: bei weiten Spruengen (Suche auf Bubble #150 von Position 0)
  // kann FlatList mehrere Iterationen brauchen bis die Items in der Naehe
  // gemessen sind. Vorher 3 = vorzeitig aufgegeben.
  const MAX_SCROLL_RETRIES = 6;
  const clearPendingScrollRetry = () => {
    if (pendingScrollRetry.current) {
      clearTimeout(pendingScrollRetry.current);
      pendingScrollRetry.current = null;
    }
    scrollRetryCount.current = 0;
  };

  // Bei Search-Index-Wechsel zur entsprechenden Bubble scrollen.
  // FlatList ist `inverted`. viewPosition 0 = Item-Top oben am Viewport →
  // Treffer-Bubble liegt mit dem Anfang direkt oben sichtbar.
  // WICHTIG: invertedMessages bewusst NICHT in den Deps — sonst feuert das
  // Effekt bei jeder neuen ARIA-Nachricht erneut und scrollt amok.
  // Den aktuellen Snapshot von invertedMessages holen wir via Ref.
  const invertedMessagesRef = useRef(invertedMessages);
  invertedMessagesRef.current = invertedMessages;
  // Cache fuer echte Bubble-Hoehen, gefuettert per onLayout in
  // renderMessage. Wird beim Pre-Scroll genutzt damit der grobe Sprung
  // praezise landet (statt mit dem 150-px-Pauschalwert weit daneben).
  const itemHeights = useRef<Map<string, number>>(new Map());
  const AVG_BUBBLE_HEIGHT = 150; // Fallback fuer noch nicht gemessene Items
  useEffect(() => {
    if (!searchMatchIds.length) {
      lastSearchScrollKey.current = '';
      clearPendingScrollRetry();
      return;
    }
    const id = searchMatchIds[searchIndex];
    if (!id) return;
    // Eindeutiger Schluessel pro Treffer-Stop — verhindert dass identische
    // Re-Renders erneut scrollen.
    const key = `${searchIndex}:${id}`;
    if (lastSearchScrollKey.current === key) return;
    lastSearchScrollKey.current = key;
    // Neue Suche → alte Retries verwerfen
    clearPendingScrollRetry();
    const idx = invertedMessagesRef.current.findIndex(m => m.id === id);
    if (idx < 0 || !flatListRef.current) return;
    // Pre-Scroll: erst grob in die Naehe springen, damit FlatList die
    // Bubbles in der Umgebung ueberhaupt rendert (sonst basiert
    // averageItemLength im Failed-Handler nur auf den ersten ~10 Items
    // und liefert einen voellig falschen Sprung).
    // Offset = Summe echter Hoehen (aus itemHeights-Cache, gefuettert per
    // onLayout) + dynamischer Fallback aus dem Mittel der bisher
    // gemessenen Items. Beim Cold-Start gibt's nur 10 Messungen (die
    // neuesten unten in der invertierten Liste) — der Mittel daraus ist
    // immer noch besser als die Pauschal-150.
    const measured = Array.from(itemHeights.current.values());
    const dynamicAvg = measured.length >= 5
      ? measured.reduce((a, b) => a + b, 0) / measured.length
      : AVG_BUBBLE_HEIGHT;
    let preOffset = 0;
    const inv = invertedMessagesRef.current;
    for (let i = 0; i < idx; i++) {
      preOffset += itemHeights.current.get(inv[i].id) || dynamicAvg;
    }
    try {
      flatListRef.current?.scrollToOffset({
        offset: preOffset,
        animated: false,
      });
    } catch {}
    // Nach Render-Pause praezise nachsetzen. 350 ms — bei weiten Spruengen
    // (Pre-Scroll 5000+ px) braucht FlatList Zeit die Items dort zu
    // mounten und onLayout zu feuern. Zu kurz → averageItemLength im
    // Failed-Handler basiert noch auf den falschen Items.
    requestAnimationFrame(() => {
      setTimeout(() => {
        try {
          flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0 });
        } catch {
          // onScrollToIndexFailed-Handler uebernimmt den Fallback
        }
      }, 350);
    });
  }, [searchIndex, searchMatchIds]);

  // Unmount → pending Timer verwerfen, sonst feuern sie nach Navigation ins Leere
  useEffect(() => () => {
    clearPendingScrollRetry();
    clearStuckWatchdog();
  }, []);

  const activeSearchId = searchMatchIds[searchIndex] || '';
  const gotoSearchPrev = () => {
    if (!searchMatchIds.length) return;
    setSearchIndex(i => (i - 1 + searchMatchIds.length) % searchMatchIds.length);
  };
  const gotoSearchNext = () => {
    if (!searchMatchIds.length) return;
    setSearchIndex(i => (i + 1) % searchMatchIds.length);
  };

  // GPS-Position holen (optional)
  const getCurrentLocation = useCallback((): Promise<{ lat: number; lon: number } | null> => {
    if (!gpsEnabled) {
      console.log('[GPS] gpsEnabled=false → kein Standort');
      return Promise.resolve(null);
    }

    return new Promise((resolve) => {
      Geolocation.getCurrentPosition(
        (position) => {
          const loc = {
            lat: position.coords.latitude,
            lon: position.coords.longitude,
          };
          console.log('[GPS] Position: lat=%s lon=%s', loc.lat, loc.lon);
          resolve(loc);
        },
        (error) => {
          console.warn('[GPS] getCurrentPosition Fehler:', error?.code, error?.message);
          resolve(null);
        },
        { enableHighAccuracy: false, timeout: 5000 },
      );
    });
  }, [gpsEnabled]);

  // --- Nachricht senden ---

  // Aufraeumen von "verarbeitet"-Placeholder die nie ein STT-Result bekommen
  // haben (leere Aufnahme, Wake-Word-Echo, STT-Fehler etc). Timeout skaliert
  // mit der Aufnahmedauer — Whisper braucht auf der Gamebox grob real-time/5,
  // plus Bridge-Roundtrip + Network. Formel: 60s Buffer + 1x Aufnahmedauer.
  // Bei 5min Aufnahme = 6 min Wait, bei 5s Aufnahme = 65s. Sicher genug damit
  // langsame STTs nicht versehentlich aufgeraeumt werden.
  const scheduleStaleAudioCleanup = useCallback((audioRequestId: string, recordingMs: number) => {
    const timeoutMs = 60000 + recordingMs;
    setTimeout(() => {
      setMessages(prev => {
        const idx = prev.findIndex(m =>
          m.audioRequestId === audioRequestId &&
          m.text.includes('Spracheingabe wird verarbeitet')
        );
        if (idx < 0) return prev;
        console.log('[Chat] Sprachnachricht ohne STT-Result nach %dms entfernt: %s',
                    timeoutMs, audioRequestId);
        ToastAndroid.show('Sprachnachricht nicht erkannt — entfernt', ToastAndroid.SHORT);
        return prev.filter((_, i) => i !== idx);
      });
    }, timeoutMs);
  }, []);

  const sendTextMessage = useCallback(async () => {
    const text = inputText.trim();

    // Wenn pending Anhaenge vorhanden → Anhaenge + Text zusammen senden
    if (pendingAttachments.length > 0) {
      sendPendingAttachments(text);
      return;
    }

    if (!text) return;

    setInputText('');

    // Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
    const wasInterrupted = interruptAriaIfBusy();
    const location = await getCurrentLocation();

    const cmid = nextClientMsgId();
    const activePid = focusedProjectIdRef.current;
    const userMsg: ChatMessage = {
      id: nextId(),
      sender: 'user',
      text,
      timestamp: Date.now(),
      clientMsgId: cmid,
      deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
      sendAttempts: 1,
      projectId: activePid,
    };
    setMessages(prev => capMessages([...prev, userMsg]));

    console.log('[Chat] sende cmid=%s voice=%s speed=%s interrupted=%s project=%s',
                cmid, localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted, activePid || '(main)');
    dispatchWithAck(cmid, 'chat', {
      text,
      voice: localXttsVoiceRef.current,
      speed: ttsSpeedRef.current,
      interrupted: wasInterrupted,
      projectId: activePid,
      ...(location && { location }),
    });
  }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy, dispatchWithAck]);

  // Anfrage abbrechen — nur den fokussierten Kontext (kontext-scoped Cancel).
  const cancelRequest = useCallback(() => {
    const pid = focusedProjectIdRef.current || '';
    setAgentActivity({ activity: 'idle', tool: '' });
    setAgentActivityByCtx(prev => ({ ...prev, [pid]: { activity: 'idle', tool: '' } }));
    clearStuckWatchdog();
    rvs.send('cancel_request' as any, { projectId: pid });
  }, []);

  // Barge-In: wenn der User waehrend ARIA arbeitet/spricht eine neue Sprach-
  // Nachricht aufnimmt, alte Aktivitaet sofort abbrechen — TTS verstummen,
  // aria-core-Run via cancel_request abbrechen. So kann man "ach vergiss es,
  // mach lieber X" sagen wie in einem echten Gespraech.
  const interruptAriaIfBusy = useCallback(() => {
    const speaking = audioService.isPlayingAudio();
    // Multi-Threading: NUR den fokussierten Kontext als "busy" werten — nicht
    // global. Sonst bricht eine Nachricht im Hauptchat die parallele Arbeit in
    // einem Projekt ab (bzw. wird faelschlich als Barge-In behandelt und die
    // eigene Anfrage geht unter). Der Busy-Status kommt kontextgenau aus
    // /projects/queue-status (queueStatusRef). agentActivity ist global und
    // taugt dafuer nicht.
    const pid = focusedProjectIdRef.current || '';
    const focusKey = pid || '__main__';
    const focusBusy = !!queueStatusRef.current?.[focusKey]?.busy;
    if (!speaking && !focusBusy) return false;
    console.log('[Chat] Barge-In: speaking=%s focusBusy=%s (ctx=%s) — interrupting',
                speaking, focusBusy, focusKey);
    // TTS immer stoppen wenn ARIA gerade spricht — egal welcher Kontext.
    if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
    // Brain-Arbeit nur abbrechen wenn GENAU dieser Kontext arbeitet.
    if (focusBusy) {
      clearStuckWatchdog();
      rvs.send('cancel_request' as any, { projectId: pid });
    }
    return true;
  }, []);

  // Manueller Aufnahme-Knopf (VoiceButton) — Start.
  // Streaming-Variante: PcmStreamRecorder + Whisper-ML-Endpointer ersetzen
  // die alte dB-VAD-Schleife. Knopf-1.-Tap startet, Knopf-2.-Tap stoppt.
  // Bubble bauen wir SOFORT damit der User sofort Feedback hat — Text wird
  // ueber audioRequestId-Match nachgereicht wenn whisper das Endpoint feuert.
  const handleVoiceButtonStart = useCallback(async (): Promise<boolean> => {
    const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
    const wasInterrupted = interruptAriaIfBusy();
    const location = await getCurrentLocation();

    const userMsg: ChatMessage = {
      id: nextId(),
      sender: 'user',
      text: '🎙 Spracheingabe wird verarbeitet...',
      timestamp: Date.now(),
      attachments: [{ type: 'audio', name: 'Sprachaufnahme' }],
      audioRequestId,
    };
    setMessages(prev => capMessages([...prev, userMsg]));

    const { ok } = await audioService.startStreamingRecording({
      audioRequestId,
      voice: localXttsVoiceRef.current,
      speed: ttsSpeedRef.current,
      interrupted: wasInterrupted,
      location: location || null,
      // Manueller Knopf: kein no-speech-Watchdog (User kontrolliert via Tap-zum-
      // Stoppen). Hard-Cap 5 Minuten als Notbremse — danach killt Whisper
      // die Session auch app-seitig haben wir +2s Toleranz.
      noSpeechTimeoutMs: 0,
      endpointMs: 1500,
      hardCapMs: 300000,
      projectId: focusedProjectIdRef.current,
    });
    if (!ok) {
      // Mikro nicht verfuegbar (Anruf? OpenWakeWord blockiert?) — Bubble weg.
      setMessages(prev => prev.filter(m => m.audioRequestId !== audioRequestId));
      return false;
    }
    scheduleStaleAudioCleanup(audioRequestId, 60000);
    return true;
  }, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);

  // Manueller Aufnahme-Knopf — Stop. Sendet stt_stream_end an Whisper, die
  // dann ihrerseits den finalen Text als stt_endpoint emittiert. aria-bridge
  // forwarded direkt an Brain. Im wake-word-conversing-Fall zusaetzlich
  // endConversation: User hat explizit gestoppt → kein Multi-Turn-Resume.
  const handleVoiceButtonStop = useCallback(async (): Promise<void> => {
    await audioService.stopStreamingRecording('user');
    if (wakeWordService.isConversing()) {
      console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
      await wakeWordService.endConversation();
    }
  }, []);

  // Datei auswaehlen → zur Pending-Liste hinzufuegen
  const handleFileSelected = useCallback(async (file: FileData) => {
    setShowFileUpload(false);
    setPendingAttachments(prev => [...prev, { file, isPhoto: false }]);
  }, []);

  // Foto auswaehlen → zur Pending-Liste hinzufuegen
  const handlePhotoSelected = useCallback(async (photo: PhotoData) => {
    setShowCameraUpload(false);
    setPendingAttachments(prev => [...prev, { file: photo, isPhoto: true }]);
  }, []);

  // Alle Pending Anhaenge + Text senden
  const sendPendingAttachments = useCallback(async (messageText: string) => {
    if (pendingAttachments.length === 0) return;
    console.log('[Chat] sendPendingAttachments: %d Anhang/Anhaenge', pendingAttachments.length);
    const location = await getCurrentLocation();
    const msgId = nextId();

    // Alle Attachments fuer die Chat-Nachricht sammeln
    const attachments: Attachment[] = [];
    for (const { file, isPhoto } of pendingAttachments) {
      const isImage = isPhoto || (file.type && file.type.startsWith('image/'));
      const name = isPhoto ? file.fileName : file.name;
      const base64 = file.base64 || '';
      const mimeType = file.type || '';
      const imageUri = isImage && base64 ? `data:${mimeType};base64,${base64}` : file.uri;

      attachments.push({
        type: isImage ? 'image' : 'file',
        name,
        size: file.size,
        uri: imageUri,
        mimeType,
      });
    }

    // Chat-Nachricht mit allen Anhaengen. clientMsgId nur wenn Text dabei
    // ist — files selber haben (noch) kein ACK-Tracking auf der Bridge.
    const cmid = messageText ? nextClientMsgId() : undefined;
    const activePid = focusedProjectIdRef.current;
    const userMsg: ChatMessage = {
      id: msgId,
      sender: 'user',
      text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
      timestamp: Date.now(),
      attachments,
      projectId: activePid,
      ...(cmid && {
        clientMsgId: cmid,
        deliveryStatus: connectionStateRef.current === 'connected' ? 'sending' : 'queued',
        sendAttempts: 1,
      }),
    };
    setMessages(prev => capMessages([...prev, userMsg]));

    // Alle Dateien an RVS senden + auf Disk speichern
    for (const { file, isPhoto } of pendingAttachments) {
      const name = isPhoto ? file.fileName : file.name;
      const base64 = file.base64 || '';
      const mimeType = file.type || '';

      // Auf Disk speichern
      if (base64) {
        persistAttachment(base64, msgId + '_' + name, name).then(filePath => {
          setMessages(prev => prev.map(m =>
            m.id === msgId ? { ...m, attachments: m.attachments?.map(a =>
              a.name === name && !a.uri?.startsWith('file://') ? { ...a, uri: filePath } : a
            )} : m
          ));
        }).catch(() => {});
      }

      // An RVS senden
      console.log('[Chat] sende file: name=%s mime=%s size=%s b64Bytes=%s',
                  name, mimeType, file.size, base64.length);
      rvs.send('file', {
        name,
        type: mimeType,
        size: file.size,
        base64,
        projectId: activePid,
        ...(isPhoto && file.width && { width: file.width, height: file.height }),
        ...(location && { location }),
      });
    }

    // Text als separate Nachricht (damit ARIA weiss was zu tun ist) — mit
    // dem clientMsgId der Bubble, damit Bridge+ACK die richtige Bubble
    // adressieren.
    if (messageText && cmid) {
      dispatchWithAck(cmid, 'chat', {
        text: messageText,
        voice: localXttsVoiceRef.current,
        speed: ttsSpeedRef.current,
        projectId: activePid,
        ...(location && { location }),
      });
    }

    setPendingAttachments([]);
    setInputText('');
  }, [pendingAttachments, getCurrentLocation, dispatchWithAck]);

  // --- Rendering ---

  const renderMessage = ({ item }: { item: ChatMessage }) => {
    const isUser = item.sender === 'user';
    const time = new Date(item.timestamp).toLocaleTimeString('de-DE', {
      hour: '2-digit',
      minute: '2-digit',
    });
    const isSearchHit = activeSearchId === item.id;
    const searchHighlightStyle = isSearchHit
      ? { borderWidth: 2, borderColor: '#FFD60A' }
      : null;

    // Spezial-Bubble: ARIA hat etwas via memory_save gespeichert
    if (item.memorySaved) {
      const m = item.memorySaved;
      const catPart = m.category ? ` · [${m.category}]` : '';
      const atts = m.attachments || [];
      const action = m.action || 'created';
      const headline =
        action === 'updated'  ? '🧠 ARIA hat eine Notiz geändert' :
        action === 'deleted'  ? '🧠 ARIA hat eine Notiz gelöscht' :
                                '🧠 ARIA hat etwas gemerkt';
      const headlineColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
      const borderColor   = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
      const openable = !!m.id && action !== 'deleted';
      const Wrapper: any = openable ? TouchableOpacity : View;
      const wrapperProps = openable
        ? { onPress: () => setMemoryDetailId(m.id || null), activeOpacity: 0.7 }
        : {};
      return (
        <Wrapper {...wrapperProps} style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: borderColor}, searchHighlightStyle]}>
          <Text style={{color: headlineColor, fontWeight: 'bold', fontSize: 14}}>
            {headline}
          </Text>
          <Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
            <Text style={{fontWeight: 'bold'}}>{m.title}</Text>
            <Text style={{color: '#8888AA', fontSize: 12}}>{` (${m.type}${m.pinned ? ' · 📌 pinned' : ''}${catPart})`}</Text>
          </Text>
          {m.preview ? (
            <Text style={{color: '#888', fontSize: 12, marginTop: 4}}>{m.preview}{m.preview.length >= 140 ? '…' : ''}</Text>
          ) : null}
          {atts.map((a, idx) => {
            const isImage = (a.mime || '').startsWith('image/');
            const icon = isImage ? '🖼️' : '📄';
            const sizeStr = a.size ? ` · ${(a.size / 1024).toFixed(0)} KB` : '';
            return (
              <TouchableOpacity
                key={`${item.id}-att-${idx}`}
                style={styles.memoryAttachmentRow}
                onPress={async () => {
                  if (!a.path) return;
                  if (a.localUri) {
                    const localPath = a.localUri.replace(/^file:\/\//, '');
                    const exists = await RNFS.exists(localPath).catch(() => false);
                    if (exists) {
                      if (isImage) setFullscreenImage(a.localUri);
                      else openFileWithIntent(localPath, a.mime || '');
                      return;
                    }
                    // Cache weg → localUri leeren + neu laden
                    setMessages(prev => prev.map(mm => mm.id === item.id && mm.memorySaved
                      ? { ...mm, memorySaved: { ...mm.memorySaved,
                          attachments: mm.memorySaved.attachments?.map(x =>
                            x.path === a.path ? { ...x, localUri: undefined } : x) } }
                      : mm));
                    if (Platform.OS === 'android') {
                      ToastAndroid.show('Cache leer — lade nach...', ToastAndroid.SHORT);
                    }
                  }
                  // Datei via Bridge nachladen — file_response hat den
                  // memorySaved-Match-Path und cached + zeigt direkt
                  autoOpenPaths.current.add(a.path);
                  rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` });
                }}
              >
                <Text style={styles.memoryAttachmentIcon}>{icon}</Text>
                <Text style={styles.memoryAttachmentName} numberOfLines={1}>{a.name}</Text>
                <Text style={styles.memoryAttachmentMeta}>
                  {a.localUri ? '(tippen zum oeffnen)' : `(tippen zum Laden${sizeStr})`}
                </Text>
              </TouchableOpacity>
            );
          })}
          <Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>
            ARIA-Memory · {time}{openable ? ' · tippen für Details' : ''}
          </Text>
        </Wrapper>
      );
    }

    // Spezial-Bubble: ARIA hat einen Trigger angelegt
    if (item.triggerCreated) {
      const t = item.triggerCreated;
      const detailLine = t.type === 'timer'
        ? `feuert: ${t.fires_at || '?'}`
        : `wenn: ${t.condition || '?'}`;
      return (
        <View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
          <Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
            {'⏰ ARIA hat einen Trigger angelegt'}
          </Text>
          <Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
            <Text style={{fontWeight: 'bold'}}>{t.name}</Text>
            <Text style={{color: '#8888AA', fontSize: 12}}>{` (${t.type})`}</Text>
          </Text>
          <Text style={{color: '#8888AA', fontSize: 12, marginTop: 2, fontFamily: 'monospace'}}>{detailLine}</Text>
          <Text style={{color: '#888', fontSize: 12, marginTop: 2}}>{`"${t.message}"`}</Text>
          <Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Trigger · {time}</Text>
        </View>
      );
    }

    // Spezial-Bubble: ARIA hat einen Skill erstellt
    if (item.skillCreated) {
      const s = item.skillCreated;
      return (
        <View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
          <Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
            {'🛠 ARIA hat einen neuen Skill erstellt'}
          </Text>
          <Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
            <Text style={{fontWeight: 'bold'}}>{s.name}</Text>
            <Text style={{color: '#8888AA', fontSize: 12}}>{` (${s.execution}, ${s.active ? 'aktiv' : 'deaktiviert'})`}</Text>
          </Text>
          <Text style={{color: '#8888AA', fontSize: 12, marginTop: 2}}>{s.description}</Text>
          {s.setupError && (
            <Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}}>
              {'⚠ Setup-Fehler: '}{s.setupError.slice(0, 200)}
            </Text>
          )}
          <Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Skill · {time}</Text>
        </View>
      );
    }

    return (
      <Pressable
        style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}
        onLayout={e => {
          // Echte Hoehe in Cache speichern — Pre-Scroll der Suche nutzt
          // die summierten Cache-Werte fuer praezisen Sprung. Bei
          // unbekannten Items faellt's auf AVG_BUBBLE_HEIGHT zurueck.
          itemHeights.current.set(item.id, e.nativeEvent.layout.height);
        }}
        onLongPress={() => openBubbleActions(item)}
        delayLongPress={500}
        android_ripple={null}
      >
        {/* Anhang-Vorschau */}
        {item.attachments?.map((att, idx) => (
          <View key={idx}>
            {att.deleted ? (
              <View style={[styles.attachmentFile, {opacity: 0.6}]}>
                <Text style={styles.attachmentFileIcon}>{'🗑️'}</Text>
                <Text style={[styles.attachmentFileName, {textDecorationLine: 'line-through'}]} numberOfLines={1}>{att.name}</Text>
                <Text style={[styles.attachmentFileSize, {color: '#FF9500'}]}>(geloescht)</Text>
              </View>
            ) : att.type === 'image' && att.uri ? (
              <ChatImage
                uri={att.uri}
                onPress={() => setFullscreenImage(att.uri || null)}
                onError={() => {
                  setMessages(prev => prev.map(m =>
                    m.id === item.id ? { ...m, attachments: m.attachments?.map((a, i) =>
                      i === idx ? { ...a, uri: undefined } : a
                    )} : m
                  ));
                }}
              />
            ) : att.type === 'image' && !att.uri ? (
              <TouchableOpacity
                style={styles.attachmentFile}
                onPress={() => {
                  if (att.serverPath) {
                    rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
                  }
                }}
              >
                <Text style={styles.attachmentFileIcon}>{'\uD83D\uDDBC\uFE0F'}</Text>
                <Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
                <Text style={styles.attachmentFileSize}>
                  {att.serverPath ? '(tippen zum Laden)' : '(nicht verfuegbar)'}
                </Text>
              </TouchableOpacity>
            ) : (
              <TouchableOpacity
                style={styles.attachmentFile}
                onPress={async () => {
                  // Lokal vorhanden? Cache koennte geleert worden sein \u2014
                  // Datei-Existenz pruefen bevor wir den Intent feuern.
                  if (att.uri) {
                    const localPath = att.uri.replace(/^file:\/\//, '');
                    const exists = await RNFS.exists(localPath).catch(() => false);
                    if (exists) {
                      openFileWithIntent(localPath, att.mimeType || '');
                      return;
                    }
                    // Cache weg \u2192 uri im State leeren damit UI "tippen zum Laden" zeigt
                    setMessages(prev => prev.map(m => m.id === item.id
                      ? { ...m, attachments: m.attachments?.map(a =>
                          a.serverPath === att.serverPath ? { ...a, uri: undefined } : a) }
                      : m));
                    if (Platform.OS === 'android') {
                      ToastAndroid.show('Cache leer \u2014 lade nach...', ToastAndroid.SHORT);
                    }
                  }
                  // Re-Download via file_request \u2192 bei file_response wird die
                  // Datei gespeichert UND geoeffnet (autoOpenPaths-Tracking).
                  if (att.serverPath) {
                    autoOpenPaths.current.add(att.serverPath);
                    rvs.send('file_request' as any, { serverPath: att.serverPath, requestId: item.id });
                  } else if (Platform.OS === 'android') {
                    ToastAndroid.show('Datei kann nicht nachgeladen werden (kein serverPath)', ToastAndroid.LONG);
                  }
                }}
              >
                <Text style={styles.attachmentFileIcon}>
                  {att.mimeType?.includes('pdf') ? '\uD83D\uDCC4' :
                   att.mimeType?.includes('word') || att.mimeType?.includes('document') ? '\uD83D\uDCC3' :
                   att.mimeType?.includes('sheet') || att.mimeType?.includes('excel') ? '\uD83D\uDCC8' :
                   '\uD83D\uDCC1'}
                </Text>
                <Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
                {att.size ? <Text style={styles.attachmentFileSize}>{Math.round(att.size / 1024)}KB</Text> : null}
                {!att.uri && att.serverPath && (
                  <Text style={[styles.attachmentFileSize, {color: '#0096FF'}]}>(tippen zum oeffnen)</Text>
                )}
                {!att.uri && !att.serverPath && <Text style={styles.attachmentFileSize}>(nicht verfuegbar)</Text>}
              </TouchableOpacity>
            )}
          </View>
        ))}
        {/* Text (nicht anzeigen wenn nur "Anhang empfangen" und ein Bild da ist) */}
        {!(item.text === 'Anhang empfangen' && item.attachments?.some(a => a.type === 'image' && a.uri)) && (
          <MessageText
            text={showSystemHints ? item.text : stripSystemHints(item.text)}
            style={[styles.messageText, isUser ? styles.userText : styles.ariaText]}
          />
        )}
        {/* Play-Button fuer ARIA-Nachrichten — Cache bevorzugt, sonst Bridge-TTS mit aktueller Engine */}
        {!isUser && item.text.length > 0 && (
          <TouchableOpacity
            style={styles.playButton}
            onPress={async () => {
              // Erst lokalen Cache pruefen — audioPath kann auf eine geloeschte
              // Datei zeigen (TTS-Cache geleert oder Auto-Cleanup). In dem Fall
              // ueber RVS neu rendern lassen statt stumm zu bleiben.
              const cachePath = item.audioPath?.replace(/^file:\/\//, '') || '';
              const cached = cachePath ? await RNFS.exists(cachePath).catch(() => false) : false;
              if (cached) {
                audioService.playFromPath(item.audioPath!);
                return;
              }
              // messageId mitschicken damit die Bridge das generierte Audio
              // wieder mit der Nachricht verknuepft (fuer den naechsten Replay aus Cache)
              rvs.send('tts_request' as any, {
                text: item.text,
                voice: localXttsVoiceRef.current,
                speed: ttsSpeedRef.current,
                messageId: item.messageId || '',
              });
            }}
          >
            <Text style={styles.playButtonText}>{'\uD83D\uDD0A'}</Text>
          </TouchableOpacity>
        )}
        {item.backupTs ? (
          <TouchableOpacity
            style={styles.bubbleTrash}
            hitSlop={{top:6,bottom:6,left:6,right:6}}
            onPress={() => confirmDeleteBubble(item)}
          >
            <Text style={styles.bubbleTrashIcon}>{'🗑'}</Text>
          </TouchableOpacity>
        ) : null}
        <View style={styles.statusRow}>
          <Text style={styles.timestamp}>{time}</Text>
          {item.text.length > 0 ? (
            <TouchableOpacity
              hitSlop={{top:6,bottom:6,left:6,right:6}}
              onPress={() => openBubbleActions(item)}
              accessibilityLabel="Aktionen"
            >
              <Text style={styles.bubbleCopyIcon}>{'⎘'}</Text>
            </TouchableOpacity>
          ) : null}
          {isUser && item.deliveryStatus ? (
            item.deliveryStatus === 'failed' && item.clientMsgId ? (
              <TouchableOpacity
                hitSlop={{top:6,bottom:6,left:6,right:6}}
                onPress={() => retryFailedMessage(item.clientMsgId!)}
              >
                <Text style={styles.statusFailed}>{'⚠ tippen f. Retry'}</Text>
              </TouchableOpacity>
            ) : (
              <Text style={
                item.deliveryStatus === 'queued'    ? styles.statusQueued :
                item.deliveryStatus === 'sending'   ? styles.statusSending :
                item.deliveryStatus === 'sent'      ? styles.statusSent :
                /* delivered */                       styles.statusDelivered
              }>
                {item.deliveryStatus === 'queued'    ? '⏱'  :
                 item.deliveryStatus === 'sending'   ? '⏳' :
                 item.deliveryStatus === 'sent'      ? '✓'  :
                 /* delivered */                       '✓✓'}
              </Text>
            )
          ) : null}
        </View>
      </Pressable>
    );
  };

  // Extrahiert kopierbare Items aus dem Bubble-Text (URLs, Mails, Telefon).
  // Wird vom Long-Press/Copy-Menu genutzt damit Stefan den einzelnen Wert
  // teilen kann ohne den umliegenden Text mitzunehmen.
  const extractCopyables = (text: string): { label: string; value: string }[] => {
    const items: { label: string; value: string }[] = [];
    const urlRe = /https?:\/\/[^\s<>"']+/gi;
    const mailRe = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g;
    const telRe = /(?:\+?\d[\d ()/-]{6,}\d)/g;
    const seen = new Set<string>();
    const push = (label: string, value: string) => {
      const trimmed = value.trim().replace(/[,;.)\]}>]+$/g, '');
      if (!trimmed || seen.has(trimmed)) return;
      seen.add(trimmed);
      items.push({ label, value: trimmed });
    };
    (text.match(urlRe) || []).forEach(u => push('URL', u));
    (text.match(mailRe) || []).forEach(m => push('E-Mail', m));
    (text.match(telRe) || []).forEach(t => push('Telefon', t));
    return items.slice(0, 5);  // max 5 items, mehr wird unleserlich
  };

  // Long-Press oder ⎘-Icon auf einer Bubble. Zeigt einen Alert mit
  // "Text teilen" (= System-Share-Sheet, dort gibt's auch Zwischenablage)
  // sowie pro extrahierte URL/E-Mail/Telefonnummer eine Option um
  // gezielt nur dieses Item zu teilen.
  const openBubbleActions = (item: ChatMessage) => {
    const text = showSystemHints ? item.text : stripSystemHints(item.text);
    if (!text) return;
    const copyables = extractCopyables(text);
    const buttons: any[] = [
      {
        text: '📋 Ganzen Text teilen',
        onPress: () => Share.share({ message: text }).catch(() => {}),
      },
    ];
    for (const c of copyables) {
      buttons.push({
        text: `📎 ${c.label}: ${c.value.slice(0, 32)}${c.value.length > 32 ? '…' : ''}`,
        onPress: () => Share.share({ message: c.value }).catch(() => {}),
      });
    }
    buttons.push({ text: 'Abbrechen', style: 'cancel' });
    Alert.alert(
      'Bubble-Aktionen',
      copyables.length > 0
        ? 'Was moechtest du teilen / kopieren?'
        : 'Text in System-Share-Sheet oeffnen (dort "In Zwischenablage" verfuegbar).',
      buttons,
    );
  };

  const confirmDeleteBubble = (item: ChatMessage) => {
    const ts = item.backupTs;
    if (!ts) return;
    const preview = (item.text || '').slice(0, 80) || '(leere Bubble)';
    Alert.alert(
      'Bubble loeschen?',
      `"${preview}${item.text && item.text.length > 80 ? '…' : ''}"\n\nWird aus chat_backup, Brain-Konversation und allen Clients entfernt.`,
      [
        { text: 'Abbrechen', style: 'cancel' },
        {
          text: 'Loeschen',
          style: 'destructive',
          onPress: () => {
            console.log(`[Chat] delete_message_request ts=${ts}`);
            rvs.send('delete_message_request' as any, { ts });
          },
        },
      ],
    );
  };

  const connectionDotColor =
    connectionState === 'connected' ? '#34C759' :
    connectionState === 'connecting' ? '#FFD60A' : '#FF3B30';

  return (
    <KeyboardAvoidingView
      style={styles.container}
      behavior={Platform.OS === 'ios' ? 'padding' : undefined}
      keyboardVerticalOffset={90}
    >
      {/* Verbindungsstatus-Leiste */}
      <View style={styles.statusBar}>
        <View style={[styles.statusDot, { backgroundColor: connectionDotColor }]} />
        <Text style={styles.statusText}>
          {connectionState === 'connected' ? 'Verbunden' :
           connectionState === 'connecting' ? 'Verbinde...' : 'Getrennt'}
        </Text>
        <TouchableOpacity onPress={() => setThoughtsVisible(true)} style={{marginLeft: 'auto', paddingHorizontal: 6, flexDirection: 'row', alignItems: 'center'}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
          <Text style={{fontSize: 16}}>{'\uD83D\uDCAD'}</Text>
          {thoughts.length > 0 ? (
            <Text style={{color: '#8888AA', fontSize: 11, marginLeft: 3}}>{thoughts.length}</Text>
          ) : null}
        </TouchableOpacity>
        <TouchableOpacity onPress={() => setInboxVisible(true)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
          <Text style={{fontSize: 18}}>{'\uD83D\uDDC2\uFE0F'}</Text>
        </TouchableOpacity>
        <TouchableOpacity onPress={() => setSearchVisible(!searchVisible)} style={{paddingHorizontal: 6}} hitSlop={{top:8,bottom:8,left:6,right:6}}>
          <Text style={{fontSize: 16}}>{'\uD83D\uDD0D'}</Text>
        </TouchableOpacity>
      </View>

      {/* Service-Status Banner (Gamebox: F5-TTS / Whisper Lade-Status) */}
      {(() => {
        const entries = Object.entries(serviceStatus);
        if (entries.length === 0 || serviceBannerDismissed) return null;
        const anyLoading = entries.some(([, v]) => v.state === 'loading');
        const anyError = entries.some(([, v]) => v.state === 'error');
        const allReady = !anyLoading && !anyError && entries.every(([, v]) => v.state === 'ready');
        const bg = anyError ? '#3A1F1F' : anyLoading ? '#3A331F' : '#1F3A2A';
        const border = anyError ? '#FF3B30' : anyLoading ? '#FFD60A' : '#34C759';
        const labels: Record<string, string> = { f5tts: 'F5-TTS', whisper: 'Whisper STT', flux: 'FLUX Image-Gen' };
        return (
          <TouchableOpacity
            activeOpacity={allReady ? 0.6 : 1.0}
            onPress={() => { if (allReady) setServiceBannerDismissed(true); }}
            style={[styles.serviceBanner, { backgroundColor: bg, borderColor: border }]}
          >
            {entries.map(([svc, info]) => {
              let icon = '\u23F3', text = '';
              if (info.state === 'loading') {
                icon = info.downloading ? '\u2B07' : '\u23F3'; // \u2B07 vs \u23F3
                const action = info.downloading
                  ? 'laedt erstmalig runter (mehrere GB, kann dauern)'
                  : 'laedt';
                text = `${labels[svc] || svc}: ${action}${info.model ? ' ' + info.model : ''}...`;
              } else if (info.state === 'ready') {
                icon = info.freshlyDownloaded ? '\uD83C\uDF89' : '\u2705'; // \uD83C\uDF89 vs \u2705
                const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
                const dl = info.freshlyDownloaded ? ' \u2014 Download fertig!' : '';
                text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}${dl}`;
              } else if (info.state === 'error') {
                icon = '\u274C';
                text = `${labels[svc] || svc}: Fehler ${info.error || ''}`;
              } else {
                text = `${labels[svc] || svc}: ${info.state}`;
              }
              return (
                <Text key={svc} style={styles.serviceBannerLine}>
                  {icon}  {text}
                </Text>
              );
            })}
            <Text style={styles.serviceBannerHint}>
              {allReady ? 'Tippen zum Schliessen' : 'Bitte warten...'}
            </Text>
          </TouchableOpacity>
        );
      })()}

      {/* Projekt-Indicator: zeigt Hauptchat oder aktives Projekt */}
      {/* Focus-Indicator + Drawer-Toggle. Multi-Threading: das ist reine
          Anzeige „was sehe ich gerade" — ARIA arbeitet gleichzeitig in
          allen Kontexten weiter, wir zeigen hier nur einen. */}
      {(() => {
        const isMain = !focusedProjectId;
        const focusedName = isMain ? '' : (projectNameById[focusedProjectId] || focusedProjectId);
        const focusedQueue = queueStatus[isMain ? '__main__' : focusedProjectId];
        const dot = focusedQueue?.busy
          ? { color: '#FF6E6E', label: 'arbeitet' }
          : focusedQueue?.queue_size
            ? { color: '#FFD60A', label: `Queue: ${focusedQueue.queue_size}` }
            : { color: '#34C759', label: 'idle' };
        // Anzahl anderer Kontexte die gerade aktiv sind (fuer Drawer-Badge)
        const otherActive = Object.entries(queueStatus).filter(([k, v]) => {
          const kFocus = isMain ? '__main__' : focusedProjectId;
          if (k === kFocus) return false;
          return v.busy || v.queue_size > 0;
        }).length;
        return (
          <View
            style={{
              flexDirection: 'row', alignItems: 'center',
              paddingHorizontal: 12, paddingVertical: 8,
              backgroundColor: isMain ? '#1A1A26' : 'rgba(52,199,89,0.10)',
              borderBottomWidth: 2,
              borderColor: isMain ? '#1E1E2E' : '#34C759',
            }}
          >
            <TouchableOpacity
              onPress={() => setProjectsVisible(true)}
              style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}
              hitSlop={{top:6,bottom:6,left:6,right:6}}
            >
              <Text style={{ fontSize: 22, color: '#E0E0F0', fontWeight: '700' }}>☰</Text>
              {otherActive > 0 && (
                <View style={{ backgroundColor: '#FF6E6E', borderRadius: 8, minWidth: 16, height: 16, paddingHorizontal: 4, alignItems: 'center', justifyContent: 'center' }}>
                  <Text style={{ color: '#fff', fontSize: 10, fontWeight: '700' }}>{otherActive}</Text>
                </View>
              )}
            </TouchableOpacity>
            <View style={{ flex: 1, marginLeft: 10, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
              <Text style={{ fontSize: 14, color: isMain ? '#E0E0F0' : '#34C759', fontWeight: '700', flex: 1 }} numberOfLines={1}>
                {isMain ? '💬 Hauptchat' : `📁 ${focusedName}`}
              </Text>
              <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: dot.color }} />
              <Text style={{ fontSize: 10, color: '#8888AA' }}>{dot.label}</Text>
              {/* Direkter Zurueck-zum-Hauptchat-Button — nur wenn man in einem
                  Projekt ist. Ein Tap statt Drawer→Hauptchat. */}
              {!isMain && (
                <TouchableOpacity
                  onPress={() => setFocusedProjectId('')}
                  hitSlop={{top:8,bottom:8,left:8,right:8}}
                  style={{
                    marginLeft: 4, paddingHorizontal: 10, paddingVertical: 4,
                    borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.12)',
                    flexDirection: 'row', alignItems: 'center', gap: 4,
                  }}
                >
                  <Text style={{ color: '#E0E0F0', fontSize: 12, fontWeight: '700' }}>← Hauptchat</Text>
                </TouchableOpacity>
              )}
            </View>
          </View>
        );
      })()}

      {/* Projekt-Drawer als Modal */}
      <ProjectsBrowser
        visible={projectsVisible}
        onClose={() => setProjectsVisible(false)}
        onActiveChanged={(p) => setFocusedProjectId(p?.id || '')}
        currentFocusId={focusedProjectId}
        queueStatus={queueStatus}
      />

      {/* Suchleiste mit Treffer-Navigation */}
      {searchVisible && (
        <View style={styles.searchBar}>
          <TextInput
            style={styles.searchInput}
            value={searchQuery}
            onChangeText={setSearchQuery}
            placeholder="Chat durchsuchen..."
            placeholderTextColor="#555570"
            autoFocus
          />
          {searchQuery ? (
            <Text style={{color: searchMatchIds.length ? '#0096FF' : '#555570', fontSize: 12, paddingHorizontal: 6}}>
              {searchMatchIds.length ? `${searchIndex + 1}/${searchMatchIds.length}` : '0/0'}
            </Text>
          ) : null}
          <TouchableOpacity
            onPress={gotoSearchPrev}
            disabled={!searchMatchIds.length}
            style={{paddingHorizontal: 6, opacity: searchMatchIds.length ? 1 : 0.3}}
          >
            <Text style={{color: '#0096FF', fontSize: 18}}>{'▲'}</Text>
          </TouchableOpacity>
          <TouchableOpacity
            onPress={gotoSearchNext}
            disabled={!searchMatchIds.length}
            style={{paddingHorizontal: 6, opacity: searchMatchIds.length ? 1 : 0.3}}
          >
            <Text style={{color: '#0096FF', fontSize: 18}}>{'▼'}</Text>
          </TouchableOpacity>
          <TouchableOpacity onPress={() => { setSearchVisible(false); setSearchQuery(''); }}>
            <Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>X</Text>
          </TouchableOpacity>
        </View>
      )}

      {/* Nachrichtenliste — Suche FILTERT NICHT mehr, sondern hebt aktiven
          Treffer hervor (siehe renderMessage: activeSearchId-Border). */}
      <FlatList
        ref={flatListRef}
        inverted
        data={invertedMessages}
        // Mehr Items beim Mount messen → bessere averageItemLength fuer
        // Such-Sprung gleich nach App-Start. Default sind 10 Items, das
        // ist bei 300+ Bubbles im Backup viel zu wenig.
        initialNumToRender={30}
        // Mehr Items im Speicher halten (Default 21 = 10 oben + 10 unten).
        // Macht scroll-to-far-away weniger anfaellig fuer Layout-Holes.
        windowSize={41}
        onScroll={(e) => {
          // Bei inverted FlatList: contentOffset.y > 0 = weg von "unten"
          // (= aelter scrollen). Wir zeigen den Jump-Down-Button ab ~250px.
          const y = e.nativeEvent.contentOffset.y;
          setShowJumpDown(y > 250);
        }}
        scrollEventThrottle={120}
        onScrollToIndexFailed={(info) => {
          // FlatList kennt das Item-Layout noch nicht. Wir scrollen grob in
          // die Naehe (Average-Item-Hoehe-Schaetzung) und versuchen bis zu
          // MAX_SCROLL_RETRIES mal praezise nachzusetzen. Danach geben wir
          // auf — User sieht die Bubble in der ungefaehren Naehe und kann
          // selber finetunen. Frueher: jeder failed Retry triggerte einen
          // neuen Retry ohne Limit → "permanent springen"-Bug, vor allem
          // wenn waehrenddessen setMessages die Layouts invalidierte.
          const offset = info.averageItemLength * info.index;
          try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
          if (pendingScrollRetry.current) {
            clearTimeout(pendingScrollRetry.current);
            pendingScrollRetry.current = null;
          }
          if (scrollRetryCount.current >= MAX_SCROLL_RETRIES) {
            // Aufgeben — Item ist offenbar nicht stabil renderbar
            scrollRetryCount.current = 0;
            return;
          }
          scrollRetryCount.current += 1;
          pendingScrollRetry.current = setTimeout(() => {
            pendingScrollRetry.current = null;
            try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0 }); } catch {}
          }, 300);
        }}
        keyExtractor={item => item.id}
        renderItem={renderMessage}
        contentContainerStyle={styles.messageList}
        showsVerticalScrollIndicator={false}
        ListEmptyComponent={
          <View style={styles.emptyContainer}>
            <Text style={styles.emptyIcon}>{'\uD83E\uDD16'}</Text>
            <Text style={styles.emptyText}>ARIA Cockpit</Text>
            <Text style={styles.emptyHint}>Starte eine Konversation mit ARIA</Text>
          </View>
        }
      />

      {/* Thinking-Indicator \u2014 NUR fuer den fokussierten Kontext (Multi-Threading).
          ARIA kann in anderen Kontexten parallel arbeiten, ohne dass hier ein
          Indikator flackert der nicht zum sichtbaren Chat gehoert. */}
      {(() => {
        const focusAct = agentActivityByCtx[focusedProjectId] || { activity: 'idle', tool: '' };
        if (focusAct.activity === 'idle') return null;
        return (
        <View style={styles.thinkingBar}>
          <Text style={styles.thinkingText}>
            {focusAct.activity === 'tool' && focusAct.tool
              ? `\uD83D\uDD27 ${focusAct.tool}`
              : focusAct.activity === 'assistant'
              ? '\u270D\uFE0F ARIA schreibt...'
              : '\uD83D\uDCAD ARIA denkt...'}
          </Text>
          <View style={{flexDirection: 'row', gap: 6}}>
            <TouchableOpacity style={styles.thinkingCancel} onPress={cancelRequest}>
              <Text style={styles.thinkingCancelText}>Abbrechen</Text>
            </TouchableOpacity>
          </View>
        </View>
        );
      })()}

      {/* Pending Anhaenge Vorschau */}
      {pendingAttachments.length > 0 && (
        <View style={styles.pendingBar}>
          <ScrollView horizontal showsHorizontalScrollIndicator={false} style={{flex: 1}}>
            {pendingAttachments.map((att, idx) => (
              <View key={idx} style={styles.pendingItem}>
                {att.file.type?.startsWith('image/') || att.isPhoto ? (
                  <Image
                    source={{ uri: att.file.base64
                      ? `data:${att.file.type};base64,${att.file.base64}`
                      : att.file.uri }}
                    style={styles.pendingThumb}
                  />
                ) : (
                  <View style={[styles.pendingThumb, {justifyContent: 'center', alignItems: 'center'}]}>
                    <Text style={{fontSize: 20}}>{'\uD83D\uDCC4'}</Text>
                  </View>
                )}
                <TouchableOpacity
                  style={styles.pendingRemove}
                  onPress={() => setPendingAttachments(prev => prev.filter((_, i) => i !== idx))}
                >
                  <Text style={{color: '#fff', fontSize: 10, fontWeight: 'bold'}}>X</Text>
                </TouchableOpacity>
              </View>
            ))}
          </ScrollView>
          <Text style={{color: '#8888AA', fontSize: 11, marginLeft: 8}}>{pendingAttachments.length}</Text>
          <TouchableOpacity onPress={() => setPendingAttachments([])}>
            <Text style={{color: '#FF3B30', fontSize: 14, paddingHorizontal: 8}}>Alle X</Text>
          </TouchableOpacity>
        </View>
      )}

      {/* Jump-to-Bottom-Button — erscheint wenn man weg von der neuesten
          Nachricht gescrollt hat. Bei inverted FlatList ist scrollToOffset
          0 == neueste Nachricht visuell unten. */}
      {showJumpDown && (
        <TouchableOpacity
          style={styles.jumpDownBtn}
          activeOpacity={0.85}
          onPress={() => {
            try {
              flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
            } catch {}
            setShowJumpDown(false);
          }}
        >
          <Text style={{color:'#fff', fontSize:18, fontWeight:'700'}}>{'↓'}</Text>
        </TouchableOpacity>
      )}

      {/* Eingabebereich */}
      <View style={styles.inputContainer}>
        {/* Datei-Buttons */}
        <TouchableOpacity
          style={styles.actionButton}
          onPress={() => setShowFileUpload(true)}
        >
          <Text style={styles.actionIcon}>{'\uD83D\uDCCE'}</Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={styles.actionButton}
          onPress={() => setShowCameraUpload(true)}
        >
          <Text style={styles.actionIcon}>{'\uD83D\uDCF7'}</Text>
        </TouchableOpacity>

        {/* Texteingabe */}
        <TextInput
          style={styles.textInput}
          value={inputText}
          onChangeText={setInputText}
          placeholder={pendingAttachments.length > 0 ? "Text zu den Anhaengen (optional)..." : "Nachricht an ARIA..."}
          placeholderTextColor="#555570"
          multiline
          maxLength={4000}
          onSubmitEditing={sendTextMessage}
          returnKeyType="send"
        />

        {/* Senden oder Sprache */}
        {inputText.trim() || pendingAttachments.length > 0 ? (
          <TouchableOpacity style={styles.sendButton} onPress={sendTextMessage}>
            <Text style={styles.sendIcon}>{'\u2B06\uFE0F'}</Text>
          </TouchableOpacity>
        ) : (
          <>
            <VoiceButton
              onTapStart={handleVoiceButtonStart}
              onTapStop={handleVoiceButtonStop}
              disabled={connectionState !== 'connected'}
              wakeWordActive={wakeWordActive}
            />
            {/* Mund-Button: TTS auf diesem Geraet muten/aufheben.
                Nur sichtbar wenn TTS in den Settings grundsaetzlich aktiv ist. */}
            {ttsDeviceEnabled && (
              <TouchableOpacity
                style={[styles.wakeWordBtn, ttsMuted && styles.mouthBtnMuted]}
                onPress={toggleMute}
                accessibilityLabel={ttsMuted ? 'Sprachausgabe einschalten' : 'Sprachausgabe stumm schalten'}
              >
                <Text style={styles.wakeWordIcon}>{ttsMuted ? '🤐' : '👄'}</Text>
              </TouchableOpacity>
            )}
            <TouchableOpacity
              style={[styles.wakeWordBtn, wakeWordActive && styles.wakeWordBtnActive]}
              onPress={toggleWakeWord}
            >
              <Text style={styles.wakeWordIcon}>
                {wakeWordState === 'conversing' ? '🎙️' :
                 wakeWordState === 'armed' ? '👂' : '🔇'}
              </Text>
            </TouchableOpacity>
          </>
        )}
      </View>

      {/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */}
      {memoryDetailId ? (
        <ErrorBoundary scope="ChatScreen.MemoryDetailModal" onReset={() => setMemoryDetailId(null)}>
          <MemoryDetailModal
            memoryId={memoryDetailId}
            visible={!!memoryDetailId}
            onClose={() => setMemoryDetailId(null)}
            onDeleted={() => setMemoryDetailId(null)}
          />
        </ErrorBoundary>
      ) : null}

      {/* Gedanken-Stream — chronologisches Log von ARIAs interner Aktivitaet.
          Bottom-Sheet (slide-up), 60% Bildschirmhoehe. Mülltonne zum Leeren. */}
      <Modal
        visible={thoughtsVisible}
        animationType="slide"
        transparent
        onRequestClose={() => setThoughtsVisible(false)}
      >
        <View style={{flex:1, backgroundColor:'rgba(0,0,0,0.5)', justifyContent:'flex-end'}}>
          {/* Tap-Outside-Bereich oberhalb des Sheets — separater Touchable
              damit das Sheet-View NICHT als Responder den FlatList-Scroll
              blockiert. Frueher hatten wir den ganzen Hintergrund als
              TouchableOpacity + inneren View mit onStartShouldSetResponder
              = das hat alle Touch-Events kassiert. */}
          <TouchableOpacity
            style={{flex:1}}
            activeOpacity={1}
            onPress={() => setThoughtsVisible(false)}
          />
          <View
            style={{height:'60%', backgroundColor:'#0D0D1A', borderTopLeftRadius:16, borderTopRightRadius:16}}
          >
            {/* Drag-Indicator */}
            <View style={{alignItems:'center', paddingTop:8, paddingBottom:4}}>
              <View style={{width:40, height:4, borderRadius:2, backgroundColor:'#2A2A3E'}} />
            </View>
            <View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
              <Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>
                {'💭'} Gedanken-Stream {thoughts.length > 0 ? `(${thoughts.length})` : ''}
              </Text>
              {thoughts.length > 0 ? (
                <TouchableOpacity
                  onPress={() => {
                    Alert.alert('Gedanken-Stream leeren?', `Alle ${thoughts.length} Eintraege werden geloescht.`, [
                      { text: 'Abbrechen', style: 'cancel' },
                      { text: 'Leeren', style: 'destructive', onPress: () => {
                        setThoughts([]);
                        lastThoughtKeyRef.current = '';
                      } },
                    ]);
                  }}
                  hitSlop={{top:8,bottom:8,left:8,right:8}}
                  style={{paddingHorizontal:8}}
                >
                  <Text style={{fontSize:18}}>{'🗑'}</Text>
                </TouchableOpacity>
              ) : null}
              <TouchableOpacity onPress={() => setThoughtsVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}>
                <Text style={{color:'#8888AA', fontSize:24}}>×</Text>
              </TouchableOpacity>
            </View>
            {thoughts.length === 0 ? (
              <View style={{flex:1, alignItems:'center', justifyContent:'center', padding:24}}>
                <Text style={{color:'#555570', fontSize:13, fontStyle:'italic', textAlign:'center'}}>
                  Noch keine Gedanken aufgezeichnet.{'\n'}Sobald ARIA was tut, taucht's hier auf.
                </Text>
              </View>
            ) : (
              <FlatList
                data={thoughts}
                keyExtractor={(_, i) => `t_${i}`}
                contentContainerStyle={{paddingVertical:8}}
                renderItem={({ item, index }) => {
                  const prev = index > 0 ? thoughts[index - 1] : null;
                  // Lange Pause? → Trenn-Linie mit Minuten-Hint
                  const gapMin = prev ? Math.floor((item.ts - prev.ts) / 60000) : 0;
                  const showGap = gapMin >= 1;
                  const time = new Date(item.ts).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit'});
                  const icon =
                    item.activity === 'idle' ? '✓' :
                    item.activity === 'tool' ? '🔧' :
                    item.activity === 'assistant' ? '✍️' :
                    item.activity === 'thinking' ? '💭' : '•';
                  const label =
                    item.activity === 'idle' ? 'fertig' :
                    item.activity === 'tool' ? (item.tool || 'tool') :
                    item.activity === 'assistant' ? 'schreibt' :
                    item.activity === 'thinking' ? 'denkt' : item.activity;
                  const isIdle = item.activity === 'idle';
                  return (
                    <View>
                      {showGap ? (
                        <View style={{flexDirection:'row', alignItems:'center', paddingHorizontal:16, paddingVertical:6}}>
                          <View style={{flex:1, height:1, backgroundColor:'#1E1E2E'}} />
                          <Text style={{color:'#555570', fontSize:10, paddingHorizontal:8}}>
                            {gapMin < 60 ? `${gapMin} Min` : `${Math.floor(gapMin/60)}h ${gapMin%60}m`}
                          </Text>
                          <View style={{flex:1, height:1, backgroundColor:'#1E1E2E'}} />
                        </View>
                      ) : null}
                      <View style={{flexDirection:'row', paddingHorizontal:16, paddingVertical:5}}>
                        <Text style={{color:'#555570', fontSize:11, width:78}}>{time}</Text>
                        <Text style={{fontSize:13, width:24}}>{icon}</Text>
                        <Text style={{color: isIdle ? '#34C759' : '#E0E0F0', fontSize:13, flex:1}}>{label}</Text>
                      </View>
                    </View>
                  );
                }}
              />
            )}
          </View>
        </View>
      </Modal>

      {/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles).
          Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles
          des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */}
      <Modal visible={inboxVisible} animationType="slide" onRequestClose={() => setInboxVisible(false)}>
        <ErrorBoundary scope="ChatScreen.InboxModal" onReset={() => setInboxVisible(false)}>
        <View style={{flex:1, backgroundColor:'#0D0D1A'}}>
          <View style={{flexDirection:'row', alignItems:'center', padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
            <Text style={{color:'#FFD60A', fontWeight:'bold', fontSize:16, flex:1}}>{'🗂️'} Notizen-Inbox</Text>
            <TouchableOpacity onPress={() => setInboxVisible(false)} hitSlop={{top:8,bottom:8,left:8,right:8}}>
              <Text style={{color:'#8888AA', fontSize:24}}>×</Text>
            </TouchableOpacity>
          </View>
          {/* Aus aktuellem Chat: Spezial-Bubbles (memory/trigger/skill) kompakt
              auflisten — neueste oben. Klick auf Memory oeffnet Detail-Modal. */}
          {(() => {
            const specials = messages
              .filter(m => m.memorySaved || m.triggerCreated || m.skillCreated)
              .slice().reverse();
            if (specials.length === 0) {
              return (
                <View style={{padding:14, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
                  <Text style={{color:'#555570', fontSize:11, fontStyle:'italic'}}>
                    (keine Notizen-Bubbles im aktuellen Chat)
                  </Text>
                </View>
              );
            }
            return (
              <View style={{maxHeight:260, borderBottomWidth:1, borderBottomColor:'#1E1E2E'}}>
                <Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:8, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
                  Aus diesem Chat
                </Text>
                <ScrollView style={{paddingHorizontal:8}} nestedScrollEnabled={true}>
                  {specials.map(m => {
                    if (m.memorySaved) {
                      const ms = m.memorySaved;
                      const action = ms.action || 'created';
                      const verb = action === 'updated' ? 'geändert' : action === 'deleted' ? 'gelöscht' : 'angelegt';
                      const dotColor = action === 'deleted' ? '#FF6B6B' : '#FFD60A';
                      return (
                        <TouchableOpacity
                          key={m.id}
                          style={styles.inboxRow}
                          onPress={() => { if (ms.id && action !== 'deleted') { setInboxVisible(false); setMemoryDetailId(ms.id); } }}
                          disabled={!ms.id || action === 'deleted'}
                        >
                          <Text style={{fontSize:16}}>{'🧠'}</Text>
                          <View style={{flex:1}}>
                            <Text style={styles.inboxRowTitle} numberOfLines={1}>{ms.title}</Text>
                            <Text style={[styles.inboxRowMeta, {color: dotColor}]}>Memory · {verb} · {ms.type}</Text>
                          </View>
                          {ms.id && action !== 'deleted' ? <Text style={{color:'#0096FF', fontSize:14}}>›</Text> : null}
                        </TouchableOpacity>
                      );
                    }
                    if (m.triggerCreated) {
                      const t = m.triggerCreated;
                      return (
                        <View key={m.id} style={styles.inboxRow}>
                          <Text style={{fontSize:16}}>{'⏰'}</Text>
                          <View style={{flex:1}}>
                            <Text style={styles.inboxRowTitle} numberOfLines={1}>{t.name}</Text>
                            <Text style={styles.inboxRowMeta}>Trigger · {t.type}{t.fires_at ? ` · ${t.fires_at.slice(0,16).replace('T',' ')}` : ''}</Text>
                          </View>
                        </View>
                      );
                    }
                    if (m.skillCreated) {
                      const sk = m.skillCreated;
                      return (
                        <View key={m.id} style={styles.inboxRow}>
                          <Text style={{fontSize:16}}>{'🛠'}</Text>
                          <View style={{flex:1}}>
                            <Text style={styles.inboxRowTitle} numberOfLines={1}>{sk.name}</Text>
                            <Text style={styles.inboxRowMeta}>Skill · {sk.execution}</Text>
                          </View>
                        </View>
                      );
                    }
                    return null;
                  })}
                </ScrollView>
              </View>
            );
          })()}
          <Text style={{color:'#8888AA', fontSize:11, paddingHorizontal:14, paddingTop:10, paddingBottom:4, textTransform:'uppercase', letterSpacing:0.5}}>
            Alle Memories aus der DB
          </Text>
          {/* flex:1 Wrapper damit MemoryBrowser den verbleibenden Platz
              bekommt (sonst rendert die FlatList intern mit 0 Hoehe und
              nimmt nur was der Inhalt sagt → Scroll-Gestures verschwinden). */}
          <View style={{flex:1}}>
            <MemoryBrowser onOpenMemory={(id) => { setInboxVisible(false); setMemoryDetailId(id); }} />
          </View>
        </View>
        </ErrorBoundary>
      </Modal>

      {/* Bild-Vollbild Modal */}
      <Modal visible={!!fullscreenImage} transparent animationType="fade" onRequestClose={() => setFullscreenImage(null)}>
        <View style={styles.fullscreenOverlay}>
          {fullscreenImage && (
            /\.svg(?:\?|$)/i.test(fullscreenImage) ? (
              // SVG: bisher keine Pinch-Zoom — Tap zum Schliessen
              <TouchableOpacity style={styles.fullscreenImage} activeOpacity={1} onPress={() => setFullscreenImage(null)}>
                <SvgUri uri={fullscreenImage} width="100%" height="100%" preserveAspectRatio="xMidYMid meet" />
              </TouchableOpacity>
            ) : (
              // Pixel-Bild: Pinch-Zoom + Pan ueber ZoomableImage
              <ZoomableImage
                uri={fullscreenImage}
                containerWidth={Dimensions.get('window').width}
                containerHeight={Dimensions.get('window').height}
                style={styles.fullscreenImage}
              />
            )
          )}
          {/* Close-Button oben rechts — die TouchableOpacity-uebergreifend funktioniert
              wegen ZoomableImage-PanResponder nicht zuverlaessig fuer Tap-to-Close */}
          <TouchableOpacity
            style={{ position: 'absolute', top: 32, right: 16, padding: 12, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 24 }}
            onPress={() => setFullscreenImage(null)}
          >
            <Text style={{ color: '#FFF', fontSize: 22 }}>{'✕'}</Text>
          </TouchableOpacity>
        </View>
      </Modal>

      {/* Datei-Upload Modal */}
      <Modal visible={showFileUpload} transparent animationType="slide">
        <View style={styles.modalOverlay}>
          <FileUpload
            onFileSelected={handleFileSelected}
            onCancel={() => setShowFileUpload(false)}
          />
        </View>
      </Modal>

      {/* Kamera-Upload Modal */}
      <Modal visible={showCameraUpload} transparent animationType="slide">
        <View style={styles.modalOverlay}>
          <CameraUpload
            onPhotoSelected={handlePhotoSelected}
            onCancel={() => setShowCameraUpload(false)}
          />
        </View>
      </Modal>
    </KeyboardAvoidingView>
  );
};

// --- Styles ---

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#0D0D1A',
  },
  statusBar: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    paddingVertical: 8,
    backgroundColor: '#12122A',
    borderBottomWidth: 1,
    borderBottomColor: '#1E1E2E',
  },
  statusDot: {
    width: 8,
    height: 8,
    borderRadius: 4,
    marginRight: 8,
  },
  statusText: {
    color: '#8888AA',
    fontSize: 12,
  },
  serviceBanner: {
    paddingVertical: 8,
    paddingHorizontal: 12,
    borderTopWidth: 0,
    borderBottomWidth: 1,
    borderLeftWidth: 0,
    borderRightWidth: 0,
  },
  serviceBannerLine: {
    color: '#FFFFFF',
    fontSize: 12,
    lineHeight: 18,
  },
  serviceBannerHint: {
    color: '#AAAACC',
    fontSize: 10,
    marginTop: 2,
    fontStyle: 'italic',
  },
  messageList: {
    padding: 12,
    paddingBottom: 8,
    flexGrow: 1,
  },
  messageBubble: {
    maxWidth: '80%',
    padding: 12,
    borderRadius: 16,
    marginBottom: 8,
  },
  userBubble: {
    alignSelf: 'flex-end',
    backgroundColor: '#0096FF',
    borderBottomRightRadius: 4,
  },
  ariaBubble: {
    alignSelf: 'flex-start',
    backgroundColor: '#1E1E2E',
    borderBottomLeftRadius: 4,
  },
  messageText: {
    fontSize: 15,
    lineHeight: 21,
  },
  userText: {
    color: '#FFFFFF',
  },
  ariaText: {
    color: '#E0E0F0',
  },
  attachmentImage: {
    // Feste Breite + dynamische aspectRatio (in ChatImage gesetzt) damit die
    // Bubble sich ans Bild anpasst. Mit width: '100%' ohne explizite Parent-
    // Breite wuerde RN das Bild auf 0px schrumpfen → "Strich".
    width: 260,
    aspectRatio: 4 / 3,
    borderRadius: 8,
    marginBottom: 6,
    backgroundColor: '#0D0D1A',
  },
  attachmentFile: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: 'rgba(255,255,255,0.1)',
    borderRadius: 8,
    padding: 10,
    marginBottom: 6,
  },
  attachmentFileIcon: {
    fontSize: 24,
    marginRight: 8,
  },
  attachmentFileName: {
    flex: 1,
    color: '#E0E0F0',
    fontSize: 13,
  },
  attachmentFileSize: {
    color: '#8888AA',
    fontSize: 11,
    marginLeft: 8,
  },
  timestamp: {
    color: 'rgba(255,255,255,0.4)',
    fontSize: 10,
    marginTop: 4,
    alignSelf: 'flex-end',
  },
  statusRow: {
    flexDirection: 'row',
    alignItems: 'center',
    alignSelf: 'flex-end',
    gap: 6,
    marginTop: 4,
  },
  statusQueued: {
    color: '#FFD60A', // Gelb — wartet auf Verbindung
    fontSize: 11,
  },
  statusSending: {
    color: 'rgba(255,255,255,0.5)',
    fontSize: 11,
  },
  statusSent: {
    color: 'rgba(255,255,255,0.6)',
    fontSize: 12,
  },
  statusDelivered: {
    color: '#34C759', // Gruen — Brain hat geantwortet
    fontSize: 12,
    fontWeight: '700',
  },
  statusFailed: {
    color: '#FF3B30',
    fontSize: 11,
    fontWeight: '700',
  },
  emptyContainer: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    paddingTop: 120,
  },
  emptyIcon: {
    fontSize: 48,
    marginBottom: 12,
  },
  emptyText: {
    color: '#FFFFFF',
    fontSize: 22,
    fontWeight: '700',
  },
  emptyHint: {
    color: '#555570',
    fontSize: 14,
    marginTop: 4,
  },
  inputContainer: {
    flexDirection: 'row',
    alignItems: 'flex-end',
    paddingHorizontal: 10,
    paddingVertical: 8,
    backgroundColor: '#12122A',
    borderTopWidth: 1,
    borderTopColor: '#1E1E2E',
  },
  actionButton: {
    width: 38,
    height: 38,
    borderRadius: 19,
    alignItems: 'center',
    justifyContent: 'center',
    marginRight: 4,
  },
  actionIcon: {
    fontSize: 20,
  },
  textInput: {
    flex: 1,
    backgroundColor: '#1E1E2E',
    borderRadius: 20,
    paddingHorizontal: 16,
    paddingVertical: 10,
    color: '#FFFFFF',
    fontSize: 15,
    maxHeight: 100,
    marginHorizontal: 6,
  },
  sendButton: {
    width: 40,
    height: 40,
    borderRadius: 20,
    backgroundColor: '#0096FF',
    alignItems: 'center',
    justifyContent: 'center',
  },
  sendIcon: {
    fontSize: 18,
  },
  wakeWordBtn: {
    width: 32,
    height: 32,
    borderRadius: 16,
    backgroundColor: 'rgba(255,255,255,0.1)',
    alignItems: 'center',
    justifyContent: 'center',
    marginLeft: 4,
  },
  wakeWordBtnActive: {
    backgroundColor: 'rgba(52, 199, 89, 0.3)',
  },
  mouthBtnMuted: {
    backgroundColor: 'rgba(255, 59, 48, 0.25)',
  },
  wakeWordIcon: {
    fontSize: 16,
  },
  thinkingBar: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    backgroundColor: '#1E1E2E',
    paddingHorizontal: 12,
    paddingVertical: 6,
    borderTopWidth: 1,
    borderTopColor: '#2A2A3E',
  },
  thinkingText: {
    color: '#FFD60A',
    fontSize: 12,
    flex: 1,
  },
  thinkingCancel: {
    paddingHorizontal: 10,
    paddingVertical: 4,
    borderWidth: 1,
    borderColor: '#FF3B30',
    borderRadius: 4,
  },
  thinkingCancelText: {
    color: '#FF3B30',
    fontSize: 11,
    fontWeight: 'bold',
  },
  pendingBar: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#1E1E2E',
    paddingHorizontal: 12,
    paddingVertical: 8,
    borderTopWidth: 1,
    borderTopColor: '#2A2A3E',
  },
  pendingItem: {
    position: 'relative',
    marginRight: 8,
  },
  pendingThumb: {
    width: 50,
    height: 50,
    borderRadius: 6,
    backgroundColor: '#0D0D1A',
  },
  pendingRemove: {
    position: 'absolute',
    top: -4,
    right: -4,
    width: 18,
    height: 18,
    borderRadius: 9,
    backgroundColor: '#FF3B30',
    justifyContent: 'center',
    alignItems: 'center',
  },
  searchBar: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#12122A',
    paddingHorizontal: 12,
    paddingVertical: 6,
    borderBottomWidth: 1,
    borderBottomColor: '#1E1E2E',
  },
  searchInput: {
    flex: 1,
    color: '#FFFFFF',
    fontSize: 14,
    paddingVertical: 4,
  },
  playButton: {
    alignSelf: 'flex-end',
    paddingHorizontal: 8,
    paddingVertical: 2,
    marginTop: 4,
  },
  playButtonText: {
    fontSize: 16,
  },
  inboxRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 10,
    backgroundColor: '#1E1E2E',
    padding: 10,
    borderRadius: 6,
    marginBottom: 4,
  },
  inboxRowTitle: {
    color: '#E0E0F0',
    fontSize: 13,
    fontWeight: '600',
  },
  inboxRowMeta: {
    color: '#8888AA',
    fontSize: 11,
    marginTop: 1,
  },
  memoryAttachmentRow: {
    flexDirection: 'row',
    alignItems: 'center',
    backgroundColor: '#0D0D1A',
    borderRadius: 6,
    paddingHorizontal: 8,
    paddingVertical: 6,
    marginTop: 4,
    gap: 6,
  },
  memoryAttachmentIcon: {
    fontSize: 16,
  },
  memoryAttachmentName: {
    flex: 1,
    color: '#E0E0F0',
    fontSize: 12,
  },
  memoryAttachmentMeta: {
    color: '#555570',
    fontSize: 10,
  },
  jumpDownBtn: {
    position: 'absolute',
    right: 16,
    bottom: 80,
    width: 44,
    height: 44,
    borderRadius: 22,
    backgroundColor: '#0096FF',
    alignItems: 'center',
    justifyContent: 'center',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.4,
    shadowRadius: 4,
    elevation: 5,
    zIndex: 100,
  },
  bubbleTrash: {
    position: 'absolute',
    top: 4,
    right: 6,
    width: 24,
    height: 24,
    borderRadius: 12,
    backgroundColor: 'rgba(255,59,48,0.18)',
    alignItems: 'center',
    justifyContent: 'center',
  },
  bubbleTrashIcon: {
    fontSize: 12,
    color: '#FF6B6B',
  },
  bubbleCopyIcon: {
    fontSize: 13,
    color: '#8888AA',
    marginLeft: 6,
    opacity: 0.7,
  },
  fullscreenOverlay: {
    flex: 1,
    backgroundColor: 'rgba(0,0,0,0.95)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  fullscreenImage: {
    width: '100%',
    height: '100%',
  },
  modalOverlay: {
    flex: 1,
    backgroundColor: 'rgba(0,0,0,0.6)',
    justifyContent: 'center',
  },
});

export default ChatScreen;
