import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:flutter/rendering.dart' show ScrollCacheExtent;
import 'package:flutter/services.dart' show Clipboard, ClipboardData;

import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/attachment_dialog.dart';
import 'package:pyramid/features/chat/chat_composer.dart';
import 'package:pyramid/features/chat/chat_provider.dart';
import 'package:pyramid/features/chat/message_group.dart';
import 'package:pyramid/features/chat/pinned_messages_panel.dart';
import 'package:pyramid/widgets/hover_region.dart';
import 'package:pyramid/widgets/mxc_image.dart';

import 'package:pyramid/core/notification_service.dart';
import 'package:pyramid/core/voip_manager.dart';

class ChatView extends ConsumerStatefulWidget {
  final String roomId;
  const ChatView({super.key, required this.roomId});

  @override
  ConsumerState<ChatView> createState() => _ChatViewState();
}

class _ChatViewState extends ConsumerState<ChatView> {
  final _scrollCtrl = ScrollController();
  Event? _replyTo;
  bool _loadingHistory = false;
  bool _historyExhausted = false;
  bool _draggingOver = false;
  bool _pinnedOpen = false;
  bool _profileExpanded = false;
  bool _showScrollDown = false;
  final Set<String> _selectedIds = {};

  // Unread state captured at session open (before marking as read)
  String? _sessionFullyReadId;
  bool _sessionHasUnread = false;
  bool _sessionCaptured = false;
  bool _didScrollToUnread = false;
  bool _loadingFuture = false;
  // Fade-out of the "Neue Nachrichten" divider once the user reaches the bottom.
  bool _unreadFading = false;
  Timer? _unreadFadeTimer;

  // Jump-to-message (from search): scroll to + briefly highlight a target event.
  final _jumpKey = GlobalKey();
  String? _jumpEventId;
  String? _highlightEventId;
  bool _highlightOn = false;
  bool _jumpInProgress = false;
  Timer? _highlightTimer;

  void _startJump(String eventId) {
    _highlightTimer?.cancel();
    setState(() {
      _jumpEventId = eventId;
      _highlightEventId = eventId;
      _highlightOn = true;
      _jumpInProgress = true;
    });
    // Consume the request so it doesn't re-fire.
    ref.read(pendingJumpEventProvider.notifier).state = null;
    WidgetsBinding.instance.addPostFrameCallback((_) => _attemptJump(0));
  }

