import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
import {
  View,
  Text,
  TextInput,
  FlatList,
  TouchableOpacity,
  Platform,
  Alert,
  ActivityIndicator,
  StyleSheet,
  Keyboard,
  ScrollView,
  Dimensions,
  type FlatList as FlatListType,
} from 'react-native';
import { Audio } from 'expo-av';
import { KeyboardStickyView } from 'react-native-keyboard-controller';
import { Image } from 'expo-image';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as ImagePicker from 'expo-image-picker';
import { requireOptionalNativeModule } from 'expo-modules-core';
// expo-media-library wird LAZY in saveImage() geladen (require statt top-level
// import): ist das native Modul in einem älteren Build nicht eincompiliert,
// würde ein top-level import den GANZEN DM-Screen crashen. Lazy → nur das
// Speichern schlägt fehl, der Screen lädt normal.
type MediaLibraryModule = typeof import('expo-media-library');
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy';
import { apiFetch } from '../lib/api';
import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble';
import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar';
import { MediaLightbox } from '../components/chat/MediaLightbox';
import { FormSheet } from '../components/FormSheet';
import { useDmRealtime } from '../hooks/useChatRealtime';
import { useDmTyping } from '../hooks/useDmTyping';
import { useColors } from '../lib/theme';
import { useAuthStore } from '../stores/auth';
import { useCallStore, isWebRTCAvailable } from '../stores/call';
import { useMe } from '../hooks/useMe';
import { supabase } from '../lib/supabase';
import { UserAvatar } from '../components/UserAvatar';
import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus';

type DmHistoryResponse = {
  partner: {
    id: string;
    nickname: string;
    username?: string;
    avatar?: string | null;
  };
  messages: Array<{
    id: string;
    content: string;
    createdAt: string;
    isOwn: boolean;
    readAt: string | null;
    senderId?: string;
    receiverId?: string;
    likesCount?: number;
    likedByMe?: boolean;
    attachmentUrl?: string | null;
    attachmentType?: string | null;
    attachmentName?: string | null;
    replyTo?: any;
  }>;
};

const GROUP_GAP_MS = 5 * 60 * 1000;

type DmData = {
  partner: DmHistoryResponse['partner'];
  messages: ChatMsg[];
};

