/**
 * 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,
} 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 rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService from '../services/wakeword';
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 { RecordingResult, 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[];
  /** 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;
}

// --- Konstanten ---

const CHAT_STORAGE_KEY = 'aria_chat_messages';
const MAX_STORED_MESSAGES = 500;
const MAX_MEMORY_MESSAGES = 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;
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'>('off');
  const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
  const [memoryDetailId, setMemoryDetailId] = useState<string | null>(null);
  const [inboxVisible, setInboxVisible] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');
  const [searchVisible, setSearchVisible] = useState(false);
  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: ''});
  // Service-Status (Gamebox: F5-TTS / Whisper Lade-Status) + Banner-Sichtbarkeit
  const [serviceStatus, setServiceStatus] = useState<Record<string, {state: string, model?: string, loadSeconds?: number, error?: string}>>({});
  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);
  // 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);
  // 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}`;
  };

  // TTS- + GPS-Settings beim Mount + alle 2s neu laden (damit Settings-Toggle
  // sofort greift, ohne Context- oder Event-System)
  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');
    };
    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.
      if (s === 'conversing') audioService.acquireConversationFocus();
      else audioService.releaseConversationFocus();
      // 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: kurzer Wake-Word-Cooldown — beim Wechsel Background→Foreground
  // gibt's haeufig Audio-Pegel-Spikes (AudioFocus-Switch, AudioTrack re-route)
  // die openWakeWord sonst faelschlich als Wake-Word interpretiert.
  useEffect(() => {
    let lastState: string = AppState.currentState;
    const sub = AppState.addEventListener('change', (next) => {
      if (lastState !== 'active' && next === 'active') {
        wakeWordService.setResumeCooldown(1500);
      }
      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');
            setMessages(parsed);
            const maxId = parsed.reduce((max, msg) => {
              const num = parseInt(msg.id.split('_').pop() || '0', 10);
              return num > max ? num : max;
            }, 0);
            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) => {
      // 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[];
          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,
          };
        });
        const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
        setMessages(prev => {
          // Lokal-only Bubbles erkennen + behalten:
          //   - Skill-Created-Notifications (skillCreated gesetzt)
          //   - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
          //     gesetzt UND text leer/Placeholder)
          const localOnly = prev.filter(m =>
            m.skillCreated ||
            m.triggerCreated ||
            m.memorySaved ||
            (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
          );
          // 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;
      }

      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) || '';
        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) || '';
          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] = { ...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] = { ...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' }],
            }]);
          });
          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,
          };
          return capMessages([...prev, ariaMsg]);
        });
      }

      // 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) || '';
        setAgentActivity({ activity, tool });
        // Spotify darf waehrend "ARIA denkt/schreibt" weiterspielen — pausiert
        // nur wenn TTS startet (dann acquired _firePlaybackStarted den Focus).
      }

      // 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) melden Lade-Status — Banner oben
      if (message.type === ('service_status' as any)) {
        const p = message.payload as any;
        const svc = (p?.service as string) || '';
        if (!svc) return;
        setServiceStatus(prev => ({
          ...prev,
          [svc]: {
            state: (p?.state as string) || 'unknown',
            model: p?.model as string | undefined,
            loadSeconds: p?.loadSeconds as number | undefined,
            error: p?.error as string | undefined,
          },
        }));
        // Bei neuer Loading-Phase Banner wieder aktivieren
        if (p?.state === 'loading') setServiceBannerDismissed(false);
      }
    });

    const unsubState = rvs.onStateChange((state) => {
      setConnectionState(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 });
      }
    });

    // Initalen Status setzen
    setConnectionState(rvs.getState());

    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 automatisch Aufnahme starten
  useEffect(() => {
    const unsubPlayback = audioService.onPlaybackFinished(() => {
      if (wakeWordService.isActive()) {
        wakeWordService.resume();
      }
    });
    return () => unsubPlayback();
  }, []);

  // Wake Word / Gespraechsmodus: Auto-Aufnahme starten
  useEffect(() => {
    const unsubWake = wakeWordService.onWakeWord(async () => {
      console.log('[Chat] Gespraechsmodus — starte Auto-Aufnahme');
      // Conversation-Window: User hat X Sekunden um anzufangen, sonst Konversation aus
      const windowMs = await loadConvWindowMs();
      const started = await audioService.startRecording(true, windowMs);
      if (started) {
        // Erst JETZT signalisieren dass das Mikro wirklich offen ist —
        // vorher war's noch in der Init-Phase. So weiss der User exakt
        // ab wann er reden kann. "Bereit"-Sound (Ding-Dong) ist optional
        // ueber Settings → Wake-Word abschaltbar.
        ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
        playWakeReadySound().catch(() => {});
      } else {
        // Mikrofon nicht verfuegbar, naechsten Versuch
        wakeWordService.resume();
      }
    });

    // Auto-Stop Callback: wenn Stille erkannt → Aufnahme senden + Wake Word wieder starten
    const unsubSilence = audioService.onSilenceDetected(async () => {
      const result = await audioService.stopRecording();
      if (result && result.durationMs > 500) {
        // User hat im Fenster gesprochen → Sprachnachricht senden
        // Barge-In: laufende ARIA-Aktivitaet abbrechen wenn welche da ist.
        const wasInterrupted = interruptAriaIfBusy();
        const location = await getCurrentLocation();
        const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
        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]));
        rvs.send('audio', {
          base64: result.base64,
          durationMs: result.durationMs,
          mimeType: result.mimeType,
          voice: localXttsVoiceRef.current,
          speed: ttsSpeedRef.current,
          interrupted: wasInterrupted,
          audioRequestId,
          ...(location && { location }),
        });
        scheduleStaleAudioCleanup(audioRequestId, result.durationMs);
        // resume() wird durch onPlaybackFinished nach ARIAs Antwort getriggert.
      } else {
        // Kein Speech im Window → Konversation beenden (Ohr geht aus oder
        // bleibt armed wenn Wake Word verfuegbar)
        wakeWordService.endConversation();
        // UI-State synchron halten
        if (!wakeWordService.isActive()) setWakeWordActive(false);
      }
    });

    // 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 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 windowMs = await loadConvWindowMs();
      const started = await audioService.startRecording(true, windowMs);
      if (started) {
        ToastAndroid.show('🎤 Mikro offen — sprich jetzt', ToastAndroid.SHORT);
        playWakeReadySound().catch(() => {});
      }
    });

    // 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();
      unsubSilence();
      unsubBarge();
      unsubTtsStart();
      unsubTtsEnd();
    };
  }, [wakeWordActive]);

  // Wake Word Toggle Handler
  const toggleWakeWord = useCallback(async () => {
    if (wakeWordActive) {
      // Vor Porcupine-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").
      try { 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]);

  // Inverted FlatList: neueste Nachrichten unten, kein manuelles Scrollen noetig
  const invertedMessages = useMemo(() => [...messages].reverse(), [messages]);

  // Such-Treffer: alle Message-IDs die zur Query passen, in chronologischer
  // Reihenfolge (aelteste zuerst). Bei Query-Change resetten wir den Index.
  const searchMatchIds = useMemo(() => {
    const q = searchQuery.trim().toLowerCase();
    if (!q) return [] as string[];
    return messages
      .filter(m => (m.text || '').toLowerCase().includes(q))
      .map(m => m.id);
  }, [messages, searchQuery]);

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

  // Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
  // FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render
  // tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal
  // damit Layout sicher fertig ist.
  useEffect(() => {
    if (!searchMatchIds.length) return;
    const id = searchMatchIds[searchIndex];
    if (!id) return;
    const idx = invertedMessages.findIndex(m => m.id === id);
    if (idx < 0 || !flatListRef.current) return;
    const tryScroll = () => {
      try {
        flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
      } catch {
        // wird von onScrollToIndexFailed nochmal versucht
      }
    };
    // requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
    requestAnimationFrame(tryScroll);
  }, [searchIndex, searchMatchIds, invertedMessages]);

  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 userMsg: ChatMessage = {
      id: nextId(),
      sender: 'user',
      text,
      timestamp: Date.now(),
    };
    setMessages(prev => capMessages([...prev, userMsg]));

    console.log('[Chat] sende mit voice=%s speed=%s interrupted=%s',
                localXttsVoiceRef.current || '(default)', ttsSpeedRef.current, wasInterrupted);
    // An RVS senden — mit geraetelokaler Voice (Bridge nutzt sie fuer die Antwort)
    rvs.send('chat', {
      text,
      voice: localXttsVoiceRef.current,
      speed: ttsSpeedRef.current,
      interrupted: wasInterrupted,
      ...(location && { location }),
    });
  }, [inputText, getCurrentLocation, pendingAttachments, sendPendingAttachments, interruptAriaIfBusy]);

  // Anfrage abbrechen — sofort lokalen Indicator weg, Bridge triggert doctor --fix
  const cancelRequest = useCallback(() => {
    setAgentActivity({ activity: 'idle', tool: '' });
    rvs.send('cancel_request' as any, {});
  }, []);

  // 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();
    const thinking = agentActivity.activity !== 'idle';
    if (!speaking && !thinking) return false;
    console.log('[Chat] Barge-In: speaking=%s thinking=%s — interrupting ARIA',
                speaking, thinking);
    if (speaking) audioService.haltAllPlayback('user spricht (barge-in)');
    if (thinking) {
      setAgentActivity({ activity: 'idle', tool: '' });
      rvs.send('cancel_request' as any, {});
    }
    return true;
  }, [agentActivity]);

  // Sprachaufnahme abgeschlossen
  const handleVoiceRecording = useCallback(async (result: RecordingResult) => {
    // Barge-In: laufende ARIA-Aktivitaet abbrechen falls aktiv.
    const wasInterrupted = interruptAriaIfBusy();
    const location = await getCurrentLocation();
    const audioRequestId = `audio_${Date.now()}_${Math.floor(Math.random() * 100000)}`;

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

    rvs.send('audio', {
      base64: result.base64,
      durationMs: result.durationMs,
      mimeType: result.mimeType,
      voice: localXttsVoiceRef.current,
      speed: ttsSpeedRef.current,
      interrupted: wasInterrupted,
      audioRequestId,
      ...(location && { location }),
    });
    scheduleStaleAudioCleanup(audioRequestId, result.durationMs);

    // Manueller Mikro-Stop waehrend Wake-Word-Konversation: User hat explizit
    // den Knopf gedrueckt → er moechte nicht in den automatischen Multi-Turn-
    // Modus, sondern nach ARIAs Antwort zurueck zu passivem Wake-Word-Lauschen.
    // Bei VAD-Auto-Stop (Wake-Word-Pfad) laeuft das ueber den silence-callback
    // und endet mit resume() — der manuelle Stop hier ist der "ich bin fertig"-
    // Knopf.
    if (wakeWordService.isConversing()) {
      console.log('[Chat] Manueller Stop in Konversation → endConversation, zurueck zu armed');
      await wakeWordService.endConversation();
    }
  }, [getCurrentLocation, interruptAriaIfBusy, scheduleStaleAudioCleanup]);

  // 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
    const userMsg: ChatMessage = {
      id: msgId,
      sender: 'user',
      text: messageText || `${pendingAttachments.length} Anhang/Anhaenge`,
      timestamp: Date.now(),
      attachments,
    };
    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,
        ...(isPhoto && file.width && { width: file.width, height: file.height }),
        ...(location && { location }),
      });
    }

    // Text als separate Nachricht (damit ARIA weiss was zu tun ist)
    if (messageText) {
      rvs.send('chat', {
        text: messageText,
        voice: localXttsVoiceRef.current,
        speed: ttsSpeedRef.current,
        ...(location && { location }),
      });
    }

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

  // --- 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={() => {
                  if (!a.path) return;
                  if (a.localUri) {
                    if (isImage) setFullscreenImage(a.localUri);
                    else openFileWithIntent(a.localUri.replace(/^file:\/\//, ''), a.mime || '');
                  } else {
                    // 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 (
      <View style={[styles.messageBubble, isUser ? styles.userBubble : styles.ariaBubble, searchHighlightStyle]}>
        {/* 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={() => {
                  // Lokal vorhanden \u2192 direkt mit System-Intent oeffnen
                  if (att.uri) {
                    openFileWithIntent(att.uri.replace(/^file:\/\//, ''), att.mimeType || '');
                    return;
                  }
                  // Sonst: 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 });
                  }
                }}
              >
                <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={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}
        <Text style={styles.timestamp}>{time}</Text>
      </View>
    );
  };

  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={() => setInboxVisible(true)} style={{marginLeft: 'auto', 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' };
        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') {
                text = `${labels[svc] || svc}: laedt${info.model ? ' ' + info.model : ''}...`;
              } else if (info.state === 'ready') {
                icon = '\u2705';
                const sec = info.loadSeconds ? ` (${info.loadSeconds.toFixed(1)}s)` : '';
                text = `${labels[svc] || svc}: bereit${info.model ? ' ' + info.model : ''}${sec}`;
              } 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>
        );
      })()}

      {/* 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}
        onScrollToIndexFailed={(info) => {
          // FlatList kennt das Item-Layout noch nicht. Zuerst grob in die
          // Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms
          // praezise nochmal versuchen.
          const offset = info.averageItemLength * info.index;
          try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
          setTimeout(() => {
            try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {}
          }, 250);
        }}
        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 */}
      {agentActivity.activity !== 'idle' && (
        <View style={styles.thinkingBar}>
          <Text style={styles.thinkingText}>
            {agentActivity.activity === 'tool' && agentActivity.tool
              ? `\uD83D\uDD27 ${agentActivity.tool}`
              : agentActivity.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>
      )}

      {/* 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
              onRecordingComplete={handleVoiceRecording}
              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 */}
      <MemoryDetailModal
        memoryId={memoryDetailId}
        visible={!!memoryDetailId}
        onClose={() => setMemoryDetailId(null)}
        onDeleted={() => setMemoryDetailId(null)}
      />

      {/* 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)}>
        <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>
          <MemoryBrowser />
        </View>
      </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',
  },
  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,
  },
  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,
  },
  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',
  },
  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;