  void _attemptJump(int attempt) {
    if (!mounted || _jumpEventId == null) return;
    final ctx = _jumpKey.currentContext;
    if (ctx != null) {
      Scrollable.ensureVisible(
        ctx,
        alignment: 0.4,
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeOut,
      );
      _jumpInProgress = false;
      // Hold the highlight briefly, then fade it out smoothly.
      _highlightTimer?.cancel();
      _highlightTimer = Timer(const Duration(milliseconds: 1300), () {
        if (!mounted) return;
        setState(() => _highlightOn = false); // triggers the AnimatedOpacity fade
        _highlightTimer = Timer(const Duration(milliseconds: 950), () {
          if (mounted) setState(() { _highlightEventId = null; _jumpEventId = null; });
        });
      });
      return;
    }
    if (attempt >= 40) {
      // Couldn't reach the event — give up gracefully (room is at least open).
      setState(() { _jumpInProgress = false; _highlightEventId = null; _jumpEventId = null; _highlightOn = false; });
      return;
    }
    // Not built yet — load older history toward the target every few tries.
    final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
    if (timeline != null && timeline.canRequestHistory && attempt % 3 == 0) {
      timeline.requestHistory(historyCount: 100).catchError((_) {});
    }
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Future.delayed(const Duration(milliseconds: 120), () => _attemptJump(attempt + 1));
    });
  }

  // GlobalKey for the "Neue Nachrichten" divider — used by ensureVisible to
  // scroll precisely to the divider on chat open.
  final _unreadDividerKey = GlobalKey();

  bool get _inSelectionMode => _selectedIds.isNotEmpty;

  void _toggleSelect(String eventId) {
    setState(() {
      if (_selectedIds.contains(eventId)) {
        _selectedIds.remove(eventId);
      } else {
        _selectedIds.add(eventId);
      }
    });
  }

  void _clearSelection() => setState(() => _selectedIds.clear());

  @override
  void initState() {
    super.initState();
    _scrollCtrl.addListener(_onScroll);
  }

  @override
  void didUpdateWidget(ChatView oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.roomId != widget.roomId) {
      _profileExpanded = false;
      _sessionFullyReadId = null;
      _sessionHasUnread = false;
      _sessionCaptured = false;
      _didScrollToUnread = false;
      _loadingFuture = false;
      _historyExhausted = false;
      _unreadFading = false;
      _unreadFadeTimer?.cancel();
      _jumpEventId = null;
      _highlightEventId = null;
      _highlightOn = false;
      _jumpInProgress = false;
      _highlightTimer?.cancel();
      // The GlobalKey stays alive — it'll simply have no context until the
      // new room's divider (if any) is built.
    }
  }

  @override
  void dispose() {
    _unreadFadeTimer?.cancel();
    _highlightTimer?.cancel();
    _scrollCtrl.dispose();
    super.dispose();
  }

  void _captureAndMarkRead(Timeline timeline) {
    if (!_sessionCaptured) {
      final room = timeline.room;
      final fullyRead = room.fullyRead; // "" if never set
      final newFullyReadId = fullyRead.isNotEmpty ? fullyRead : null;

      // Show the divider only if there's a genuinely unread VISIBLE message
      // FROM SOMEONE ELSE after the read marker. Own messages and hidden events
      // (reactions, receipts, edits) don't count — otherwise the divider ends
      // up below all messages (e.g. right after one's own latest message).
      final me = room.client.userID;
      final events = timeline.events; // newest-first
      final frIndex =
          fullyRead.isEmpty ? -1 : events.indexWhere((e) => e.eventId == fullyRead);
      // Indices [0, upTo) are newer than the read marker.
      final upTo = frIndex >= 0 ? frIndex : events.length;
      var newHasUnread = false;
      for (var i = 0; i < upTo; i++) {
        final e = events[i];
        if (_MessageList._isVisible(e) && e.senderId != me) {
          newHasUnread = true;
          break;
        }
      }

      _sessionCaptured = true;

      // setState so _MessageList rebuilds with the captured unread marker.
      if (mounted) {
        setState(() {
          _sessionFullyReadId = newFullyReadId;
          _sessionHasUnread = newHasUnread;
        });
        if (newHasUnread) {
          WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToUnread(timeline));
        }
      }
    }
    _markRead(timeline);
  }

  void _scrollToUnread(Timeline timeline) {
    if (_didScrollToUnread) return;
    _attemptScrollToUnread(0);
  }

  // The "Neue Nachrichten" divider may not be laid out yet on first open (lazy
  // reverse ListView only builds visible + cacheExtent items). Retry until its
  // context exists, nudging the scroll upward periodically so the list builds
  // toward the divider when there are many unread messages.
  void _attemptScrollToUnread(int attempt) {
    if (_didScrollToUnread || !mounted) return;
    final ctx = _unreadDividerKey.currentContext;
    if (ctx != null) {
      _didScrollToUnread = true;
      // alignment 0.85: in a reverse list this lands the divider near the top
      // of the viewport with a little read context above it and the unread
      // messages filling the screen below.
      Scrollable.ensureVisible(
        ctx,
        alignment: 0.85,
        duration: const Duration(milliseconds: 250),
        curve: Curves.easeOut,
      );
      return;
    }
    if (attempt >= 24) {
      _didScrollToUnread = true; // give up — stay at the newest messages
      return;
    }
    if (attempt > 0 && attempt % 4 == 0 && _scrollCtrl.hasClients) {
      final pos = _scrollCtrl.position;
      final target = (pos.pixels + 1500).clamp(0.0, pos.maxScrollExtent);
      if (target > pos.pixels) _scrollCtrl.jumpTo(target);
    }
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Future.delayed(const Duration(milliseconds: 70),
          () => _attemptScrollToUnread(attempt + 1));
    });
  }

  void _markRead([Timeline? tl]) {
    // Cancel any pending notification for this room (user is now reading it).
    cancelNotificationForRoom(widget.roomId);

    final sendReceipts = ref.read(privacyReadReceiptsProvider);
    if (!sendReceipts) return;
    final room = ref.read(roomProvider(widget.roomId));
    if (room == null) return;
    final timeline = tl ?? ref.read(timelineProvider(widget.roomId)).valueOrNull;
    final latest = timeline?.events.firstOrNull;
    if (latest != null && latest.status.isSynced) {
      room.setReadMarker(latest.eventId, mRead: latest.eventId)
          .catchError((_) {});
    } else {
      room.markUnread(false).catchError((_) {});
    }
  }

  Future<void> _onScroll() async {
    if (!_scrollCtrl.hasClients) return;
    final pixels = _scrollCtrl.position.pixels;
    final maxExtent = _scrollCtrl.position.maxScrollExtent;

    if (pixels < 60) {
      _markRead(null);
      // Reached the newest messages → the "Neue Nachrichten" divider is no
      // longer useful. Fade it out, then remove it.
      if (_sessionHasUnread && !_unreadFading) {
        setState(() => _unreadFading = true);
        _unreadFadeTimer?.cancel();
        _unreadFadeTimer = Timer(const Duration(milliseconds: 900), () {
          if (mounted) setState(() { _sessionHasUnread = false; _unreadFading = false; });
        });
      }
    }
    final shouldShow = pixels > 400;
    if (shouldShow != _showScrollDown) {
      setState(() => _showScrollDown = shouldShow);
    }

    final timeline = ref.read(timelineProvider(widget.roomId)).valueOrNull;
    if (timeline == null) return;

    // Backward pagination — older events near the top. We keep requesting until
    // a request genuinely returns nothing new (real start of room), tracked via
    // _historyExhausted, so we neither stop early nor hammer the server.
    if (!_loadingHistory &&
        !_historyExhausted &&
        pixels >= maxExtent - 600) {
      _loadingHistory = true;
      final oldestBefore = timeline.events.isNotEmpty ? timeline.events.last.eventId : null;
      final lenBefore = timeline.events.length;
      try {
        await timeline.requestHistory(historyCount: 60);
      } catch (_) {}
      final oldestAfter = timeline.events.isNotEmpty ? timeline.events.last.eventId : null;
      // Nothing older arrived → we've reached the beginning of the room.
      if (timeline.events.length == lenBefore && oldestBefore == oldestAfter) {
        _historyExhausted = true;
      }
      await Future.delayed(const Duration(milliseconds: 200));
      _loadingHistory = false;
    }

    // Forward pagination — only relevant after jumping to a historical anchor
    // (e.g. from search). At the live edge canRequestFuture is false, so this
    // won't fire during normal reading and can't pull the view back down.
    if (!_loadingFuture &&
        timeline.canRequestFuture &&
        pixels < 200) {
      _loadingFuture = true;
      try {
        await timeline.requestFuture(historyCount: 60);
      } catch (_) {}
      await Future.delayed(const Duration(milliseconds: 250));
      _loadingFuture = false;
    }
  }

  @override
  Widget build(BuildContext context) {
    final pt = PyramidTheme.of(context);
    final room = ref.watch(roomProvider(widget.roomId));
    final timelineAsync = ref.watch(timelineProvider(widget.roomId));
    final membersOpen = ref.watch(membersPanelOpenProvider);

    // A search result requested a jump to a specific message. Using watch (not
    // listen) so it also fires when this ChatView is freshly mounted for a
    // DIFFERENT room — listen would miss a value set before registration.
    final pendingJump = ref.watch(pendingJumpEventProvider);
    if (pendingJump != null && !_jumpInProgress && pendingJump != _jumpEventId) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (mounted &&
            !_jumpInProgress &&
            ref.read(pendingJumpEventProvider) == pendingJump) {
          _startJump(pendingJump);
        }
      });
    }

    final isDesktop = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
    final body = Stack(
        fit: StackFit.expand,
        children: [
          Row(
            children: [
              Expanded(
                child: Container(
                  color: pt.bg1,
                  child: Column(
                    children: [
                      if (_inSelectionMode)
                        _SelectionHeader(
                          pt: pt,
                          count: _selectedIds.length,
                          selectedIds: Set.from(_selectedIds),
                          room: room,
                          timeline: ref.read(timelineProvider(widget.roomId)).valueOrNull,
                          currentUserId: room?.client.userID ?? '',
                          onClear: _clearSelection,
                          onReply: () {
                            final tl = ref.read(timelineProvider(widget.roomId)).valueOrNull;
                            if (tl == null) return;
                            try {
                              final event = tl.events.firstWhere((e) => e.eventId == _selectedIds.first);
                              setState(() { _replyTo = event; _selectedIds.clear(); });
                            } catch (_) {}
                          },
                        )
                      else if (room != null && room.isDirectChat)
                        // DM: aufklappbarer Banner-Header — Avatar und Name
                        // skalieren mit, Banner und Bio blenden ein.
                        // ValueKey: pro Raum ein frischer State, damit beim
                        // schnellen Kanalwechsel keinerlei Daten (Avatar,
                        // Banner, laufende Futures) vom Vorgänger übrigbleiben.
                        _DmExpandingHeader(
                          key: ValueKey('dmheader:${room.id}'),
                          room: room,
                          roomId: widget.roomId,
                          pt: pt,
                          expanded: _profileExpanded,
                          onToggle: () => setState(
                              () => _profileExpanded = !_profileExpanded),
                          membersOpen: membersOpen,
                          pinnedOpen: _pinnedOpen,
                          onToggleMembers: () => ref
                              .read(membersPanelOpenProvider.notifier)
                              .update((s) => !s),
                          onOpenSearch: () =>
                              ref.read(activeModalProvider.notifier).state =
                                  ModalKind.search,
                          onTogglePinned: () =>
                              setState(() => _pinnedOpen = !_pinnedOpen),
                        )
                      else
                        _ChatHeader(
                          room: room,
                          roomId: widget.roomId,
                          pt: pt,
                          membersOpen: membersOpen,
                          pinnedOpen: _pinnedOpen,
                          onToggleMembers: () => ref
                              .read(membersPanelOpenProvider.notifier)
                              .update((s) => !s),
                          onOpenSearch: () =>
                              ref.read(activeModalProvider.notifier).state =
                                  ModalKind.search,
                          onTogglePinned: () =>
                              setState(() => _pinnedOpen = !_pinnedOpen),
                        ),
                      const _ConnectionBanner(),
                      Expanded(
                        child: Stack(
                          children: [
                            timelineAsync.when(
                              loading: () => Center(
                                child: CircularProgressIndicator(
                                  strokeWidth: 2,
                                  color: pt.accent,
                                ),
                              ),
                              error: (e, _) => Center(
                                child: Text(
                                  'Could not load messages',
                                  style: TextStyle(color: pt.fgDim, fontSize: 14),
                                ),
                              ),
                              data: (timeline) {
                                WidgetsBinding.instance.addPostFrameCallback(
                                    (_) => _captureAndMarkRead(timeline));
                                return _MessageList(
                                  timeline: timeline,
                                  scrollCtrl: _scrollCtrl,
                                  currentUserId: room?.client.userID ?? '',
                                  pt: pt,
                                  onReply: (event) => setState(() => _replyTo = event),
                                  selectedIds: _selectedIds,
                                  onToggleSelect: _toggleSelect,
                                  sessionFullyReadId: _sessionFullyReadId,
                                  sessionHasUnread: _sessionHasUnread,
                                  unreadFading: _unreadFading,
                                  unreadDividerKey: _sessionHasUnread ? _unreadDividerKey : null,
                                  jumpEventId: _jumpEventId,
                                  jumpKey: _jumpKey,
                                  highlightEventId: _highlightEventId,
                                  highlightOn: _highlightOn,
                                );
                              },
                            ),
                            if (_showScrollDown)
                              Positioned(
                                right: 16,
                                bottom: 12,
                                child: AnimatedOpacity(
                                  opacity: _showScrollDown ? 1 : 0,
                                  duration: const Duration(milliseconds: 150),
                                  child: GestureDetector(
                                    onTap: () => _scrollCtrl.animateTo(
                                      0,
                                      duration: const Duration(milliseconds: 300),
                                      curve: Curves.easeOut,
                                    ),
                                    child: Container(
                                      width: 36,
                                      height: 36,
                                      decoration: BoxDecoration(
                                        color: pt.bg0,
                                        borderRadius: BorderRadius.circular(18),
                                        border: Border.all(color: pt.border),
                                        boxShadow: [
                                          BoxShadow(
                                            color: Colors.black.withAlpha(80),
                                            blurRadius: 8,
                                            offset: const Offset(0, 2),
                                          ),
                                        ],
                                      ),
                                      child: Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: pt.fgMuted),
                                    ),
                                  ),
                                ),
                              ),
                          ],
                        ),
                      ),
                      if (room != null) _TypingIndicator(room: room, pt: pt),
                      if (room != null)
                        ChatComposer(
                          room: room,
                          replyTo: _replyTo,
                          onClearReply: () => setState(() => _replyTo = null),
                        ),
                    ],
                  ),
                ),
              ),
              if (_pinnedOpen)
                PinnedMessagesPanel(
                  roomId: widget.roomId,
                  onClose: () => setState(() => _pinnedOpen = false),
                ),
            ],
          ),
          // Drag-and-drop overlay — bleibt gemountet und blendet animiert
          // ein/aus, damit der Übergang flüssig statt abrupt ist.
          Positioned.fill(
            child: IgnorePointer(
              child: AnimatedOpacity(
                opacity: _draggingOver ? 1 : 0,
                duration: const Duration(milliseconds: 160),
                curve: Curves.easeOutCubic,
                child: _DragOverlay(pt: pt, active: _draggingOver),
              ),
            ),
          ),
        ],
    );

    if (!isDesktop) return body;

    return DropTarget(
      onDragEntered: (_) => setState(() => _draggingOver = true),
      onDragExited: (_) => setState(() => _draggingOver = false),
      onDragDone: (details) async {
        setState(() => _draggingOver = false);
        if (room == null) return;
        final files = details.files.map((xf) => File(xf.path)).toList();
        if (files.isEmpty) return;
        if (!mounted) return;
        await AttachmentDialog.show(context, files, room, replyTo: _replyTo);
        if (mounted) setState(() => _replyTo = null);
      },
      child: body,
    );
  }
}