// Merge bei Background-Refetch: Server-Daten sind autoritativ; lokale Extras
// (optimistische temp-* Sends + Realtime-Inserts, die der letzte Fetch noch
// nicht kannte) bleiben erhalten und werden nach createdAt einsortiert.
function mergeMessages(server: ChatMsg[], local: ChatMsg[]): ChatMsg[] {
  const serverIds = new Set(server.map((m) => m.id));
  const extras = local.filter((m) => !serverIds.has(m.id));
  if (extras.length === 0) return server;
  return [...server, ...extras].sort(
    (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
  );
}

export default function DmScreen() {
  const { t } = useTranslation();
  const router = useRouter();
  const insets = useSafeAreaInsets();
  const colors = useColors();
  const styles = makeStyles(colors);
  const queryClient = useQueryClient();
  const myUserId = useAuthStore((s) => s.user?.id);
  const { me } = useMe();

  const { userId } = useLocalSearchParams<{ userId: string }>();

  // Chat-Hintergrund: immer clean (solider Theme-BG, weiß / schwarz). Insta-Style.
  const chatBg = colors.bg;

  const flatListRef = useRef<FlatListType<ChatMsg>>(null);

  // scrollToEnd() unterschätzt auf Android UND iOS die Content-Höhe und
  // stoppt 1 Item zu früh (besonders nach Bild-Load + InputBar-Padding).
  // scrollToOffset(999999) wird intern auf den echten Max-Wert geclampt.
  const scrollToBottom = useCallback((animated = false) => {
    flatListRef.current?.scrollToOffset({ offset: 999999, animated });
  }, []);
  // Seed beide aus dem React-Query-Cache → Reopen einer bereits geladenen
  // Konversation ist sofort sichtbar (kein Spinner, kein Flash).
  const [messages, setMessages] = useState<ChatMsg[]>(
    () => queryClient.getQueryData<DmData>(['dm-history', userId])?.messages ?? [],
  );
  const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(
    () => queryClient.getQueryData<DmData>(['dm-history', userId])?.partner ?? null,
  );
  const partnerRef = useRef<DmHistoryResponse['partner'] | null>(partner);
  // userId, zu dem die aktuellen `messages` gehören (Stack-Reuse-Guard).
  const messagesUserId = useRef<string | undefined>(userId);
  const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
    null,
  );
  // Ref auf das Text-Eingabefeld → nach dem Senden Fokus re-asserten, damit die
  // Tastatur offen bleibt (Insta/WA-Style), auch wenn das Leeren des Inputs den
  // Send-Button gegen den Mic-Button austauscht.
  const inputRef = useRef<TextInput>(null);
  const [sending, setSending] = useState(false);
  const [inputText, setInputText] = useState('');
  const [attachment, setAttachment] = useState<{ uri: string; name: string } | null>(null);
  const [uploading, setUploading] = useState(false);
  const [keyboardVisible, setKeyboardVisible] = useState(false);
  const [keyboardHeight, setKeyboardHeight] = useState(0);
  const [inputBarHeight, setInputBarHeight] = useState(60);
  const [infoSheetOpen, setInfoSheetOpen] = useState(false);
  // Lightbox = Carousel über ALLE geteilten Bilder der Konversation. Tippt man
  // ein Bild an, öffnet die Galerie bei dessen Index und man kann horizontal
  // zwischen allen Bildern wischen.
  const [lightboxImages, setLightboxImages] = useState<string[]>([]);
  const [lightboxIndex, setLightboxIndex] = useState(0);
  // Aus welchem Kontext die Lightbox geöffnet wurde. 'chat' = Root-Instanz,
  // 'info' = genestet im Info-Sheet (liegt zuverlässig über dem FormSheet-Modal).
  const [lightboxSource, setLightboxSource] = useState<'chat' | 'info'>('chat');
  const [savingImage, setSavingImage] = useState(false);
  const lightboxOpen = lightboxImages.length > 0;

  // messagesRef, damit openLightbox (useCallback []) immer die aktuelle Bildliste
  // sieht, ohne bei jeder neuen Nachricht neu erzeugt zu werden.
  const messagesRef = useRef(messages);
  messagesRef.current = messages;

  const openLightbox = useCallback((uri: string, source: 'chat' | 'info' = 'chat') => {
    const urls = messagesRef.current
      .filter((m) => m.attachmentType === 'image' && m.attachmentUrl)
      .map((m) => m.attachmentUrl as string);
    const list = urls.length ? urls : [uri];
    setLightboxSource(source);
    setLightboxImages(list);
    setLightboxIndex(Math.max(0, list.indexOf(uri)));
  }, []);
  const closeLightbox = useCallback(() => {
    setLightboxImages([]);
  }, []);

  // Voice recording
  const [isVoiceRecording, setIsVoiceRecording] = useState(false);
  const [voiceDuration, setVoiceDuration] = useState(0);
  const [voiceLevel, setVoiceLevel] = useState(0);
  const [voiceTrashFlash, setVoiceTrashFlash] = useState(false);
  const voiceRecordingRef = useRef<Audio.Recording | null>(null);
  const voiceTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const voiceStartTime = useRef(0);

  // Konversation gewechselt (expo-router reused den DM-Screen). Reply-Draft
  // wegräumen und sofort auf den Cache der neuen Konversation umschalten:
  // vorhanden → instant sichtbar, sonst leeren (Spinner via isLoading).
  useEffect(() => {
    if (messagesUserId.current === userId) return;
    setReplyTo(null);
    const cached = queryClient.getQueryData<DmData>(['dm-history', userId]);
    setMessages(cached?.messages ?? []);
    setPartner(cached?.partner ?? null);
    partnerRef.current = cached?.partner ?? null;
    messagesUserId.current = userId;
  }, [userId, queryClient]);

  // Keyboard-Sichtbarkeit tracken + scroll to end beim Schließen
  useEffect(() => {
    const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
    const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
    const show = Keyboard.addListener(showEvent, (e) => {
      setKeyboardHeight(e.endCoordinates.height);
      setKeyboardVisible(true);
      requestAnimationFrame(() => scrollToBottom(false));
    });
    const hide = Keyboard.addListener(hideEvent, () => {
      setKeyboardHeight(0);
      setKeyboardVisible(false);
      setTimeout(() => scrollToBottom(false), 50);
    });
    return () => { show.remove(); hide.remove(); };
  }, []);

  // Wenn User zurücknavigiert, soll die Conversation-Liste sofort neu laden
  // (unread-Badge soll verschwinden — Backend hat bereits markDmsAsRead beim GET aufgerufen)
  useFocusEffect(
    useCallback(() => {
      return () => {
        queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
      };
    }, [queryClient]),
  );

  // DM-History laden — stale-while-revalidate: gecachte Messages werden sofort
  // gezeigt (useState-Seed oben + Sync-Effekt unten), im Hintergrund frisch
  // gefetcht und gemerged. gcTime hält den Cache über Navigation hinweg, sodass
  // ein Reopen instant ist statt jedes Mal die ganze History neu zu ziehen.
  const { data: historyData, isLoading, isFetching } = useQuery<DmData>({
    queryKey: ['dm-history', userId],
    queryFn: async () => {
      const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
      const msgs: ChatMsg[] = data.messages.map((m: any) => ({
        id: m.id,
        userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
        nickname: m.isOwn ? 'Du' : data.partner?.nickname ?? '?',
        avatar: m.isOwn ? null : data.partner?.avatar ?? null,
        content: m.content,
        replyTo: m.replyTo
          ? {
              id: m.replyTo.id,
              userId: m.replyTo.senderId,
              nickname:
                m.replyTo.senderId === myUserId ? 'Du' : data.partner?.nickname ?? '?',
              content: m.replyTo.content?.slice(0, 100) ?? '',
              attachmentType: m.replyTo.attachmentType ?? null,
            }
          : null,
        attachmentUrl: m.attachmentUrl ?? null,
        attachmentType: m.attachmentType ?? null,
        attachmentName: m.attachmentName ?? null,
        likesCount: m.likesCount ?? 0,
        likedByMe: m.likedByMe ?? false,
        createdAt: m.createdAt,
        isOwn: m.isOwn,
        readAt: m.readAt,
        reactions: m.reactions ?? [],
        deleted: m.deleted ?? false,
      }));
      return { partner: data.partner, messages: msgs };
    },
    enabled: !!userId && !!myUserId,
    staleTime: 30_000,
    gcTime: 30 * 60_000,
  });

  // Cache → lokaler State. Lokaler State bleibt Render-Source-of-Truth, damit
  // Realtime-Inserts & optimistische Sends ihn direkt mutieren können; der
  // Merge bewahrt lokale Extras (temp-* / noch-nicht-gefetchte Realtime-Msgs).
  useEffect(() => {
    if (!historyData) return;
    setPartner(historyData.partner);
    partnerRef.current = historyData.partner;
    setMessages((prev) => {
      const base = messagesUserId.current === userId ? prev : [];
      messagesUserId.current = userId;
      return mergeMessages(historyData.messages, base);
    });
  }, [historyData, userId]);

  // Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen
  useEffect(() => {
    if (messages.length === 0) return;
    requestAnimationFrame(() => scrollToBottom(true));
  }, [messages.length]);

  // Realtime: neue DMs vom Partner
  const onDmInsert = useCallback(
    (row: any) => {
      if (row.receiver_id !== myUserId) return;
      setMessages((prev) => {
        if (prev.some((m) => m.id === row.id)) return prev;
        const p = partnerRef.current;
        return [
          ...prev,
          {
            id: row.id,
            userId: row.sender_id,
            nickname: p?.nickname ?? '?',
            avatar: p?.avatar ?? null,
            content: row.content ?? '',
            replyTo: null,
            attachmentUrl: row.attachment_url ?? null,
            attachmentType: row.attachment_type ?? null,
            attachmentName: row.attachment_name ?? null,
            likesCount: row.likes_count ?? 0,
            likedByMe: false,
            createdAt: row.created_at,
            isOwn: false,
            readAt: null,
          },
        ];
      });
      // Nachricht kam live rein WÄHREND der Chat offen ist → serverseitig als
      // gelesen markieren. markDmsAsRead läuft nur im History-GET, also den
      // invalidieren (refetch markiert read). Sonst bleibt der Tab-Bar-Badge
      // hängen, weil dm-conversations die Live-Message als unread zählt.
      queryClient.invalidateQueries({ queryKey: ['dm-history', userId] });
    },
    [myUserId, queryClient, userId],
  );
  // Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch.
  const refetchHistory = useCallback(() => {
    queryClient.invalidateQueries({ queryKey: ['dm-history', userId] });
  }, [queryClient, userId]);
  useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory);

  // Typing-Indicator (ephemerer Broadcast, kein DB-Write)
  const { partnerTyping, setComposing, sendStopTyping } = useDmTyping(myUserId, userId);

  // Darf der User den Partner anrufen? (gegenseitiger Follow + callsEnabled).
  // Steuert Sichtbarkeit des Call-Buttons im Header.
  const { data: canCallData } = useQuery({
    queryKey: ['can-call', userId],
    queryFn: () => apiFetch<{ canCall: boolean }>(`/api/chat/can-call/${userId}`),
    enabled: !!userId && !!myUserId,
    staleTime: 60_000,
  });
  const canCall = canCallData?.canCall ?? false;

  function startCall() {
    if (!userId || !partner) return;
    // Native WebRTC fehlt im aktuellen Build → ehrlicher Hinweis statt Crash.
    if (!isWebRTCAvailable()) {
      Alert.alert(t('chat.call'), t('call.needs_rebuild'));
      return;
    }
    useCallStore
      .getState()
      .startCall(
        { id: userId, nickname: partner.nickname ?? '?', avatar: partner.avatar ?? null },
        { id: me?.id ?? myUserId ?? '', nickname: me?.nickname ?? 'Du', avatar: me?.avatar ?? null },
      )
      .catch((e: any) => console.log('[CALL] startCall error:', e?.message ?? e));
    router.push('/call' as any);
  }

  async function pickImage() {
    const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
    if (!perm.granted) {
      Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body'));
      return;
    }
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Images,
      quality: 0.8,
    });
    if (!result.canceled && result.assets[0]?.uri) {
      const a = result.assets[0];
      setAttachment({ uri: a.uri, name: a.fileName ?? `image-${Date.now()}.jpg` });
    }
  }

  async function uploadAttachment(file: { uri: string; name: string }): Promise<{ url: string; type: string; name: string } | null> {
    try {
      setUploading(true);
      const ext = file.name.split('.').pop() || 'jpg';
      const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
      const base64 = await FileSystem.readAsStringAsync(file.uri, {
        encoding: FileSystem.EncodingType.Base64,
      });
      const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary');
      const bytes = new Uint8Array(binary.length);
      for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
      const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, {
        cacheControl: '3600',
        upsert: false,
        contentType: 'image/jpeg',
      });
      if (error) throw error;
      const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
      return { url: data.publicUrl, type: 'image', name: file.name };
    } catch (err: any) {
      Alert.alert(t('chat.upload_failed'), err?.message ?? '');
      return null;
    } finally {
      setUploading(false);
    }
  }

  async function handleSend() {
    const content = inputText.trim();
    if (!content && !attachment) return;
    if (sending || uploading) return;

    const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
    const currentReplyTo = replyTo;
    const currentAttachment = attachment;

    const optimisticMsg: ChatMsg = {
      id: tempId,
      userId: myUserId ?? '',
      nickname: 'Du',
      avatar: null,
      content,
      replyTo: currentReplyTo
        ? {
            id: currentReplyTo.id,
            userId: myUserId ?? '',
            nickname: currentReplyTo.nickname,
            content: currentReplyTo.content,
            attachmentType: null,
          }
        : null,
      attachmentUrl: currentAttachment?.uri ?? null,
      attachmentType: currentAttachment ? 'image' : null,
      attachmentName: currentAttachment?.name ?? null,
      likesCount: 0,
      likedByMe: false,
      createdAt: new Date().toISOString(),
      isOwn: true,
      readAt: null,
      status: 'pending',
    };

    setMessages((prev) => [...prev, optimisticMsg]);
    setInputText('');
    setAttachment(null);
    setReplyTo(null);
    setSending(true);
    sendStopTyping();
    // Fokus 1× re-assertieren reicht — die mehrfach-focus-Aufrufe waren cargo-cult.
    // Wichtiger: Send-Button bleibt mounted solange `sending` true ist (siehe
    // Render-Bedingung unten), dadurch fällt das Touch-Target nicht weg und
    // die Tastatur bleibt stehen.
    inputRef.current?.focus();

    try {
      let attachmentMeta: { url: string; type: string; name: string } | null = null;
      if (currentAttachment) {
        attachmentMeta = await uploadAttachment(currentAttachment);
        if (!attachmentMeta) {
          setMessages((prev) =>
            prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
          );
          setSending(false);
          return;
        }
      }

      const newMsg = await apiFetch<any>('/api/chat/dm', {
        method: 'POST',
        body: {
          receiverId: userId,
          content,
          replyToId: currentReplyTo?.id,
          attachmentUrl: attachmentMeta?.url,
          attachmentType: attachmentMeta?.type,
          attachmentName: attachmentMeta?.name,
        },
      });

      setMessages((prev) =>
        prev.map((m) =>
          m.id === tempId
            ? {
                ...m,
                id: newMsg.id,
                content: newMsg.content,
                attachmentUrl: newMsg.attachmentUrl ?? null,
                attachmentType: newMsg.attachmentType ?? null,
                attachmentName: newMsg.attachmentName ?? null,
                createdAt: newMsg.createdAt,
                status: 'sent' as const,
              }
            : m,
        ),
      );

      queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
    } catch (err) {
      console.error('DM send failed:', err);
      setMessages((prev) =>
        prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
      );
    } finally {
      setSending(false);
    }
  }

  async function toggleLike(msg: ChatMsg) {
    try {
      const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', {
        method: 'POST',
        body: { messageId: msg.id, type: 'dm' },
      });
      setMessages((prev) =>
        prev.map((m) =>
          m.id === msg.id
            ? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) }
            : m,
        ),
      );
    } catch {}
  }

  // Optimistisches Anwenden einer Reaktion auf die aggregierte Pills-Liste
  // (WhatsApp-Toggle: gleiches Emoji entfernt, anderes ersetzt meine Reaktion).
  function applyReactionOptimistic(
    reactions: MessageReaction[],
    emoji: string,
  ): MessageReaction[] {
    const list = reactions.map((r) => ({ ...r }));
    const mine = list.find((r) => r.mine);
    const toggledOff = mine?.emoji === emoji;
    if (mine) {
      mine.count -= 1;
      mine.mine = false;
    }
    const cleaned = list.filter((r) => r.count > 0);
    if (toggledOff) return cleaned;
    const existing = cleaned.find((r) => r.emoji === emoji);
    if (existing) {
      existing.count += 1;
      existing.mine = true;
    } else {
      cleaned.push({ emoji, count: 1, mine: true });
    }
    return cleaned;
  }

  async function toggleReaction(msg: ChatMsg, emoji: string) {
    setMessages((prev) =>
      prev.map((m) =>
        m.id === msg.id
          ? { ...m, reactions: applyReactionOptimistic(m.reactions ?? [], emoji) }
          : m,
      ),
    );
    try {
      await apiFetch('/api/chat/reaction', {
        method: 'POST',
        body: { messageId: msg.id, emoji },
      });
    } catch {
      refetchHistory(); // Rollback via Server-State
    }
  }

  function deleteMessage(msg: ChatMsg) {
    Alert.alert(
      t('chat.delete_confirm_title'),
      t('chat.delete_confirm_msg'),
      [
        { text: t('common.cancel'), style: 'cancel' },
        {
          text: t('chat.delete'),
          style: 'destructive',
          onPress: async () => {
            setMessages((prev) =>
              prev.map((m) =>
                m.id === msg.id
                  ? { ...m, deleted: true, content: '', attachmentUrl: null, attachmentType: null, reactions: [] }
                  : m,
              ),
            );
            try {
              await apiFetch('/api/chat/delete-message', {
                method: 'POST',
                body: { messageId: msg.id },
              });
            } catch {
              refetchHistory();
            }
          },
        },
      ],
    );
  }

  function startReply(msg: ChatMsg) {
    setReplyTo({
      id: msg.id,
      nickname: msg.nickname ?? '?',
      content: msg.content?.slice(0, 100) ||
        (msg.attachmentType === 'image' ? `📷 ${t('chat.photo')}` :
         msg.attachmentType === 'audio' ? `🎤 ${t('chat.voice_message')}` : ''),
    });
  }

  async function startVoiceRecording() {
    if (isVoiceRecording || sending) return;
    const { status } = await Audio.requestPermissionsAsync();
    if (status !== 'granted') {
      Alert.alert(t('chat.mic_access_title'), t('chat.mic_access_body'));
      return;
    }
    try {
      await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
      const rec = new Audio.Recording();
      await rec.prepareToRecordAsync({ ...Audio.RecordingOptionsPresets.HIGH_QUALITY, isMeteringEnabled: true });
      await rec.startAsync();
      voiceRecordingRef.current = rec;
      voiceStartTime.current = Date.now();
      setVoiceDuration(0);
      setIsVoiceRecording(true);
      voiceTimerRef.current = setInterval(async () => {
        setVoiceDuration(Math.floor((Date.now() - voiceStartTime.current) / 1000));
        try {
          const s = await voiceRecordingRef.current?.getStatusAsync();
          if (s?.isRecording && s.metering !== undefined) {
            setVoiceLevel(Math.max(0, Math.min(1, (s.metering + 60) / 60)));
          }
        } catch {}
      }, 200);
    } catch {
      await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {});
    }
  }

  async function cancelVoiceRecording() {
    const rec = voiceRecordingRef.current;
    voiceRecordingRef.current = null;
    if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; }
    setVoiceDuration(0);
    setVoiceLevel(0);
    setVoiceTrashFlash(true);
    setTimeout(() => {
      setVoiceTrashFlash(false);
      setIsVoiceRecording(false);
    }, 350);
    try { await rec?.stopAndUnloadAsync(); } catch {}
    await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
  }

  async function stopAndSendVoice() {
    if (!isVoiceRecording) return;
    if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; }
    const duration = voiceDuration;
    setIsVoiceRecording(false);
    setVoiceDuration(0);
    setVoiceLevel(0);
    const rec = voiceRecordingRef.current;
    voiceRecordingRef.current = null;
    if (!rec) return;
    try { await rec.stopAndUnloadAsync(); } catch {}
    await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
    const uri = rec.getURI();
    if (!uri) return;
    await sendVoiceMessage(uri, formatVoiceDuration(duration));
  }

  async function sendVoiceMessage(uri: string, durationStr: string) {
    if (sending) return;
    const tempId = `temp-voice-${Date.now()}`;
    const optimisticMsg: ChatMsg = {
      id: tempId,
      userId: myUserId ?? '',
      nickname: 'Du',
      avatar: null,
      content: '',
      replyTo: null,
      attachmentUrl: uri,
      attachmentType: 'audio',
      attachmentName: durationStr,
      likesCount: 0,
      likedByMe: false,
      createdAt: new Date().toISOString(),
      isOwn: true,
      readAt: null,
      status: 'pending',
    };
    setMessages((prev) => [...prev, optimisticMsg]);
    setSending(true);
    try {
      setUploading(true);
      const ext = uri.split('.').pop() ?? 'm4a';
      const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
      const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
      const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary');
      const bytes = new Uint8Array(binary.length);
      for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
      const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, {
        cacheControl: '3600',
        upsert: false,
        contentType: 'audio/m4a',
      });
      setUploading(false);
      if (error) throw error;
      const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
      const newMsg = await apiFetch<any>('/api/chat/dm', {
        method: 'POST',
        body: { receiverId: userId, content: '', attachmentUrl: data.publicUrl, attachmentType: 'audio', attachmentName: durationStr },
      });
      setMessages((prev) =>
        prev.map((m) =>
          m.id === tempId
            ? { ...m, id: newMsg.id, attachmentUrl: newMsg.attachmentUrl, createdAt: newMsg.createdAt, status: 'sent' as const }
            : m
        )
      );
      queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
    } catch (err) {
      console.error('Voice send failed:', err);
      setMessages((prev) => prev.map((m) => m.id === tempId ? { ...m, status: 'failed' as const } : m));
      setUploading(false);
    } finally {
      setSending(false);
    }
  }

  // Bild aus der Lightbox in die Fotos-App sichern. Remote-URLs müssen erst
  // lokal heruntergeladen werden, da saveToLibraryAsync eine file:// URI braucht.
  async function saveImage(uri: string) {
    if (savingImage) return;
    // In einem älteren Build ohne eincompiliertes expo-media-library existieren
    // die JS-Wrapper zwar, der native Teil aber nicht → der Call würde tief drin
    // mit "Cannot read property ... of undefined" failen. requireOptionalNative-
    // Module gibt null zurück (statt zu werfen), wenn das native Modul fehlt →
    // saubere Vorab-Prüfung mit klarer Meldung.
    if (!requireOptionalNativeModule('ExpoMediaLibrary')) {
      Alert.alert(t('chat.save_failed'), t('chat.save_needs_rebuild'));
      return;
    }
    const MediaLibrary: MediaLibraryModule = require('expo-media-library');
    try {
      setSavingImage(true);
      const perm = await MediaLibrary.requestPermissionsAsync();
      if (!perm.granted) {
        Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body'));
        return;
      }
      let localUri = uri;
      if (!uri.startsWith('file://')) {
        const ext = uri.split('?')[0].split('.').pop() || 'jpg';
        const target = `${FileSystem.cacheDirectory}save-${Date.now()}.${ext}`;
        const res = await FileSystem.downloadAsync(uri, target);
        localUri = res.uri;
      }
      await MediaLibrary.saveToLibraryAsync(localUri);
      Alert.alert(t('chat.image_saved'));
    } catch (err: any) {
      Alert.alert(t('chat.save_failed'), err?.message ?? '');
    } finally {
      setSavingImage(false);
    }
  }

  function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
    if (!a || !b) return false;
    if (a.userId !== b.userId) return false;
    return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
  }

  return (
    <SafeAreaView style={styles.container} edges={['top']}>
      <View style={[styles.header, { backgroundColor: colors.bg }]}>
        <TouchableOpacity style={styles.backBtn} onPress={() => router.back()} hitSlop={8} activeOpacity={0.7}>
          <Ionicons name="chevron-back" size={22} color={colors.text} />
        </TouchableOpacity>

        {/* Avatar + Name + Chevron — tap → Info-Sheet (ersetzt den alten i-Button) */}
        <TouchableOpacity
          style={styles.headerCenter}
          activeOpacity={0.7}
          onPress={() => setInfoSheetOpen(true)}
        >
          <View style={{ marginRight: 8 }}>
            <UserAvatar
              userId={userId ?? null}
              avatar={partner?.avatar ?? null}
              nickname={partner?.nickname ?? '?'}
              size="md"
            />
          </View>
          <View style={{ flexShrink: 1 }}>
            <View style={{ flexDirection: 'row', alignItems: 'center', gap: 3 }}>
              <Text style={styles.headerName} numberOfLines={1}>
                {partner?.nickname ?? '…'}
              </Text>
              <Ionicons name="chevron-forward" size={15} color={colors.textMuted} />
            </View>
            {userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
          </View>
        </TouchableOpacity>

        {/* Call-Button — nur wenn erlaubt (gegenseitiger Follow + callsEnabled) */}
        {canCall && (
          <TouchableOpacity
            style={styles.infoBtn}
            hitSlop={8}
            activeOpacity={0.7}
            onPress={startCall}
          >
            <Ionicons name="call-outline" size={23} color={colors.text} />
          </TouchableOpacity>
        )}
      </View>

      <View style={{ flex: 1, backgroundColor: chatBg }}>
        {(isLoading || isFetching) && messages.length === 0 ? (
          <View style={styles.loadingBox}>
            <ActivityIndicator color={colors.brandOrange} />
          </View>
        ) : messages.length === 0 ? (
          <View style={styles.loadingBox}>
            <Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
            <Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
          </View>
        ) : (
          <FlatList
            ref={flatListRef}
            data={messages}
            style={{ flex: 1 }}
            renderItem={({ item, index }) => (
              <ChatBubble
                msg={item}
                isDM
                isFirstInGroup={!sameAuthor(messages[index - 1], item)}
                isLastInGroup={!sameAuthor(item, messages[index + 1])}
                onReply={startReply}
                onLike={toggleLike}
                onReact={toggleReaction}
                onDelete={deleteMessage}
                onOpenImage={openLightbox}
                cleanBg
              />
            )}
            keyExtractor={(m) => m.id}
            contentContainerStyle={{
              paddingHorizontal: 0,
              paddingTop: 12,
              // Tastatur offen: Input-Bar floatet (per transform) über der Tastatur,
              // der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap).
              // Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom,
              // schiebt die Bar um insets.bottom NACH OBEN über den Content → diese
              // Überlappung als Clearance abziehen. +16 = mittlerer Gap (nicht so eng
              // wie +4, nicht so hoch wie die alte inputBarHeight-Variante).
              paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 16,
            }}
            showsVerticalScrollIndicator={false}
            keyboardDismissMode="interactive"
            keyboardShouldPersistTaps="handled"
            onContentSizeChange={() => scrollToBottom(false)}
            onLayout={() => scrollToBottom(false)}
          />
        )}
      </View>

      <KeyboardStickyView
        offset={{ closed: -insets.bottom, opened: 0 }}
        style={{ backgroundColor: colors.bg }}
      >
        <View
          onLayout={(e) => {
            const h = e.nativeEvent.layout.height;
            if (Math.abs(h - inputBarHeight) > 1) setInputBarHeight(h);
          }}
          style={[
            styles.inputBar,
            {
              paddingBottom: 8,
              backgroundColor: colors.bg,
              borderTopColor: colors.border,
            },
          ]}
        >
          {replyTo && (
            <View style={[styles.replyBar, { backgroundColor: colors.surface }]}>
              <Ionicons name="arrow-undo" size={14} color="#007AFF" style={{ marginRight: 6 }} />
              <View style={{ flex: 1 }}>
                <Text style={styles.replyName} numberOfLines={1}>
                  {t('chat.reply_to')} {replyTo.nickname}
                </Text>
                <Text style={[styles.replyContent, { color: colors.textMuted }]} numberOfLines={1}>
                  {replyTo.content || '…'}
                </Text>
              </View>
              <TouchableOpacity hitSlop={10} onPress={() => setReplyTo(null)} activeOpacity={0.7}>
                <Ionicons name="close" size={16} color={colors.textMuted} />
              </TouchableOpacity>
            </View>
          )}
          {attachment && (
            <View style={[styles.attachBar, { backgroundColor: colors.surface }]}>
              <Image source={{ uri: attachment.uri }} style={styles.attachImg} contentFit="cover" />
              <Text style={[styles.attachName, { color: colors.text }]} numberOfLines={1}>
                {attachment.name}
              </Text>
              <TouchableOpacity hitSlop={10} onPress={() => setAttachment(null)} activeOpacity={0.7}>
                <Ionicons name="close" size={16} color={colors.textMuted} />
              </TouchableOpacity>
            </View>
          )}
          {isVoiceRecording ? (
            <View style={styles.voiceRecBarWrap}>
              <VoiceRecordingBar
                duration={voiceDuration}
                level={voiceLevel}
                trashFlash={voiceTrashFlash}
                onCancel={cancelVoiceRecording}
                onSend={stopAndSendVoice}
                sendIcon="arrow-up"
                accentColor="#007AFF"
              />
            </View>
          ) : (
            <View style={styles.inputRow}>
              <TouchableOpacity
                activeOpacity={0.7}
                style={[styles.addBtn, { backgroundColor: colors.surfaceElevated }]}
                onPress={pickImage}
                disabled={uploading || sending}
              >
                <Ionicons name="add" size={22} color={colors.textMuted} />
              </TouchableOpacity>
              <TextInput
                ref={inputRef}
                style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
                placeholder={t('chat.placeholder')}
                placeholderTextColor={colors.textMuted}
                value={inputText}
                onChangeText={(v) => {
                  setInputText(v);
                  // Heartbeat aktiv solange Text vorhanden (Feld ist beim
                  // onChangeText zwangsläufig fokussiert). Hält „tippt" stabil
                  // über Denkpausen — onBlur/Senden/Leeren stoppt ihn.
                  setComposing(v.trim().length > 0);
                }}
                onBlur={() => sendStopTyping()}
                multiline
                maxLength={2000}
                returnKeyType="send"
                onSubmitEditing={handleSend}
                // Insta/WA-Style: nach dem Senden bleibt die Tastatur offen
                // (Fokus bleibt am Input), bis der User woanders hin tippt.
                blurOnSubmit={false}
                // editable NICHT auf !sending setzen — das wäre der Grund warum
                // die Tastatur nach Send dismisst (non-editable TextInput → iOS
                // forciert Blur → Keyboard weg). User darf während des Sendens
                // schon die nächste Nachricht tippen (wie WhatsApp/Insta).
                editable={!uploading}
              />
              {(inputText.trim().length > 0 || attachment) ? (
                <TouchableOpacity
                  style={styles.sendBtn}
                  onPress={handleSend}
                  disabled={uploading}
                  activeOpacity={0.7}
                >
                  <Ionicons name="send" size={16} color="#fff" />
                </TouchableOpacity>
              ) : (
                <TouchableOpacity
                  style={[styles.addBtn, { backgroundColor: colors.surfaceElevated }]}
                  onPress={startVoiceRecording}
                  disabled={sending}
                  activeOpacity={0.7}
                >
                  <Ionicons name="mic" size={20} color={colors.textMuted} />
                </TouchableOpacity>
              )}
            </View>
          )}
        </View>
      </KeyboardStickyView>

      {/* ── Info-Sheet ─────────────────────────────────────────────── */}
      <DmInfoSheet
        visible={infoSheetOpen}
        onClose={() => setInfoSheetOpen(false)}
        partner={partner}
        partnerUserId={userId ?? null}
        messages={messages}
        onImagePress={(uri) => openLightbox(uri, 'info')}
        onViewProfile={() => {
          setInfoSheetOpen(false);
          setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
        }}
        colors={colors}
        t={t}
        lightbox={
          <MediaLightbox
            visible={lightboxOpen && lightboxSource === 'info'}
            images={lightboxImages}
            index={lightboxIndex}
            onIndexChange={setLightboxIndex}
            onClose={closeLightbox}
            onSave={saveImage}
            saving={savingImage}
          />
        }
      />

      {/* ── Lightbox (Chat-Kontext) ──────────────────────────────────
          Root-Instanz für Taps auf Bilder im Chatverlauf. Die Info-Sheet-
          Instanz wird genested im DmInfoSheet gerendert (siehe oben). */}
      <MediaLightbox
        visible={lightboxOpen && lightboxSource === 'chat'}
        images={lightboxImages}
        index={lightboxIndex}
        onIndexChange={setLightboxIndex}
        onClose={closeLightbox}
        onSave={saveImage}
        saving={savingImage}
      />
    </SafeAreaView>
  );
}