class _ChatHeader extends ConsumerWidget {
  final Room? room;
  final String roomId;
  final PyramidTheme pt;
  final bool membersOpen;
  final bool pinnedOpen;
  final VoidCallback onToggleMembers;
  final VoidCallback onOpenSearch;
  final VoidCallback onTogglePinned;

  const _ChatHeader({
    required this.room,
    required this.roomId,
    required this.pt,
    required this.membersOpen,
    required this.pinnedOpen,
    required this.onToggleMembers,
    required this.onOpenSearch,
    required this.onTogglePinned,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final name = room?.getLocalizedDisplayname() ?? roomId;
    final isDm = room?.isDirectChat ?? false;
    final isEncrypted = room?.encrypted ?? false;
    final topic = room?.topic ?? (isDm ? '' : 'Welcome to #$name');
    final client = ref.watch(matrixClientProvider).valueOrNull;
    final size = MediaQuery.sizeOf(context);
    final isMobile = size.width < 900 || size.height < 500;

    return Container(
      height: 52,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      decoration: BoxDecoration(
        color: pt.bg1,
        border: Border(bottom: BorderSide(color: pt.border)),
      ),
      child: Row(
        children: [
          // Back button on mobile
          if (isMobile) ...[
            GestureDetector(
              onTap: () => ref.read(activeRoomIdProvider.notifier).state = null,
              child: Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
            ),
            const SizedBox(width: 12),
          ],
          // Room icon / avatar
          if (isDm && client != null) ...[
            _DmHeaderAvatar(room: room, name: name, client: client),
          ] else if (!isDm) ...[
            Icon(
              isEncrypted ? Icons.lock_outline_rounded : Icons.tag_rounded,
              size: 16,
              color: pt.fgDim,
            ),
          ],
          const SizedBox(width: 8),
          // Name + presence for DMs
          if (isDm)
            Expanded(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    name,
                    style: TextStyle(
                        color: pt.fg,
                        fontSize: 15,
                        fontWeight: FontWeight.w600),
                    overflow: TextOverflow.ellipsis,
                  ),
                  if (room != null && client != null)
                    _PresenceLine(
                      userId: room!.directChatMatrixID,
                      client: client,
                      pt: pt,
                    ),
                ],
              ),
            )
          else
            Text(
              name,
              style: TextStyle(
                  color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
            ),
          const SizedBox(width: 12),
          if (!isDm && topic.isNotEmpty) ...[
            Container(width: 1, height: 18, color: pt.border),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                topic,
                style: TextStyle(color: pt.fgMuted, fontSize: 13),
                overflow: TextOverflow.ellipsis,
              ),
            ),
          ] else if (!isDm)
            const Spacer(),
          // Right actions
          Row(
            children: [
              if (isDm) ...[
                PyrIconBtn(
                  size: 28,
                  icon: const Icon(Icons.phone_rounded),
                  tooltip: 'Voice call',
                  onPressed: () => _startCall(ref, room, cam: false),
                ),
                PyrIconBtn(
                  size: 28,
                  icon: const Icon(Icons.videocam_rounded),
                  tooltip: 'Video call',
                  onPressed: () => _startCall(ref, room, cam: true),
                ),
                if (size.width > 700)
                  Container(
                    width: 1,
                    height: 20,
                    margin: const EdgeInsets.symmetric(horizontal: 4),
                    color: pt.border,
                  ),
              ],
              if (size.width > 800) ...[
                PyrIconBtn(
                  size: 28,
                  icon: const Icon(Icons.inbox_rounded),
                  tooltip: 'Threads',
                  onPressed: () {},
                ),
                PyrIconBtn(
                  size: 28,
                  icon: const Icon(Icons.push_pin_rounded),
                  tooltip: 'Angepinnte Nachrichten',
                  active: pinnedOpen,
                  onPressed: onTogglePinned,
                ),
              ],
              PyrIconBtn(
                size: 28,
                icon: const Icon(Icons.search_rounded),
                tooltip: 'Search',
                onPressed: onOpenSearch,
              ),
              PyrIconBtn(
                size: 28,
                icon: const Icon(Icons.group_rounded),
                tooltip: 'Members',
                active: membersOpen,
                onPressed: onToggleMembers,
              ),
            ],
          ),
        ],
      ),
    );
  }

  void _startCall(WidgetRef ref, Room? room, {required bool cam}) async {
    if (room == null) return;
    
    if (room.isDirectChat) {
      // Use native Matrix VoIP for 1:1 DMs
      ref.read(viewModeProvider.notifier).state = ViewMode.voice;
      await PyramidVoipManager.instance.startCall(room.id, video: cam);
      return;
    }

    // Use LiveKit for group voice rooms
    ref.read(viewModeProvider.notifier).state = ViewMode.voice;
    await ref.read(callStateProvider).startCall(
      roomName: roomId,
      roomDisplayName: room.getLocalizedDisplayname(),
      identity: 'user_${DateTime.now().millisecondsSinceEpoch}',
      audioOnly: !cam,
    );
  }
}

/// DM-Header, der sich per Klick aufklappt: Das Banner blendet als Hintergrund
/// ein, Avatar und Name skalieren mit, Handle + Bio erscheinen darunter
/// (Design nach Nutzer-Mockup, Twitter/Discord-artig).
class _DmExpandingHeader extends ConsumerStatefulWidget {
  final Room room;
  final String roomId;
  final PyramidTheme pt;
  final bool expanded;
  final VoidCallback onToggle;
  final bool membersOpen;
  final bool pinnedOpen;
  final VoidCallback onToggleMembers;
  final VoidCallback onOpenSearch;
  final VoidCallback onTogglePinned;

  const _DmExpandingHeader({
    super.key,
    required this.room,
    required this.roomId,
    required this.pt,
    required this.expanded,
    required this.onToggle,
    required this.membersOpen,
    required this.pinnedOpen,
    required this.onToggleMembers,
    required this.onOpenSearch,
    required this.onTogglePinned,
  });

  @override
  ConsumerState<_DmExpandingHeader> createState() =>
      _DmExpandingHeaderState();
}

class _DmExpandingHeaderState extends ConsumerState<_DmExpandingHeader> {
  static const _animDuration = Duration(milliseconds: 280);
  static const _animCurve = Curves.easeOutCubic;

  Profile? _profile;
  String? _bannerMxcUri;
  String? _statusMsg;
  bool _online = false;

  String? get _partnerId => widget.room.directChatMatrixID;

  @override
  void initState() {
    super.initState();
    // Direkt laden, damit das Banner beim ersten Aufklappen schon da ist.
    _load();
  }

  @override
  void didUpdateWidget(_DmExpandingHeader old) {
    super.didUpdateWidget(old);
    if (old.room.id != widget.room.id) {
      _profile = null;
      _bannerMxcUri = null;
      _statusMsg = null;
      _online = false;
      _load();
    }
  }

  Future<void> _load() async {
    final userId = _partnerId;
    if (userId == null) return;
    final client = widget.room.client;
    // Race-Guard: Beim schnellen DM-Wechsel dürfen verspätete Antworten des
    // VORHERIGEN Partners das Profil des aktuellen nicht überschreiben.
    bool stillCurrent() => mounted && _partnerId == userId;
    try {
      final p = await client.getProfileFromUserId(userId);
      if (stillCurrent()) setState(() => _profile = p);
    } catch (_) {}
    try {
      final p = await client.fetchCurrentPresence(userId);
      final last = p.lastActiveTimestamp;
      final fresh = p.currentlyActive == true ||
          (last != null &&
              DateTime.now().difference(last) < const Duration(minutes: 5));
      if (stillCurrent()) {
        setState(() {
          _online = p.presence == PresenceType.online && fresh;
          _statusMsg = p.statusMsg;
        });
      }
    } catch (_) {}
    // Banner aus dem Profil-Custom-Field (io.pyramid.profile.banner_url).
    try {
      final apiUri = client.homeserver!.replace(
        path: '/_matrix/client/v3/profile/${Uri.encodeComponent(userId)}',
      );
      final resp = await client.httpClient.get(
        apiUri,
        headers: {'Authorization': 'Bearer ${client.accessToken}'},
      );
      if (resp.statusCode == 200) {
        final data = jsonDecode(resp.body) as Map<String, dynamic>;
        final url = data['io.pyramid.profile.banner_url'] as String?;
        if (url != null && stillCurrent()) {
          setState(() => _bannerMxcUri = url);
        }
      }
    } catch (_) {}
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    final expanded = widget.expanded;
    final size = MediaQuery.sizeOf(context);
    final isMobile = size.width < 900 || size.height < 500;
    final client = ref.watch(matrixClientProvider).valueOrNull;

    final userId = _partnerId ?? '';
    final member = widget.room.unsafeGetUserFromMemoryOrFallback(userId);
    final name = _profile?.displayName ??
        member.displayName ??
        widget.room.getLocalizedDisplayname();
    final avatarUrl =
        _profile?.avatarUrl ?? member.avatarUrl ?? widget.room.avatar;
    final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';

    // Im Querformat (wenig Höhe) kompakter aufklappen.
    final expandedHeight = size.height < 500 ? 140.0 : 190.0;
    final avatarSize = expanded
        ? (size.height < 500 ? 72.0 : 96.0)
        : 32.0;

    return AnimatedContainer(
      duration: _animDuration,
      curve: _animCurve,
      height: expanded ? expandedHeight : 52,
      decoration: BoxDecoration(
        color: pt.bg1,
        border: Border(bottom: BorderSide(color: pt.border)),
      ),
      clipBehavior: Clip.hardEdge,
      child: Stack(
        fit: StackFit.expand,
        children: [
          // Banner als Hintergrund — blendet beim Aufklappen ein.
          if (_bannerMxcUri != null && client != null)
            Positioned.fill(
              child: AnimatedOpacity(
                opacity: expanded ? 1 : 0,
                duration: _animDuration,
                curve: Curves.easeOut,
                child: MxcImage(
                  mxcUri: _bannerMxcUri!,
                  client: client,
                  fit: BoxFit.cover,
                  placeholder: const SizedBox.shrink(),
                ),
              ),
            ),
          // Scrim: links dunkel, damit Avatar/Text lesbar bleiben (Mockup).
          if (_bannerMxcUri != null)
            Positioned.fill(
              child: AnimatedOpacity(
                opacity: expanded ? 1 : 0,
                duration: _animDuration,
                child: DecoratedBox(
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      begin: Alignment.centerLeft,
                      end: Alignment.centerRight,
                      colors: [
                        pt.bg1,
                        pt.bg1.withAlpha(210),
                        pt.bg1.withAlpha(0),
                      ],
                      stops: const [0.0, 0.32, 0.72],
                    ),
                  ),
                ),
              ),
            ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Row(
              children: [
                if (isMobile) ...[
                  GestureDetector(
                    onTap: () =>
                        ref.read(activeRoomIdProvider.notifier).state = null,
                    child:
                        Icon(Icons.arrow_back_rounded, size: 22, color: pt.fg),
                  ),
                  const SizedBox(width: 12),
                ],
                // Klickbarer Profilbereich: Avatar + Name + Details
                Expanded(
                  child: MouseRegion(
                    cursor: SystemMouseCursors.click,
                    child: GestureDetector(
                      behavior: HitTestBehavior.opaque,
                      onTap: widget.onToggle,
                      child: Row(
                        children: [
                          // Avatar — skaliert mit (rund, wie im Mockup)
                          AnimatedContainer(
                            duration: _animDuration,
                            curve: _animCurve,
                            width: avatarSize,
                            height: avatarSize,
                            decoration: BoxDecoration(
                              shape: BoxShape.circle,
                              color: pt.bg3,
                            ),
                            clipBehavior: Clip.antiAlias,
                            child: avatarUrl != null && client != null
                                ? MxcImage(
                                    mxcUri: avatarUrl.toString(),
                                    client: client,
                                    fit: BoxFit.cover,
                                    placeholder: _initialBox(initial, pt),
                                  )
                                : _initialBox(initial, pt),
                          ),
                          AnimatedContainer(
                            duration: _animDuration,
                            curve: _animCurve,
                            width: expanded ? 20 : 10,
                          ),
                          Expanded(
                            child: Column(
                              mainAxisAlignment: MainAxisAlignment.center,
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                // Name skaliert mit; Status blendet daneben ein
                                Row(
                                  crossAxisAlignment: CrossAxisAlignment.center,
                                  children: [
                                    Flexible(
                                      child: AnimatedDefaultTextStyle(
                                        duration: _animDuration,
                                        curve: _animCurve,
                                        style: TextStyle(
                                          color: pt.fg,
                                          fontSize: expanded ? 24 : 15,
                                          fontWeight: expanded
                                              ? FontWeight.w700
                                              : FontWeight.w600,
                                        ),
                                        child: Text(
                                          name,
                                          maxLines: 1,
                                          overflow: TextOverflow.ellipsis,
                                        ),
                                      ),
                                    ),
                                    AnimatedOpacity(
                                      opacity: expanded ? 1 : 0,
                                      duration: _animDuration,
                                      child: Padding(
                                        padding:
                                            const EdgeInsets.only(left: 8),
                                        child: Text(
                                          _online ? 'online' : 'offline',
                                          style: TextStyle(
                                            color: _online
                                                ? pt.online
                                                : pt.fgMuted,
                                            fontSize: 13,
                                            fontWeight: FontWeight.w500,
                                          ),
                                        ),
                                      ),
                                    ),
                                  ],
                                ),
                                // Eingeklappt: "zuletzt online vor…",
                                // aufgeklappt: Handle + Biographie.
                                AnimatedCrossFade(
                                  duration: _animDuration,
                                  sizeCurve: _animCurve,
                                  crossFadeState: expanded
                                      ? CrossFadeState.showSecond
                                      : CrossFadeState.showFirst,
                                  firstChild: client != null
                                      ? _PresenceLine(
                                          userId: _partnerId,
                                          client: client,
                                          pt: pt,
                                        )
                                      : const SizedBox.shrink(),
                                  secondChild: Column(
                                    crossAxisAlignment:
                                        CrossAxisAlignment.start,
                                    children: [
                                      const SizedBox(height: 4),
                                      Text(
                                        userId,
                                        style: TextStyle(
                                            color: pt.fgMuted, fontSize: 13),
                                        maxLines: 1,
                                        overflow: TextOverflow.ellipsis,
                                      ),
                                      if (_statusMsg != null &&
                                          _statusMsg!.isNotEmpty) ...[
                                        const SizedBox(height: 8),
                                        Text(
                                          _statusMsg!,
                                          style: TextStyle(
                                              color: pt.fgMuted,
                                              fontSize: 13,
                                              height: 1.35),
                                          maxLines: 2,
                                          overflow: TextOverflow.ellipsis,
                                        ),
                                      ],
                                    ],
                                  ),
                                ),
                              ],
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
                const SizedBox(width: 12),
                // Aktionen — bleiben immer sichtbar
                Row(
                  children: [
                    PyrIconBtn(
                      size: 28,
                      icon: const Icon(Icons.phone_rounded),
                      tooltip: 'Voice call',
                      onPressed: () => _startCall(cam: false),
                    ),
                    PyrIconBtn(
                      size: 28,
                      icon: const Icon(Icons.videocam_rounded),
                      tooltip: 'Video call',
                      onPressed: () => _startCall(cam: true),
                    ),
                    if (size.width > 700)
                      Container(
                        width: 1,
                        height: 20,
                        margin: const EdgeInsets.symmetric(horizontal: 4),
                        color: pt.border,
                      ),
                    if (size.width > 800)
                      PyrIconBtn(
                        size: 28,
                        icon: const Icon(Icons.push_pin_rounded),
                        tooltip: 'Angepinnte Nachrichten',
                        active: widget.pinnedOpen,
                        onPressed: widget.onTogglePinned,
                      ),
                    PyrIconBtn(
                      size: 28,
                      icon: const Icon(Icons.search_rounded),
                      tooltip: 'Search',
                      onPressed: widget.onOpenSearch,
                    ),
                    PyrIconBtn(
                      size: 28,
                      icon: const Icon(Icons.group_rounded),
                      tooltip: 'Members',
                      active: widget.membersOpen,
                      onPressed: widget.onToggleMembers,
                    ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _initialBox(String initial, PyramidTheme pt) => Center(
        child: FittedBox(
          fit: BoxFit.scaleDown,
          child: Padding(
            padding: const EdgeInsets.all(6),
            child: Text(
              initial,
              style: TextStyle(
                  color: pt.fg, fontSize: 28, fontWeight: FontWeight.w700),
            ),
          ),
        ),
      );

  void _startCall({required bool cam}) async {
    ref.read(viewModeProvider.notifier).state = ViewMode.voice;
    await PyramidVoipManager.instance.startCall(widget.room.id, video: cam);
  }
}

class _DmHeaderAvatar extends StatelessWidget {
  final Room? room;
  final String name;
  final Client client;

  const _DmHeaderAvatar({required this.room, required this.name, required this.client});

  @override
  Widget build(BuildContext context) {
    final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
    const size = 32.0;
    return MxcAvatar(
      mxcUri: room?.avatar,
      client: client,
      size: size,
      borderRadius: BorderRadius.circular(size / 2),
      placeholder: (_) => _InitialCircle(initial: initial, size: size),
    );
  }
}

class _InitialCircle extends StatelessWidget {
  final String initial;
  final double size;
  const _InitialCircle({required this.initial, required this.size});

  @override
  Widget build(BuildContext context) {
    const color = Color(0xFF06B6D4);
    return Container(
      width: size,
      height: size,
      decoration: const BoxDecoration(color: color, shape: BoxShape.circle),
      child: Center(
        child: Text(initial,
          style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600)),
      ),
    );
  }
}

class _PresenceLine extends StatefulWidget {
  final String? userId;
  final Client client;
  final PyramidTheme pt;

  const _PresenceLine({required this.userId, required this.client, required this.pt});

  @override
  State<_PresenceLine> createState() => _PresenceLineState();
}

class _PresenceLineState extends State<_PresenceLine> {
  CachedPresence? _presence;
  StreamSubscription<CachedPresence>? _sub;
  // Debounce timer: delay "online" updates 4s to filter out background-sync
  // false positives (push notifications briefly wake the recipient's client).
  Timer? _onlineDebounce;

  @override
  void initState() {
    super.initState();
    _load(widget.userId);
  }

  @override
  void didUpdateWidget(_PresenceLine oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.userId != widget.userId) {
      _sub?.cancel();
      _sub = null;
      _onlineDebounce?.cancel();
      setState(() => _presence = null);
      _load(widget.userId);
    }
  }

  @override
  void dispose() {
    _sub?.cancel();
    _onlineDebounce?.cancel();
    super.dispose();
  }

  // "online" only when the server's presence enum says online AND the last
  // activity is recent. Using currentlyActive (the old logic) reported online
  // long after the user left, because that flag lingers.
  static bool _isOnline(CachedPresence p) {
    if (p.presence != PresenceType.online) return false;
    final last = p.lastActiveTimestamp;
    return last == null || DateTime.now().difference(last).inSeconds < 90;
  }

  void _applyPresence(CachedPresence p) {
    _onlineDebounce?.cancel();
    if (_isOnline(p)) {
      // Wait 4 seconds before committing "online" to filter single-sync spikes
      // (a backgrounded client briefly reports online when a push wakes it).
      _onlineDebounce = Timer(const Duration(seconds: 4), () {
        if (mounted) setState(() => _presence = p);
      });
    } else {
      if (mounted) setState(() => _presence = p);
    }
  }

  Future<void> _load(String? userId) async {
    if (userId == null) return;
    // Never show our own presence in a DM header
    if (userId == widget.client.userID) return;
    try {
      final p = await widget.client.fetchCurrentPresence(userId);
      if (mounted) _applyPresence(p);
    } catch (_) {}
    _sub = widget.client.onPresenceChanged.stream
        .where((p) => p.userid == userId)
        .listen((p) => _applyPresence(p));
  }

  @override
  Widget build(BuildContext context) {
    final p = _presence;
    if (p == null) return const SizedBox.shrink();

    String text;
    Color color = widget.pt.fgDim;
    if (_isOnline(p)) {
      text = 'online';
      color = const Color(0xFF3BA55D);
    } else if (p.lastActiveTimestamp != null) {
      text = 'zuletzt online ${_relativeTime(p.lastActiveTimestamp!)}';
    } else {
      return const SizedBox.shrink();
    }

    return Text(
      text,
      style: TextStyle(color: color, fontSize: 11),
      maxLines: 1,
      overflow: TextOverflow.ellipsis,
    );
  }

  String _relativeTime(DateTime t) {
    final diff = DateTime.now().difference(t);
    if (diff.inMinutes < 1) return 'gerade eben';
    if (diff.inMinutes < 60) return 'vor ${diff.inMinutes}min';
    if (diff.inHours < 24) return 'vor ${diff.inHours}h';
    if (diff.inDays < 7) return 'vor ${diff.inDays}d';
    if (diff.inDays < 365) return 'vor ${(diff.inDays / 7).floor()}w';
    return 'vor ${(diff.inDays / 365).floor()}j';
  }
}

// Shows a slim banner when the client loses its connection to the homeserver.
// Debounced so brief transient sync errors don't flicker a banner.
class _ConnectionBanner extends ConsumerStatefulWidget {
  const _ConnectionBanner();
  @override
  ConsumerState<_ConnectionBanner> createState() => _ConnectionBannerState();
}

class _ConnectionBannerState extends ConsumerState<_ConnectionBanner> {
  StreamSubscription? _sub;
  Timer? _debounce;
  bool _lost = false;

  @override
  void initState() {
    super.initState();
    _attach();
  }

  Future<void> _attach() async {
    try {
      final client = await ref.read(matrixClientProvider.future);
      _sub = client.onSyncStatus.stream.listen((s) {
        if (s.status == SyncStatus.error) {
          // Only surface after the error persists a few seconds.
          _debounce ??= Timer(const Duration(seconds: 4), () {
            _debounce = null;
            if (mounted) setState(() => _lost = true);
          });
        } else if (s.status == SyncStatus.finished ||
            s.status == SyncStatus.processing) {
          _debounce?.cancel();
          _debounce = null;
          if (_lost && mounted) setState(() => _lost = false);
        }
      });
    } catch (_) {}
  }

  @override
  void dispose() {
    _sub?.cancel();
    _debounce?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final pt = PyramidTheme.of(context);
    return AnimatedSize(
      duration: const Duration(milliseconds: 200),
      curve: Curves.easeOut,
      alignment: Alignment.topCenter,
      child: _lost
          ? Container(
              width: double.infinity,
              color: pt.danger.withAlpha(36),
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.cloud_off_rounded, size: 14, color: pt.danger),
                  const SizedBox(width: 8),
                  Flexible(
                    child: Text(
                      'Keine Verbindung zum Server – Wiederverbindung läuft…',
                      style: TextStyle(color: pt.danger, fontSize: 12, fontWeight: FontWeight.w500),
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                ],
              ),
            )
          : const SizedBox.shrink(),
    );
  }
}

class _MessageList extends StatelessWidget {
  final Timeline timeline;
  final ScrollController scrollCtrl;
  final String currentUserId;
  final PyramidTheme pt;
  final ValueChanged<Event> onReply;
  final Set<String> selectedIds;
  final ValueChanged<String> onToggleSelect;
  final String? sessionFullyReadId;
  final bool sessionHasUnread;
  final bool unreadFading;
  /// Attached to the group-level _UnreadDivider so ensureVisible can scroll to it.
  final GlobalKey? unreadDividerKey;
  /// Jump-to-message target (from search): scroll to + highlight this event.
  final String? jumpEventId;
  final GlobalKey? jumpKey;
  final String? highlightEventId;
  final bool highlightOn;

  const _MessageList({
    required this.timeline,
    required this.scrollCtrl,
    required this.currentUserId,
    required this.pt,
    required this.onReply,
    required this.selectedIds,
    required this.onToggleSelect,
    this.sessionFullyReadId,
    this.sessionHasUnread = false,
    this.unreadFading = false,
    this.unreadDividerKey,
    this.jumpEventId,
    this.jumpKey,
    this.highlightEventId,
    this.highlightOn = false,
  });

  @override
  Widget build(BuildContext context) {
    // Dedupe by eventId — during concurrent forward/backward pagination the
    // timeline can transiently contain the same event twice, which would
    // produce duplicate ListView keys and crash the sliver (indexOf assertion).
    final seenIds = <String>{};
    final events = timeline.events
        .where(_isVisible)
        .where((e) => seenIds.add(e.eventId))
        .toList();

    if (events.isEmpty) {
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.forum_outlined, size: 48, color: pt.fgDim),
            const SizedBox(height: 16),
            Text(
              'No messages yet',
              style: TextStyle(color: pt.fgMuted, fontSize: 14),
            ),
          ],
        ),
      );
    }

    final groups = _buildGroups(events);
    final hasUnread = sessionHasUnread;

    // Resolve the effective fullyReadId: if the captured ID points to a
    // non-visible event (reaction, edit, state) walk the timeline forward
    // (toward older events) until we find the nearest visible one.
    String? fullyReadId = sessionFullyReadId;
    if (fullyReadId != null) {
      final inGroups = groups.any((g) => g.any((e) => e.eventId == fullyReadId));
      if (!inGroups) {
        final tlEvents = timeline.events; // newest-first
        final idx = tlEvents.indexWhere((e) => e.eventId == fullyReadId);
        if (idx >= 0) {
          fullyReadId = null;
          for (int i = idx; i < tlEvents.length; i++) {
            if (_isVisible(tlEvents[i])) {
              fullyReadId = tlEvents[i].eventId;
              break;
            }
          }
        } else {
          fullyReadId = null; // event not even in loaded timeline
        }
      }
    }

    // Read-receipt display — matching Element behaviour:
    //   • The newest unread own group shows ✓ (sent but not read)
    //   • The newest own group AT or BEFORE the partner's receipt shows ✓✓
    //   • If those are the same group → just ✓✓
    final room = timeline.room;

    // Read receipts (Element-style):
    //  • ✓✓ at the single newest event another member has read (any sender) —
    //    moves next to the partner's own reply when they reply/read everything.
    //  • grey ✓ only on my newest message while it's still unread by them.
    // timeline.events is newest-first → lower index = newer.
    //
    // Receipts können je nach Client in `global` ODER `mainThread` stehen
    // (threaded read receipts) — beide zusammenführen, neuere ts gewinnt.
    final mergedReceipts = <String, LatestReceiptStateData>{};
    for (final source in [
      room.receiptState.global.otherUsers,
      room.receiptState.mainThread?.otherUsers ?? const {},
    ]) {
      source.forEach((userId, data) {
        final existing = mergedReceipts[userId];
        if (existing == null || data.ts > existing.ts) {
          mergedReceipts[userId] = data;
        }
      });
    }

    int readIndex = -1;
    int latestReceiptTs = 0;
    mergedReceipts.forEach((userId, data) {
      if (userId == currentUserId) return;
      if (data.ts > latestReceiptTs) latestReceiptTs = data.ts;
      var ri = timeline.events.indexWhere((e) => e.eventId == data.eventId);
      // Receipt kann auf ein unsichtbares Event zeigen (Reaktion, Edit) —
      // zum nächstälteren sichtbaren Event vorrücken, sonst geht der Marker
      // verloren.
      while (ri >= 0 &&
          ri < timeline.events.length &&
          !_isVisible(timeline.events[ri])) {
        ri++;
      }
      if (ri >= timeline.events.length) ri = -1;
      if (ri >= 0 && (readIndex < 0 || ri < readIndex)) readIndex = ri;
    });
    final readEventIds =
        readIndex >= 0 ? {timeline.events[readIndex].eventId} : <String>{};

    // Newest own visible message, and whether it's still unread (newer than the
    // partner's read position, or no read position yet).
    final newestOwnIndex =
        timeline.events.indexWhere((e) => _isVisible(e) && e.senderId == currentUserId);
    var ownUnread = newestOwnIndex >= 0 && (readIndex < 0 || newestOwnIndex < readIndex);
    // Fallback: Das Receipt-Event liegt außerhalb des geladenen Fensters
    // (z.B. sehr alte Nachricht) — wenn der Receipt-Zeitstempel jünger ist als
    // meine neueste Nachricht, gilt sie trotzdem als gelesen.
    if (ownUnread &&
        readIndex < 0 &&
        latestReceiptTs >=
            timeline.events[newestOwnIndex].originServerTs.millisecondsSinceEpoch) {
      readEventIds.add(timeline.events[newestOwnIndex].eventId);
      ownUnread = false;
    }
    final lastOwnGroupKey = (ownUnread)
        ? () {
            for (final g in groups) {
              if (g.first.senderId == currentUserId) return 'g:${g.first.eventId}';
            }
            return null;
          }()
        : null;

    // Flatten groups + date dividers + unread divider into independent list items.
    // itemFullyReadIds[i] is non-null only for a group item that contains the
    // fullyRead event at a non-last position — MessageGroup renders an inline
    // divider after that event.
    final itemKeys = <String>[];
    final itemGroups = <List<Event>?>[];
    final itemDates = <DateTime?>[];
    final itemUnread = <bool>[];
    final itemFullyReadIds = <String?>[];

    bool unreadInserted = false;

    for (var i = 0; i < groups.length; i++) {
      final group = groups[i];
      // Use the newest event (group.last after reversal) for date comparison.
      final groupNewest = group.last.originServerTs;
      final nextGroupNewest = i < groups.length - 1 ? groups[i + 1].last.originServerTs : null;
      final isLastOfDay = nextGroupNewest == null || !_sameDay(groupNewest, nextGroupNewest);

      // Check if this group contains the effective fullyRead event.
      final groupContainsFullyRead = fullyReadId != null &&
          group.any((e) => e.eventId == fullyReadId);

      if (!unreadInserted && hasUnread && groupContainsFullyRead) {
        if (group.last.eventId == fullyReadId) {
          // fullyRead is the NEWEST event in the group → the whole group is
          // read.  Insert a group-level divider between this group and the
          // newer (lower-index) groups.
          itemKeys.add('unread');
          itemGroups.add(null);
          itemDates.add(null);
          itemUnread.add(true);
          itemFullyReadIds.add(null);
        }
        // Whether group-level or inline, we've now handled the marker.
        unreadInserted = true;
      }

      // Key based on oldest event (group.first) — stable identifier.
      itemKeys.add('g:${group.first.eventId}');
      itemGroups.add(group);
      itemDates.add(null);
      itemUnread.add(false);
      // Pass fullyReadId to the group only when the event is mid-group
      // (not the last event) so MessageGroup can render an inline divider.
      // groupContainsFullyRead already guarantees fullyReadId != null.
      final passedFullyReadId =
          (groupContainsFullyRead && group.last.eventId != fullyReadId)
              ? fullyReadId
              : null;
      itemFullyReadIds.add(passedFullyReadId);

      if (isLastOfDay) {
        final d = groupNewest;
        itemKeys.add('d:${d.year}-${d.month}-${d.day}');
        itemGroups.add(null);
        itemDates.add(d);
        itemUnread.add(false);
        itemFullyReadIds.add(null);
      }
    }

    // Fallback: fullyRead event is older than all loaded events — all visible
    // messages are unread. Insert divider above the oldest loaded group (top of list).
    if (!unreadInserted && hasUnread) {
      itemKeys.add('unread');
      itemGroups.add(null);
      itemDates.add(null);
      itemUnread.add(true);
      itemFullyReadIds.add(null);
    }

    final keyIndex = <String, int>{
      for (var i = 0; i < itemKeys.length; i++) itemKeys[i]: i,
    };

    return ListView.builder(
      controller: scrollCtrl,
      reverse: true,
      physics: const ClampingScrollPhysics(),
      padding: const EdgeInsets.only(bottom: 8),
      scrollCacheExtent: const ScrollCacheExtent.pixels(2500),
      itemCount: itemKeys.length,
      findChildIndexCallback: (Key key) {
        // Only return an index when the key still maps to that exact slot —
        // returning a stale/duplicate index trips the sliver indexOf assertion.
        if (key is ValueKey<String>) {
          final idx = keyIndex[key.value];
          if (idx != null && idx < itemKeys.length && itemKeys[idx] == key.value) {
            return idx;
          }
        }
        return null;
      },
      itemBuilder: (context, i) {
        final key = ValueKey(itemKeys[i]);
        if (itemUnread[i]) {
          // Wrap in SizedBox so the list can use the ValueKey for optimisation
          // while _UnreadDivider carries the GlobalKey for ensureVisible.
          return SizedBox(
            key: key,
            child: _UnreadDivider(key: unreadDividerKey, pt: pt, fading: unreadFading),
          );
        }
        final date = itemDates[i];
        if (date != null) return DateDivider(key: key, date: date);
        final group = itemGroups[i]!;
        final groupKey = itemKeys[i];
        // Show the sending/sent indicator on the newest own group.
        final showStatus = groupKey == lastOwnGroupKey;
        return MessageGroup(
          key: key,
          events: group,
          currentUserId: currentUserId,
          room: room,
          timeline: timeline,
          onReply: () => onReply(group.last),
          selectedIds: selectedIds,
          onToggleSelect: onToggleSelect,
          showStatusIndicator: showStatus,
          readEventIds: readEventIds,
          fullyReadId: itemFullyReadIds[i],
          jumpEventId: jumpEventId,
          jumpKey: jumpKey,
          highlightEventId: highlightEventId,
          highlightOn: highlightOn,
        );
      },
    );
  }

  List<List<Event>> _buildGroups(List<Event> events) {
    final groups = <List<Event>>[];
    List<Event>? current;

    for (final event in events) {
      if (current == null ||
          current.first.senderId != event.senderId ||
          event.originServerTs.difference(current.last.originServerTs).abs() >
              const Duration(minutes: 5)) {
        current = [event];
        groups.add(current);
      } else {
        current.add(event);
      }
    }
    // The timeline is newest-first, so each group is [newest, ..., oldest].
    // Reverse each group so messages render oldest-to-newest (top to bottom).
    return groups.map((g) => g.reversed.toList()).toList();
  }

  bool _sameDay(DateTime a, DateTime b) =>
      a.year == b.year && a.month == b.month && a.day == b.day;

  static bool _isVisible(Event e) {
    // NOTE: error-status events are kept visible so a failed send shows a
    // "not sent" state with retry/delete instead of silently disappearing.
    if (!{
      EventTypes.Message,
      EventTypes.Sticker,
      EventTypes.Encrypted,
    }.contains(e.type)) { return false; }
    if ({
      RelationshipTypes.edit,
      RelationshipTypes.reaction,
    }.contains(e.relationshipType)) { return false; }
    if (e.type == EventTypes.Redaction) { return false; }
    return true;
  }
}

class _SelectionHeader extends StatelessWidget {
  final PyramidTheme pt;
  final int count;
  final Set<String> selectedIds;
  final Room? room;
  final Timeline? timeline;
  final String currentUserId;
  final VoidCallback onClear;
  final VoidCallback onReply;

  const _SelectionHeader({
    required this.pt,
    required this.count,
    required this.selectedIds,
    required this.room,
    required this.timeline,
    required this.currentUserId,
    required this.onClear,
    required this.onReply,
  });

  List<Event> _resolveEvents() {
    if (timeline == null) return [];
    return selectedIds
        .map((id) {
          try { return timeline!.events.firstWhere((e) => e.eventId == id); } catch (_) { return null; }
        })
        .whereType<Event>()
        .toList();
  }

  void _copyAll() {
    final texts = _resolveEvents().map((e) => e.body).join('\n');
    Clipboard.setData(ClipboardData(text: texts));
  }

  void _deleteAll(BuildContext context) {
    for (final e in _resolveEvents()) {
      if (e.senderId == currentUserId) {
        e.redactEvent(reason: 'Vom Nutzer gelöscht').catchError((_) => null);
      }
    }
    onClear();
  }

  void _pinAll() {
    if (room == null) return;
    final pinned = List<String>.from(
      room!.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
    );
    for (final e in _resolveEvents()) {
      if (!pinned.contains(e.eventId)) pinned.add(e.eventId);
    }
    room!.setPinnedEvents(pinned).catchError((_) => '');
    onClear();
  }


  @override
  Widget build(BuildContext context) {
    final events = _resolveEvents();
    final canDelete = events.isNotEmpty && events.every((e) => e.senderId == currentUserId);
    final isTextOnly = events.every((e) => e.messageType == 'm.text');

    return Container(
      height: 52,
      padding: const EdgeInsets.symmetric(horizontal: 8),
      decoration: BoxDecoration(
        color: pt.accentSoft,
        border: Border(bottom: BorderSide(color: pt.accent.withAlpha(80))),
      ),
      child: Row(
        children: [
          IconButton(
            onPressed: onClear,
            icon: Icon(Icons.close_rounded, size: 20, color: pt.accent),
            tooltip: 'Auswahl aufheben',
            constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
            padding: EdgeInsets.zero,
          ),
          const SizedBox(width: 4),
          Text(
            '$count ausgewählt',
            style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600),
          ),
          const Spacer(),
          if (count == 1)
            _SelBtn(icon: Icons.reply_rounded, tooltip: 'Antworten', pt: pt, onTap: onReply),
          if (isTextOnly)
            _SelBtn(icon: Icons.copy_rounded, tooltip: 'Kopieren', pt: pt, onTap: _copyAll),
          _SelBtn(icon: Icons.push_pin_outlined, tooltip: 'Anpinnen', pt: pt, onTap: _pinAll),
          if (canDelete)
            _SelBtn(
              icon: Icons.delete_outline_rounded,
              tooltip: 'Löschen',
              pt: pt,
              color: pt.danger,
              onTap: () => _deleteAll(context),
            ),
        ],
      ),
    );
  }
}

class _SelBtn extends StatelessWidget {
  final IconData icon;
  final String tooltip;
  final PyramidTheme pt;
  final Color? color;
  final VoidCallback onTap;

  const _SelBtn({required this.icon, required this.tooltip, required this.pt, required this.onTap, this.color});

  @override
  Widget build(BuildContext context) {
    return IconButton(
      onPressed: onTap,
      icon: Icon(icon, size: 20, color: color ?? pt.fg),
      tooltip: tooltip,
      constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
      padding: EdgeInsets.zero,
    );
  }
}

class _UnreadDivider extends StatelessWidget {
  final PyramidTheme pt;
  final bool fading;
  const _UnreadDivider({super.key, required this.pt, this.fading = false});

  @override
  Widget build(BuildContext context) {
    return AnimatedOpacity(
      opacity: fading ? 0.0 : 1.0,
      duration: const Duration(milliseconds: 700),
      curve: Curves.easeOut,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
        child: Row(
          children: [
            Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 10),
              child: Text(
                'Neue Nachrichten',
                style: TextStyle(color: pt.accent, fontSize: 11, fontWeight: FontWeight.w600),
              ),
            ),
            Expanded(child: Divider(color: pt.accent.withAlpha(160), height: 1)),
          ],
        ),
      ),
    );
  }
}

// ─── Typing indicator ─────────────────────────────────────────────────────────

class _TypingIndicator extends StatefulWidget {
  final Room room;
  final PyramidTheme pt;
  const _TypingIndicator({required this.room, required this.pt});

  @override
  State<_TypingIndicator> createState() => _TypingIndicatorState();
}

class _TypingIndicatorState extends State<_TypingIndicator> {
  StreamSubscription? _sub;
  List<User> _typing = [];

  StreamSubscription _listenTyping(Room room) {
    // client.onSync fires after every sync — cheap to filter for typing events.
    return room.client.onSync.stream.listen((_) {
      if (!mounted) return;
      final typing = room.typingUsers
          .where((u) => u.id != room.client.userID)
          .toList();
      if (typing.length != _typing.length ||
          !typing.every((u) => _typing.any((t) => t.id == u.id))) {
        setState(() => _typing = typing);
      }
    });
  }

  @override
  void initState() {
    super.initState();
    _sub = _listenTyping(widget.room);
  }

  @override
  void didUpdateWidget(_TypingIndicator oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.room.id != widget.room.id) {
      _sub?.cancel();
      setState(() => _typing = []);
      _sub = _listenTyping(widget.room);
    }
  }

  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_typing.isEmpty) return const SizedBox.shrink();
    final pt = widget.pt;
    final names = _typing.map((u) => u.calcDisplayname()).toList();
    final label = names.length == 1
        ? '${names[0]} tippt…'
        : names.length == 2
            ? '${names[0]} und ${names[1]} tippen…'
            : '${names[0]} und ${names.length - 1} weitere tippen…';
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 2, 20, 0),
      child: Row(
        children: [
          _TypingDots(color: pt.fgDim),
          const SizedBox(width: 8),
          Flexible(
            child: Text(
              label,
              style: TextStyle(color: pt.fgDim, fontSize: 12, fontStyle: FontStyle.italic),
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
            ),
          ),
        ],
      ),
    );
  }
}