// ─── DmInfoSheet ─────────────────────────────────────────────────────────────

const MEDIA_COL = 3;
const MEDIA_GAP = 2;
const MEDIA_SIZE = (Dimensions.get('window').width - MEDIA_GAP * (MEDIA_COL + 1)) / MEDIA_COL;

function DmInfoSheet({
  visible,
  onClose,
  partner,
  partnerUserId,
  messages,
  onImagePress,
  onViewProfile,
  colors,
  t,
  lightbox,
}: {
  visible: boolean;
  onClose: () => void;
  partner: { id: string; nickname: string; avatar?: string | null } | null;
  partnerUserId: string | null;
  messages: ChatMsg[];
  onImagePress: (uri: string) => void;
  onViewProfile: () => void;
  colors: ReturnType<typeof useColors>;
  t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
  // Genestete Lightbox — liegt über dem FormSheet-Modal, damit das Sheet beim
  // Bild-Öffnen erhalten bleibt (kein Kontextwechsel zurück zur DM).
  lightbox?: ReactNode;
}) {
  const sharedMedia = messages.filter(
    (m) => m.attachmentType === 'image' && m.attachmentUrl,
  );

  return (
    <FormSheet
      visible={visible}
      onClose={onClose}
      title={t('chat.info')}
      initialHeightPct={0.85}
      dismissOnBackdrop
    >
      <ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
        {/* Partner-Karte — Avatar via UserAvatar (rendert auch Listen-/Default-
            Avatare, nicht nur eigene Foto-URLs), Pfeil direkt neben dem Namen. */}
        <TouchableOpacity
          activeOpacity={0.7}
          onPress={onViewProfile}
          style={{ flexDirection: 'row', alignItems: 'center', padding: 16, gap: 14 }}
        >
          <UserAvatar
            userId={partnerUserId}
            avatar={partner?.avatar ?? null}
            nickname={partner?.nickname ?? '?'}
            size="lg"
          />
          <View style={{ flex: 1 }}>
            <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
              <Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }} numberOfLines={1}>
                {partner?.nickname ?? '…'}
              </Text>
              <Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
            </View>
            <Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2 }}>
              {t('dm.view_profile')}
            </Text>
          </View>
        </TouchableOpacity>

        <View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.border, marginHorizontal: 16 }} />

        {/* Geteilte Medien */}
        <View style={{ paddingHorizontal: 16, paddingTop: 20, paddingBottom: 12, flexDirection: 'row', alignItems: 'baseline', gap: 6 }}>
          <Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text }}>
            {t('dm.shared_media')}
          </Text>
          {sharedMedia.length > 0 && (
            <Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
              {sharedMedia.length}
            </Text>
          )}
        </View>

        {sharedMedia.length === 0 ? (
          <View style={{ alignItems: 'center', paddingVertical: 32 }}>
            <Ionicons name="images-outline" size={40} color={colors.textMuted} />
            <Text style={{ marginTop: 10, fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
              {t('dm.no_shared_media')}
            </Text>
          </View>
        ) : (
          <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: MEDIA_GAP, paddingHorizontal: MEDIA_GAP }}>
            {[...sharedMedia].reverse().map((m) => (
              <TouchableOpacity
                key={m.id}
                activeOpacity={0.8}
                onPress={() => onImagePress(m.attachmentUrl!)}
              >
                <Image
                  source={{ uri: m.attachmentUrl! }}
                  style={{ width: MEDIA_SIZE, height: MEDIA_SIZE }}
                  contentFit="cover"
                  cachePolicy="memory-disk"
                />
              </TouchableOpacity>
            ))}
          </View>
        )}
      </ScrollView>
      {lightbox}
    </FormSheet>
  );
}