class _TypingDots extends StatefulWidget {
  final Color color;
  const _TypingDots({required this.color});

  @override
  State<_TypingDots> createState() => _TypingDotsState();
}

class _TypingDotsState extends State<_TypingDots>
    with SingleTickerProviderStateMixin {
  late final AnimationController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 900),
    )..repeat();
  }

  @override
  void dispose() {
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _ctrl,
      builder: (context, child) {
        return Row(
          mainAxisSize: MainAxisSize.min,
          children: List.generate(3, (i) {
            // Each dot peaks at a different phase.
            final phase = ((_ctrl.value * 3) - i).clamp(0.0, 1.0);
            final opacity = (phase < 0.5 ? phase * 2 : (1 - phase) * 2).clamp(0.3, 1.0);
            return Padding(
              padding: const EdgeInsets.symmetric(horizontal: 1),
              child: Opacity(
                opacity: opacity,
                child: Container(
                  width: 4,
                  height: 4,
                  decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle),
                ),
              ),
            );
          }),
        );
      },
    );
  }
}

// ─── Drag-and-drop overlay ────────────────────────────────────────────────────

class _DragOverlay extends StatefulWidget {
  final PyramidTheme pt;
  final bool active;
  const _DragOverlay({required this.pt, required this.active});

  @override
  State<_DragOverlay> createState() => _DragOverlayState();
}

class _DragOverlayState extends State<_DragOverlay>
    with SingleTickerProviderStateMixin {
  // Sanftes Auf-und-ab des Icons, solange das Overlay aktiv ist.
  late final AnimationController _bob = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 900),
  );

  @override
  void initState() {
    super.initState();
    if (widget.active) _bob.repeat(reverse: true);
  }

  @override
  void didUpdateWidget(_DragOverlay old) {
    super.didUpdateWidget(old);
    if (widget.active && !old.active) {
      _bob.repeat(reverse: true);
    } else if (!widget.active && old.active) {
      _bob.stop();
    }
  }

  @override
  void dispose() {
    _bob.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    return Container(
      color: pt.bg0.withAlpha(200),
      child: Center(
        child: AnimatedScale(
          scale: widget.active ? 1.0 : 0.88,
          duration: const Duration(milliseconds: 220),
          curve: Curves.easeOutBack,
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 32),
            decoration: BoxDecoration(
              color: pt.bg2,
              borderRadius: BorderRadius.circular(20),
              border: Border.all(color: pt.accent, width: 2),
              boxShadow: [
                BoxShadow(
                  color: pt.accent.withAlpha(60),
                  blurRadius: 32,
                  spreadRadius: 4,
                ),
              ],
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                AnimatedBuilder(
                  animation: _bob,
                  builder: (_, child) => Transform.translate(
                    offset: Offset(
                        0, -7 * Curves.easeInOut.transform(_bob.value)),
                    child: child,
                  ),
                  child:
                      Icon(Icons.upload_file_rounded, size: 56, color: pt.accent),
                ),
                const SizedBox(height: 16),
                Text(
                  'Datei hier ablegen',
                  style: TextStyle(
                    color: pt.fg,
                    fontSize: 20,
                    fontWeight: FontWeight.w600,
                  ),
                ),
                const SizedBox(height: 8),
                Text(
                  'Loslassen zum Hochladen',
                  style: TextStyle(color: pt.fgMuted, fontSize: 14),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

// Placeholder shown when no room is selected
class NoChatSelected extends StatelessWidget {
  const NoChatSelected({super.key});

  @override
  Widget build(BuildContext context) {
    final pt = PyramidTheme.of(context);
    return Container(
      color: pt.bg1,
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.forum_outlined, size: 64, color: pt.fgDim),
            const SizedBox(height: 16),
            Text(
              'Select a room to start chatting',
              style: TextStyle(color: pt.fgMuted, fontSize: 16),
            ),
            const SizedBox(height: 8),
            Text(
              'Choose from the sidebar on the left',
              style: TextStyle(color: pt.fgDim, fontSize: 13),
            ),
          ],
        ),
      ),
    );
  }
}