function makeStyles(colors: ReturnType<typeof useColors>) {
  return StyleSheet.create({
    container: { flex: 1, backgroundColor: colors.bg },
    header: {
      flexDirection: 'row',
      alignItems: 'center',
      paddingHorizontal: 12,
      paddingVertical: 10,
    },
    backBtn: {
      padding: 8,
      alignItems: 'center',
      justifyContent: 'center',
    },
    headerCenter: {
      flex: 1,
      flexDirection: 'row',
      alignItems: 'center',
      marginLeft: 8,
    },
    infoBtn: {
      padding: 8,
      alignItems: 'center',
      justifyContent: 'center',
    },
    headerName: {
      fontSize: 15,
      fontFamily: 'Nunito_700Bold',
      color: colors.text,
      flexShrink: 1,
    },
    loadingBox: {
      flex: 1,
      alignItems: 'center',
      justifyContent: 'center',
    },
    emptyText: {
      fontSize: 13,
      fontFamily: 'Nunito_600SemiBold',
      color: colors.textMuted,
      marginTop: 12,
    },
    inputBar: {
      borderTopWidth: StyleSheet.hairlineWidth,
    },
    replyBar: {
      flexDirection: 'row',
      alignItems: 'center',
      paddingHorizontal: 12,
      paddingVertical: 8,
      borderLeftWidth: 3,
      borderLeftColor: '#007AFF',
      marginHorizontal: 8,
      marginTop: 6,
      borderRadius: 8,
    },
    replyName: {
      fontSize: 11,
      fontFamily: 'Nunito_700Bold',
      color: '#007AFF',
    },
    replyContent: {
      fontSize: 11,
      fontFamily: 'Nunito_400Regular',
      marginTop: 1,
    },
    attachBar: {
      flexDirection: 'row',
      alignItems: 'center',
      paddingHorizontal: 12,
      paddingVertical: 6,
      marginHorizontal: 8,
      marginTop: 6,
      borderRadius: 8,
    },
    attachImg: {
      width: 36,
      height: 36,
      borderRadius: 6,
      marginRight: 8,
    },
    attachName: {
      flex: 1,
      fontSize: 12,
      fontFamily: 'Nunito_600SemiBold',
    },
    inputRow: {
      flexDirection: 'row',
      alignItems: 'flex-end',
      gap: 8,
      paddingHorizontal: 12,
      paddingTop: 8,
    },
    addBtn: {
      width: 44,
      height: 44,
      borderRadius: 22,
      alignItems: 'center',
      justifyContent: 'center',
    },
    textInput: {
      flex: 1,
      borderRadius: 22,
      paddingVertical: 9,
      paddingHorizontal: 16,
      fontSize: 15,
      fontFamily: 'Nunito_400Regular',
      maxHeight: 120,
    },
    sendBtn: {
      width: 44,
      height: 44,
      borderRadius: 22,
      backgroundColor: '#007AFF',
      alignItems: 'center',
      justifyContent: 'center',
    },
    sendBtnDisabled: {
      opacity: 0.4,
    },
    voiceRecBarWrap: {
      flexDirection: 'row',
      marginHorizontal: 12,
      marginTop: 8,
      marginBottom: 4,
    },
  });
}
