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

import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pyramid/features/chat/media_player.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/media_cache.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/features/chat/emoji_picker.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:pyramid/features/chat/document_viewer.dart';
import 'package:pyramid/features/chat/media_viewer.dart';
import 'package:pyramid/features/chat/reaction_service.dart';
import 'package:pyramid/utils/gif_favorite_service.dart';
import 'package:pyramid/widgets/mxc_image.dart';
import 'package:url_launcher/url_launcher.dart';

// Wraps a group of consecutive messages from the same sender
class MessageGroup extends StatefulWidget {
  final List<Event> events;
  final String currentUserId;
  final Room? room;
  final Timeline? timeline;
  final VoidCallback? onReply;
  final Set<String> selectedIds;
  final ValueChanged<String>? onToggleSelect;
  final bool showStatusIndicator;
  /// Event IDs that another member has read up to (their last-read position).
  /// A ✓✓ is rendered at any of this group's events contained here.
  final Set<String> readEventIds;
  /// When non-null and pointing to a non-last event in this group, an inline
  /// "Neue Nachrichten" divider is rendered after that event's row.
  final String? fullyReadId;
  /// Jump-to-message (from search): the row matching [jumpEventId] gets
  /// [jumpKey] attached (for ensureVisible) and [highlightEventId] is tinted.
  final String? jumpEventId;
  final GlobalKey? jumpKey;
  final String? highlightEventId;
  final bool highlightOn;

  const MessageGroup({
    super.key,
    required this.events,
    required this.currentUserId,
    this.room,
    this.timeline,
    this.onReply,
    this.selectedIds = const {},
    this.onToggleSelect,
    this.showStatusIndicator = false,
    this.readEventIds = const {},
    this.fullyReadId,
    this.jumpEventId,
    this.jumpKey,
    this.highlightEventId,
    this.highlightOn = false,
  });

  @override
  State<MessageGroup> createState() => _MessageGroupState();
}

class _MessageGroupState extends State<MessageGroup> {
  // Per-message hover state (desktop): which event row the pointer is over, and
  // which row currently has its reaction-picker overlay open.
  String? _hoveredEventId;
  String? _overlayEventId;
  double _swipeOffset = 0;
  static const _swipeThreshold = 56.0;
  bool _swipeTriggered = false;

  bool get _isMobile => Platform.isAndroid || Platform.isIOS;

  @override
  Widget build(BuildContext context) {
    final pt = PyramidTheme.of(context);
    final firstEvent = widget.events.first;
    final sender = firstEvent.senderFromMemoryOrFallback;
    final name = sender.calcDisplayname();
    final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
    final color = _colorFromId(firstEvent.senderId);
    final time = _formatTime(firstEvent.originServerTs);
    final avatarUri = sender.avatarUrl;

    // Build a FLAT column of rows — each message at the same indentation level.
    // First event row: avatar + name + time + content
    // Continuation rows: timestamp-slot + content
    // This prevents the nested-Row double-indent bug.
    final rows = <Widget>[];

    // ── First event row ──────────────────────────────────
    // Each message row is INDIVIDUALLY selectable (see _selectableRow) so two
    // consecutive messages from the same sender aren't selected as one block.
    rows.add(_selectableRow(firstEvent, pt, Padding(
      padding: EdgeInsets.only(top: pt.msgGap / 2, left: 20, right: 20, bottom: 2),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Avatar
          SizedBox(
            width: 38,
            child: Column(children: [
              const SizedBox(height: 2),
              _Avatar(initial: initial, color: color, pt: pt, size: 38, avatarUri: avatarUri),
            ]),
          ),
          const SizedBox(width: 14),
          // Name + time + message
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(children: [
                  Text(name, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600)),
                  const SizedBox(width: 8),
                  Text(time, style: TextStyle(color: pt.fgDim, fontSize: 11)),
                ]),
                const SizedBox(height: 2),
                _MessageBody(
                  event: firstEvent,
                  pt: pt,
                  timeline: widget.timeline,
                  room: widget.room,
                  currentUserId: widget.currentUserId,
                ),
              ],
            ),
          ),
        ],
      ),
    )));
    if (widget.readEventIds.contains(firstEvent.eventId)) rows.add(_ReadMarker(pt: pt));
    // Inline unread divider after the first event when it's the fullyRead
    // marker and there are more events in this group.
    if (widget.fullyReadId == firstEvent.eventId && widget.events.length > 1) {
      rows.add(_InlineUnreadDivider(pt: pt));
    }

    // ── Continuation event rows (siblings, same indentation) ──
    for (var j = 1; j < widget.events.length; j++) {
      final event = widget.events[j];
      rows.add(_selectableRow(event, pt, Padding(
        padding: const EdgeInsets.only(top: 8, left: 20, right: 20),
        child: _ContinuationMessage(
          event: event,
          pt: pt,
          timeline: widget.timeline,
          room: widget.room,
          currentUserId: widget.currentUserId,
        ),
      )));
      if (widget.readEventIds.contains(event.eventId)) rows.add(_ReadMarker(pt: pt));
      // Inline divider after this event if it's the fullyRead and not the last.
      if (widget.fullyReadId == event.eventId && j < widget.events.length - 1) {
        rows.add(_InlineUnreadDivider(pt: pt));
      }
    }

    // Sending/sent indicator on the newest own message — only when no read ✓✓
    // is already shown for it (the ✓✓ implies it was delivered + read).
    if (widget.showStatusIndicator &&
        firstEvent.senderId == widget.currentUserId &&
        widget.room != null &&
        !widget.readEventIds.contains(widget.events.last.eventId)) {
      rows.add(Padding(
        padding: const EdgeInsets.only(right: 24, bottom: 1),
        child: Align(
          alignment: Alignment.centerRight,
          child: _MessageStatus(
            lastEvent: widget.events.last,
            pt: pt,
          ),
        ),
      ));
    }
    rows.add(const SizedBox(height: 2));

    // Hover highlight + actions are now per-row (see _selectableRow), so the
    // group is just the column of rows.
    Widget content = Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: rows,
    );

    if (!_isMobile) return content;

    return GestureDetector(
      onHorizontalDragUpdate: (d) {
        if (d.delta.dx > 0) return; // Only allow right-to-left swipe for reply
        setState(() {
          _swipeOffset = (_swipeOffset + d.delta.dx).clamp(-_swipeThreshold, 0.0);
          if (_swipeOffset <= -_swipeThreshold && !_swipeTriggered) {
            _swipeTriggered = true;
            HapticFeedback.mediumImpact();
          }
        });
      },
      onHorizontalDragEnd: (_) {
        if (_swipeTriggered) widget.onReply?.call();
        setState(() { _swipeOffset = 0; _swipeTriggered = false; });
      },
      child: Stack(
        children: [
          Transform.translate(
            offset: Offset(_swipeOffset, 0),
            child: content,
          ),
          if (_swipeOffset < -8)
            Positioned(
              right: 12,
              top: 0,
              bottom: 0,
              child: Center(
                child: AnimatedOpacity(
                  opacity: (-_swipeOffset / _swipeThreshold).clamp(0.0, 1.0),
                  duration: const Duration(milliseconds: 60),
                  child: Icon(Icons.reply_rounded, size: 20, color: pt.fgMuted),
                ),
              ),
            ),
        ],
      ),
    );
  }

  // Wraps a single message row so it can be selected independently of the rest
  // of the sender's group. Long-press enters selection mode; in selection mode a
  // full-row tap overlay toggles this message's selection.
  Widget _selectableRow(Event event, PyramidTheme pt, Widget child) {
    final selected = widget.selectedIds.contains(event.eventId);
    final inSel = widget.selectedIds.isNotEmpty;
    final showActions = !_isMobile && !inSel &&
        (_hoveredEventId == event.eventId || _overlayEventId == event.eventId);
    final isJumpTarget = widget.jumpEventId == event.eventId;
    final isHighlighted = widget.highlightEventId == event.eventId;

    Widget row = GestureDetector(
      key: isJumpTarget ? widget.jumpKey : null,
      onLongPress: () => widget.onToggleSelect?.call(event.eventId),
      child: Stack(
        children: [
          AnimatedContainer(
            duration: const Duration(milliseconds: 100),
            color: selected
                ? pt.accent.withAlpha(40)
                : (showActions ? pt.bg2 : Colors.transparent),
            child: child,
          ),
          // Jump-from-search highlight — fades out via AnimatedOpacity.
          if (isHighlighted)
            Positioned.fill(
              child: IgnorePointer(
                child: AnimatedOpacity(
                  opacity: widget.highlightOn ? 1.0 : 0.0,
                  duration: const Duration(milliseconds: 900),
                  curve: Curves.easeOut,
                  child: Container(color: pt.accent.withAlpha(48)),
                ),
              ),
            ),
          if (selected)
            Positioned(
              left: 0,
              top: 0,
              bottom: 0,
              child: Container(width: 3, color: pt.accent),
            ),
          if (showActions)
            Positioned(
              top: 0,
              right: 32,
              child: _HoverActions(
                pt: pt,
                event: event,
                events: [event],
                room: widget.room,
                currentUserId: widget.currentUserId,
                onReply: widget.onReply,
                onOverlayStateChanged: (active) => setState(
                    () => _overlayEventId = active ? event.eventId : null),
              ),
            ),
          if (inSel)
            Positioned.fill(
              child: GestureDetector(
                behavior: HitTestBehavior.opaque,
                onTap: () => widget.onToggleSelect?.call(event.eventId),
              ),
            ),
        ],
      ),
    );

    if (_isMobile) return row;

    // Desktop: per-row hover highlight + actions.
    return MouseRegion(
      onEnter: (_) => setState(() => _hoveredEventId = event.eventId),
      onExit: (_) => setState(() {
        if (_hoveredEventId == event.eventId) _hoveredEventId = null;
      }),
      child: row,
    );
  }

  Color _colorFromId(String id) {
    const colors = [
      Color(0xFF06B6D4), Color(0xFFA855F7), Color(0xFF10B981),
      Color(0xFFF43F5E), Color(0xFF3B82F6), Color(0xFFF59E0B),
      Color(0xFFFF6B9D),
    ];
    if (id.isEmpty) return colors[0];
    return colors[id.codeUnitAt(0) % colors.length];
  }

  String _formatTime(DateTime t) {
    final h = t.hour.toString().padLeft(2, '0');
    final m = t.minute.toString().padLeft(2, '0');
    return '$h:$m';
  }
}

// Shown under a message whose send failed (e.g. offline) — keeps the message
// visible with retry / delete instead of letting it silently disappear.
class _SendFailedRow extends StatelessWidget {
  final Event event;
  final PyramidTheme pt;
  const _SendFailedRow({required this.event, required this.pt});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(top: 3, bottom: 1),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(Icons.error_outline_rounded, size: 13, color: pt.danger),
          const SizedBox(width: 4),
          Text('Nicht gesendet', style: TextStyle(color: pt.danger, fontSize: 12)),
          const SizedBox(width: 10),
          _link('Wiederholen', pt, () async {
            try { await event.sendAgain(); } catch (_) {}
          }),
          const SizedBox(width: 10),
          _link('Löschen', pt, () async {
            try { await event.cancelSend(); } catch (_) {}
          }),
        ],
      ),
    );
  }

  Widget _link(String label, PyramidTheme pt, VoidCallback onTap) => GestureDetector(
        onTap: onTap,
        child: Text(
          label,
          style: TextStyle(
            color: pt.accent,
            fontSize: 12,
            decoration: TextDecoration.underline,
            decorationColor: pt.accent,
          ),
        ),
      );
}

// Single message (first in group) with full avatar + name shown
class _MessageBody extends StatelessWidget {
  final Event event;
  final PyramidTheme pt;
  final Timeline? timeline;
  final Room? room;
  final String currentUserId;

  const _MessageBody({
    required this.event,
    required this.pt,
    this.timeline,
    this.room,
    required this.currentUserId,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _MessageContent(event: event, pt: pt, timeline: timeline),
        if (event.status.isError) _SendFailedRow(event: event, pt: pt),
        if (timeline != null) _ReactionsRow(event: event, timeline: timeline!, room: room, currentUserId: currentUserId, pt: pt),
      ],
    );
  }
}

// Continuation message — no avatar, just indented text with timestamp on hover
class _ContinuationMessage extends StatefulWidget {
  final Event event;
  final PyramidTheme pt;
  final Timeline? timeline;
  final Room? room;
  final String currentUserId;

  const _ContinuationMessage({
    required this.event,
    required this.pt,
    this.timeline,
    this.room,
    required this.currentUserId,
  });

  @override
  State<_ContinuationMessage> createState() => _ContinuationMessageState();
}

class _ContinuationMessageState extends State<_ContinuationMessage> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (_) => setState(() => _hovered = true),
      onExit: (_) => setState(() => _hovered = false),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // Timestamp on hover (in avatar column space)
          SizedBox(
            width: 38,
            child: Padding(
              padding: const EdgeInsets.only(top: 4),
              child: AnimatedOpacity(
                opacity: _hovered ? 1 : 0,
                duration: const Duration(milliseconds: 120),
                child: Text(
                  _formatTime(widget.event.originServerTs),
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    color: widget.pt.fgDim,
                    fontSize: 10,
                  ),
                ),
              ),
            ),
          ),
          const SizedBox(width: 14),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                _MessageContent(event: widget.event, pt: widget.pt, timeline: widget.timeline),
                if (widget.event.status.isError) _SendFailedRow(event: widget.event, pt: widget.pt),
                if (widget.timeline != null)
                  _ReactionsRow(
                    event: widget.event,
                    timeline: widget.timeline!,
                    room: widget.room,
                    currentUserId: widget.currentUserId,
                    pt: widget.pt,
                  ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  String _formatTime(DateTime t) {
    final h = t.hour.toString().padLeft(2, '0');
    final m = t.minute.toString().padLeft(2, '0');
    return '$h:$m';
  }
}

class _MessageContent extends StatelessWidget {
  final Event event;
  final PyramidTheme pt;
  final Timeline? timeline;

  const _MessageContent({required this.event, required this.pt, this.timeline});

  // Resolves edits: returns the display event (last edit) for this event.
  Event get _displayEvent =>
      timeline != null ? event.getDisplayEvent(timeline!) : event;

  String? _getReplyId() {
    final relatesTo = event.content.tryGetMap<String, dynamic>('m.relates_to');
    final replyMap = relatesTo?.tryGetMap<String, dynamic>('m.in_reply_to');
    return replyMap?['event_id'] as String?;
  }

  String _stripReplyFallback(String body) {
    final norm = body.replaceAll('\r\n', '\n');
    if (!norm.startsWith('> ') && !norm.startsWith('>\n')) return norm;
    final lines = norm.split('\n');
    var i = 0;
    while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
      i++;
    }
    if (i < lines.length && lines[i].trim().isEmpty) i++;
    if (i >= lines.length) return norm;
    return lines.skip(i).join('\n').trim();
  }

  Event? _findReplyEvent(String replyId) {
    if (timeline == null) return null;
    try {
      return timeline!.events.firstWhere((e) => e.eventId == replyId);
    } catch (_) {
      return null;
    }
  }

  @override
  Widget build(BuildContext context) {
    final displayEvent = _displayEvent;
    final isEdited = displayEvent.eventId != event.eventId;

    // Still encrypted (decryption pending or failed)
    if (displayEvent.type == EventTypes.Encrypted) {
      return Padding(
        padding: const EdgeInsets.only(bottom: 2),
        child: Wrap(
          crossAxisAlignment: WrapCrossAlignment.center,
          spacing: 4,
          children: [
            Icon(Icons.lock_outline_rounded, size: 13, color: pt.fgDim),
            Text(
              'Nachricht wird entschlüsselt…',
              style: TextStyle(color: pt.fgDim, fontSize: 13, fontStyle: FontStyle.italic),
            ),
            if (timeline != null)
              Builder(
                builder: (ctx) => GestureDetector(
                  onTap: () {
                    timeline!.requestKeys();
                    ScaffoldMessenger.of(ctx).showSnackBar(
                      const SnackBar(
                        content: Text('Schlüssel werden von anderen Geräten angefordert…'),
                        duration: Duration(seconds: 3),
                      ),
                    );
                  },
                  child: Text(
                    'Schlüssel anfordern',
                    style: TextStyle(
                      color: pt.accent,
                      fontSize: 12,
                      decoration: TextDecoration.underline,
                      decorationColor: pt.accent,
                    ),
                  ),
                ),
              ),
          ],
        ),
      );
    }

    if (displayEvent.type == EventTypes.Sticker) {
      return _MediaContent(event: displayEvent, pt: pt);
    }

    final msgType = displayEvent.messageType;

    if (msgType == 'm.image') {
      final ext = displayEvent.body.contains('.')
          ? displayEvent.body.split('.').last.toLowerCase()
          : '';
      const kImageExts = {
        'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp',
        'tiff', 'tif', 'heic', 'heif', 'avif', 'ico', 'jfif',
      };
      // SVG kann Image.memory nicht dekodieren — über den SVG-Renderer anzeigen.
      if (ext == 'svg' ||
          displayEvent.content
                  .tryGetMap<String, Object?>('info')?['mimetype'] ==
              'image/svg+xml') {
        return _DocPreviewContent(event: displayEvent, pt: pt);
      }
      if (ext.isNotEmpty && !kImageExts.contains(ext)) {
        return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded);
      }
      return _ImageContent(event: displayEvent, pt: pt);
    }
    if (msgType == 'm.audio') {
      return _AudioContent(event: displayEvent, pt: pt);
    }
    if (msgType == 'm.video') {
      return _VideoContent(event: displayEvent, pt: pt);
    }
    if (msgType == 'm.file') {
      if (DocumentViewer.isInlinePreviewable(displayEvent.body)) {
        return _DocPreviewContent(event: displayEvent, pt: pt);
      }
      if (DocumentViewer.showsInlineCard(displayEvent.body)) {
        return _ProprietaryInlineCard(event: displayEvent, pt: pt);
      }
      return _FileContent(event: displayEvent, pt: pt, icon: Icons.insert_drive_file_rounded);
    }

    final replyId = _getReplyId();
    final replyEvent = replyId != null ? _findReplyEvent(replyId) : null;
    final plainBody = replyId != null
        ? _stripReplyFallback(displayEvent.body)
        : displayEvent.body;

    // Use formatted_body (HTML) if the event has one, otherwise plain text.
    final format = displayEvent.content.tryGet<String>('format');
    // Strip the Matrix reply fallback <mx-reply>…</mx-reply> block — we render
    // our own quote card (_ReplyPreview) above, so the inline "In reply to @user"
    // would be redundant and ugly.
    final formattedBody = displayEvent.formattedText.replaceAll(
      RegExp(r'<mx-reply>.*?</mx-reply>', caseSensitive: false, dotAll: true),
      '',
    ).trim();
    final hasHtml = format == 'org.matrix.custom.html' && formattedBody.isNotEmpty;

    final firstUrl = _firstUrl(plainBody);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        if (replyEvent != null) _ReplyPreview(replyEvent: replyEvent, pt: pt),
        Padding(
          padding: const EdgeInsets.only(bottom: 2),
          child: hasHtml
              ? _MatrixHtmlText(
                  html: formattedBody,
                  baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55),
                  linkColor: pt.accent,
                )
              : _PlainLinkText(
                  text: plainBody,
                  baseStyle: TextStyle(color: pt.fg, fontSize: 14, height: 1.55),
                  linkColor: pt.accent,
                ),
        ),
        if (isEdited)
          Text(
            '(bearbeitet)',
            style: TextStyle(color: pt.fgDim, fontSize: 11, fontStyle: FontStyle.italic),
          ),
        if (firstUrl != null) _LinkPreview(url: firstUrl, pt: pt),
      ],
    );
  }

  // Extracts the first http(s) URL in the text, for link-preview fetching.
  static final RegExp _urlRegex = RegExp(r'https?://[^\s<>()\[\]]+', caseSensitive: false);
  String? _firstUrl(String text) {
    final m = _urlRegex.firstMatch(text);
    if (m == null) return null;
    var url = m.group(0)!;
    // Trim common trailing punctuation that isn't part of the URL.
    url = url.replaceAll(RegExp(r'[.,;:!?]+$'), '');
    return url;
  }
}

// ─── Link preview card (Open-Graph, WhatsApp/Discord-style) ──────────────────

class _LinkPreviewData {
  final String? title;
  final String? description;
  final String? imageMxc;
  const _LinkPreviewData({this.title, this.description, this.imageMxc});
  bool get isEmpty =>
      (title == null || title!.isEmpty) &&
      (description == null || description!.isEmpty) &&
      imageMxc == null;
}

class _LinkPreview extends ConsumerStatefulWidget {
  final String url;
  final PyramidTheme pt;
  const _LinkPreview({required this.url, required this.pt});

  // url → preview (null = fetched but nothing usable). Avoids refetching on
  // every rebuild/scroll.
  static final Map<String, _LinkPreviewData?> _cache = {};

  @override
  ConsumerState<_LinkPreview> createState() => _LinkPreviewState();
}

class _LinkPreviewState extends ConsumerState<_LinkPreview> {
  _LinkPreviewData? _data;
  bool _done = false;

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

  Future<void> _load() async {
    if (_LinkPreview._cache.containsKey(widget.url)) {
      setState(() { _data = _LinkPreview._cache[widget.url]; _done = true; });
      return;
    }
    try {
      final client = await ref.read(matrixClientProvider.future);
      final hs = client.homeserver.toString();
      final enc = Uri.encodeQueryComponent(widget.url);
      final headers = {'authorization': 'Bearer ${client.accessToken}'};

      var res = await client.httpClient.get(
        Uri.parse('$hs/_matrix/client/v1/media/preview_url?url=$enc'),
        headers: headers,
      );
      if (res.statusCode == 404) {
        // Legacy fallback for older servers.
        res = await client.httpClient.get(
          Uri.parse('$hs/_matrix/media/v3/preview_url?url=$enc'),
          headers: headers,
        );
      }
      _LinkPreviewData? data;
      if (res.statusCode == 200) {
        final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
        data = _LinkPreviewData(
          title: (json['og:title'] as String?)?.trim(),
          description: (json['og:description'] as String?)?.trim(),
          imageMxc: json['og:image'] as String?,
        );
        if (data.isEmpty) data = null;
      }
      _LinkPreview._cache[widget.url] = data;
      if (mounted) setState(() { _data = data; _done = true; });
    } catch (_) {
      _LinkPreview._cache[widget.url] = null;
      if (mounted) setState(() { _data = null; _done = true; });
    }
  }

  @override
  Widget build(BuildContext context) {
    final data = _data;
    if (!_done || data == null) return const SizedBox.shrink();
    final pt = widget.pt;
    final client = ref.watch(matrixClientProvider).valueOrNull;

    return Padding(
      padding: const EdgeInsets.only(top: 6, bottom: 2),
      child: GestureDetector(
        onTap: () => launchUrl(Uri.parse(widget.url), mode: LaunchMode.externalApplication),
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 380),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(pt.rBase),
            child: Container(
              decoration: BoxDecoration(
                color: pt.bg2,
                border: Border(left: BorderSide(color: pt.accent, width: 3)),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  if (data.imageMxc != null && client != null)
                    ConstrainedBox(
                      constraints: const BoxConstraints(maxHeight: 180),
                      child: SizedBox(
                        width: double.infinity,
                        child: MxcImage(
                          mxcUri: data.imageMxc!,
                          client: client,
                          fit: BoxFit.cover,
                          placeholder: const SizedBox.shrink(),
                        ),
                      ),
                    ),
                  Padding(
                    padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        if (data.title != null && data.title!.isNotEmpty)
                          Text(
                            data.title!,
                            style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600),
                            maxLines: 2,
                            overflow: TextOverflow.ellipsis,
                          ),
                        if (data.description != null && data.description!.isNotEmpty) ...[
                          const SizedBox(height: 3),
                          Text(
                            data.description!,
                            style: TextStyle(color: pt.fgMuted, fontSize: 12, height: 1.35),
                            maxLines: 3,
                            overflow: TextOverflow.ellipsis,
                          ),
                        ],
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _ReplyPreview extends StatelessWidget {
  final Event replyEvent;
  final PyramidTheme pt;
  const _ReplyPreview({required this.replyEvent, required this.pt});

  @override
  Widget build(BuildContext context) {
    final senderName = replyEvent.senderFromMemoryOrFallback.calcDisplayname();

    // Media-aware preview: show an icon + label instead of a raw filename,
    // matching WhatsApp/Discord. Falls back to stripped text for m.text.
    final (IconData? icon, String body) = _previewFor(replyEvent);

    return Padding(
      padding: const EdgeInsets.only(bottom: 4),
      child: IntrinsicHeight(
        child: ClipRRect(
          borderRadius: BorderRadius.circular(pt.rSm),
          child: Container(
            color: pt.accentSoft,
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                // Accent bar flush with the left edge of the tinted card.
                Container(width: 3, color: pt.accent),
                Expanded(
                  child: Padding(
                    padding: const EdgeInsets.fromLTRB(8, 5, 10, 5),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisSize: MainAxisSize.min,
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Text(senderName, style: TextStyle(color: pt.accent, fontSize: 12, fontWeight: FontWeight.w600)),
                        Row(
                          children: [
                            if (icon != null) ...[
                              Icon(icon, size: 13, color: pt.fgMuted),
                              const SizedBox(width: 4),
                            ],
                            Expanded(
                              child: Text(
                                body,
                                style: TextStyle(color: pt.fgMuted, fontSize: 12),
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                              ),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  (IconData?, String) _previewFor(Event e) {
    switch (e.messageType) {
      case 'm.image':
        return (Icons.image_outlined, 'Bild');
      case 'm.video':
        return (Icons.videocam_outlined, 'Video');
      case 'm.audio':
        return (Icons.mic_none_rounded, 'Sprachnachricht');
      case 'm.file':
        return (Icons.insert_drive_file_outlined, e.body.isNotEmpty ? e.body : 'Datei');
    }
    if (e.type == EventTypes.Sticker) return (Icons.emoji_emotions_outlined, 'Sticker');

    // Plain text: strip nested reply fallback, collapse to first line.
    String body = e.body.replaceAll('\r\n', '\n');
    if (body.startsWith('> ')) {
      final lines = body.split('\n');
      var i = 0;
      while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
        i++;
      }
      if (i < lines.length && lines[i].trim().isEmpty) i++;
      if (i < lines.length) body = lines.skip(i).join(' ').trim();
    }
    return (null, body.split('\n').first);
  }
}

class _ImageContent extends ConsumerStatefulWidget {
  final Event event;
  final PyramidTheme pt;
  const _ImageContent({required this.event, required this.pt});

  @override
  ConsumerState<_ImageContent> createState() => _ImageContentState();
}

class _ImageContentState extends ConsumerState<_ImageContent> {
  late Future<_MediaResult> _future;

  @override
  void initState() {
    super.initState();
    _future = _load();
  }

  Future<_MediaResult> _load() async {
    final event = widget.event;
    final rawUrl = event.content.tryGet<String>('url') ?? '';
    if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
      return _MediaResult.network(rawUrl);
    }
    // Serve from the shared LRU cache if this image was viewed recently.
    final cached = MediaCache.instance.get(event.eventId);
    if (cached != null) return _MediaResult.bytes(cached);

    final client = await ref.read(matrixClientProvider.future);
    final mxcUri = event.attachmentMxcUrl;
    if (mxcUri == null) return _MediaResult.none();
    if (event.isAttachmentEncrypted) {
      try {
        final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
        MediaCache.instance.put(event.eventId, file.bytes);
        return _MediaResult.bytes(file.bytes);
      } catch (e) {
        return _MediaResult.error(_classifyMediaError(e));
      }
    } else {
      try {
        final httpUri = await mxcUri.getThumbnailUri(client, width: 800, height: 600, method: ThumbnailMethod.scale);
        final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
        if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
        MediaCache.instance.put(event.eventId, res.bodyBytes);
        return _MediaResult.bytes(res.bodyBytes);
      } catch (_) {
        try {
          final httpUri = await mxcUri.getDownloadUri(client);
          final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
          if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
          MediaCache.instance.put(event.eventId, res.bodyBytes);
          return _MediaResult.bytes(res.bodyBytes);
        } catch (e) {
          return _MediaResult.error(e.toString().split('\n').first);
        }
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;

    return LayoutBuilder(builder: (context, constraints) {
      // Never exceed the available chat width (important on narrow phones),
      // capped at the comfortable desktop maximum.
      final maxW = constraints.maxWidth.isFinite
          ? constraints.maxWidth.clamp(80.0, 420.0)
          : 420.0;
      const maxH = 320.0;
      // Reserve the final display box up-front from the event's info dimensions,
      // so the layout doesn't shift (and yank the scroll position) when the image
      // finishes loading. Null when dimensions are absent.
      final box = _reservedImageSize(widget.event, maxW: maxW, maxH: maxH);

      return FutureBuilder<_MediaResult>(
        future: _future,
        builder: (context, snap) {
          Widget inner;
          if (snap.connectionState == ConnectionState.waiting) {
            inner = Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim)));
          } else if (snap.hasData) {
            final result = snap.data!;
            if (result.bytes != null) {
              inner = Image.memory(
                result.bytes!,
                fit: BoxFit.contain,
                gaplessPlayback: true,
                errorBuilder: (context, e, stack) => _mediaFallback(pt, e.toString().split('\n').first, widget.event.body),
              );
            } else if (result.networkUrl != null) {
              inner = Image.network(result.networkUrl!, fit: BoxFit.contain);
            } else {
              inner = _mediaFallback(pt, result.error, widget.event.body);
            }
          } else {
            inner = _mediaFallback(pt, null, widget.event.body);
          }

          final result = snap.data;
          return GestureDetector(
            onTap: (snap.hasData && result != null)
                ? () => MediaViewer.show(
                      context,
                      senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(),
                      filename: widget.event.body,
                      bytes: result.bytes,
                      networkUrl: result.networkUrl,
                      event: widget.event,
                    )
                : null,
            child: Align(
              alignment: Alignment.centerLeft,
              child: Container(
                margin: const EdgeInsets.only(top: 4, bottom: 4),
                width: box?.width,
                height: box?.height,
                constraints: box == null
                    ? BoxConstraints(
                        minWidth: 120, minHeight: 90,
                        maxWidth: maxW, maxHeight: maxH,
                      )
                    : null,
                decoration: BoxDecoration(
                  color: pt.bg1,
                  borderRadius: BorderRadius.circular(pt.rBase),
                ),
                clipBehavior: Clip.hardEdge,
                child: inner,
              ),
            ),
          );
        },
      );
    });
  }
}

// Computes the on-screen box for an image/video event, fitting within
// [maxW]×[maxH] while preserving the aspect ratio from the event's `info`
// (w/h). Used to reserve layout space before the media loads so nothing shifts.
// Returns null when the event carries no dimensions — callers should then use
// loose constraints instead of a fixed box.
Size? _reservedImageSize(Event event, {required double maxW, required double maxH}) {
  final info = event.content.tryGetMap<String, Object?>('info');
  final w = (info?['w'] as num?)?.toDouble();
  final h = (info?['h'] as num?)?.toDouble();
  if (w == null || h == null || w <= 0 || h <= 0) return null;
  // Fit inside maxW×maxH without distorting; never upscale small images.
  var scale = (maxW / w).clamp(0.0, maxH / h);
  if (scale > 1.0) scale = 1.0;
  return Size(w * scale, h * scale);
}

// Downloads media bytes then hands off to PyramidAudioPlayer / PyramidVideoPlayer
class _AudioContent extends ConsumerStatefulWidget {
  final Event event;
  final PyramidTheme pt;
  const _AudioContent({required this.event, required this.pt});

  @override
  ConsumerState<_AudioContent> createState() => _AudioContentState();
}

class _AudioContentState extends ConsumerState<_AudioContent> {
  late Future<_MediaResult> _future;

  @override
  void initState() {
    super.initState();
    _future = _loadBytes();
  }

  Future<_MediaResult> _loadBytes() async {
    final client = await ref.read(matrixClientProvider.future);
    final event = widget.event;
    if (event.isAttachmentEncrypted) {
      try {
        final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
        return _MediaResult.bytes(file.bytes);
      } catch (e) {
        return _MediaResult.error(_classifyMediaError(e));
      }
    }
    final mxcUri = event.attachmentMxcUrl;
    if (mxcUri == null) return _MediaResult.none();
    try {
      final httpUri = await mxcUri.getDownloadUri(client);
      final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
      if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
      return _MediaResult.bytes(res.bodyBytes);
    } catch (e) {
      return _MediaResult.error(e.toString().split('\n').first);
    }
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    return FutureBuilder<_MediaResult>(
      future: _future,
      builder: (ctx, snap) {
        if (snap.connectionState == ConnectionState.waiting) {
          return Padding(
            padding: const EdgeInsets.symmetric(vertical: 8),
            child: Row(mainAxisSize: MainAxisSize.min, children: [
              SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim)),
              const SizedBox(width: 10),
              Text('Lade Audio…', style: TextStyle(color: pt.fgMuted, fontSize: 12)),
            ]),
          );
        }
        final result = snap.data;
        if (result?.bytes == null) {
          return _mediaFallback(pt, result?.error, widget.event.body);
        }
        return PyramidAudioPlayer(
          bytes: result!.bytes!,
          filename: widget.event.body,
          pt: pt,
          isVoice: widget.event.content['msgtype'] == 'm.audio',
        );
      },
    );
  }
}

class _VideoContent extends ConsumerStatefulWidget {
  final Event event;
  final PyramidTheme pt;
  const _VideoContent({required this.event, required this.pt});

  @override
  ConsumerState<_VideoContent> createState() => _VideoContentState();
}

class _VideoContentState extends ConsumerState<_VideoContent> {
  late Future<_MediaResult> _future;

  @override
  void initState() {
    super.initState();
    _future = _loadBytes();
  }

  Future<_MediaResult> _loadBytes() async {
    final client = await ref.read(matrixClientProvider.future);
    final event = widget.event;
    if (event.isAttachmentEncrypted) {
      try {
        final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
        return _MediaResult.bytes(file.bytes);
      } catch (e) {
        return _MediaResult.error(_classifyMediaError(e));
      }
    }
    final mxcUri = event.attachmentMxcUrl;
    if (mxcUri == null) return _MediaResult.none();
    try {
      final httpUri = await mxcUri.getDownloadUri(client);
      final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
      if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
      return _MediaResult.bytes(res.bodyBytes);
    } catch (e) {
      return _MediaResult.error(e.toString().split('\n').first);
    }
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    return FutureBuilder<_MediaResult>(
      future: _future,
      builder: (ctx, snap) {
        if (snap.connectionState == ConnectionState.waiting) {
          return Container(
            margin: const EdgeInsets.only(top: 4, bottom: 4),
            constraints: const BoxConstraints(maxWidth: 360),
            height: 200,
            decoration: BoxDecoration(
              color: Colors.black,
              borderRadius: BorderRadius.circular(pt.rBase),
              border: Border.all(color: pt.border),
            ),
            child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
          );
        }
        final result = snap.data;
        if (result?.bytes == null) {
          return _FileContent(event: widget.event, pt: pt, icon: Icons.videocam_rounded);
        }
        return PyramidVideoPlayer(
          bytes: result!.bytes!,
          filename: widget.event.body,
          pt: pt,
          senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(),
        );
      },
    );
  }
}

class _MediaContent extends ConsumerStatefulWidget {
  final Event event;
  final PyramidTheme pt;

  const _MediaContent({required this.event, required this.pt});

  @override
  ConsumerState<_MediaContent> createState() => _MediaContentState();
}

class _MediaContentState extends ConsumerState<_MediaContent> {
  late Future<_MediaResult> _future;

  @override
  void initState() {
    super.initState();
    _future = _load();
  }

  Future<_MediaResult> _load() async {
    final client = await ref.read(matrixClientProvider.future);
    final event = widget.event;

    // External URL (e.g. Giphy GIFs) — not an MXC URI
    final rawUrl = event.content.tryGet<String>('url') ?? '';
    if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
      return _MediaResult.network(rawUrl);
    }

    final mxcUri = event.attachmentMxcUrl;
    if (mxcUri == null) return _MediaResult.none();

    if (event.isAttachmentEncrypted) {
      try {
        final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
        return _MediaResult.bytes(file.bytes);
      } catch (e) {
        return _MediaResult.error(_classifyMediaError(e));
      }
    } else {
      try {
        final httpUri = await mxcUri.getThumbnailUri(
          client,
          width: 800,
          height: 600,
          method: ThumbnailMethod.scale,
        );
        final res = await client.httpClient.get(
          httpUri,
          headers: {'authorization': 'Bearer ${client.accessToken}'},
        );
        if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
        return _MediaResult.bytes(res.bodyBytes);
      } catch (_) {
        // Fall back to full-size download
        try {
          final httpUri = await mxcUri.getDownloadUri(client);
          final res = await client.httpClient.get(
            httpUri,
            headers: {'authorization': 'Bearer ${client.accessToken}'},
          );
          if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
          return _MediaResult.bytes(res.bodyBytes);
        } catch (e) {
          return _MediaResult.error(e.toString().split('\n').first);
        }
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    final isSticker = widget.event.type == EventTypes.Sticker;
    final maxW = isSticker ? 160.0 : 420.0;
    final maxH = isSticker ? 160.0 : 320.0;

    return FutureBuilder<_MediaResult>(
      future: _future,
      builder: (context, snap) {
        Widget inner;
        if (snap.connectionState == ConnectionState.waiting) {
          inner = Center(
            child: SizedBox(
              width: 24, height: 24,
              child: CircularProgressIndicator(strokeWidth: 2, color: pt.fgDim),
            ),
          );
        } else if (snap.hasData) {
          final result = snap.data!;
          if (result.networkUrl != null) {
            inner = Image.network(
              result.networkUrl!,
              fit: BoxFit.contain,
              errorBuilder: (context, e, stack) => _fallback(pt),
            );
          } else if (result.bytes != null) {
            inner = Image.memory(
              result.bytes!,
              fit: BoxFit.contain,
              errorBuilder: (context, e, stack) => _fallback(pt),
            );
          } else {
            inner = _fallback(pt, result.error);
          }
        } else {
          inner = _fallback(pt);
        }

        return Align(
          alignment: Alignment.centerLeft,
          child: Container(
            margin: const EdgeInsets.only(top: 4, bottom: 4),
            constraints: BoxConstraints(maxWidth: maxW, maxHeight: maxH),
            decoration: isSticker ? null : BoxDecoration(
              color: pt.bg3,
              borderRadius: BorderRadius.circular(pt.rBase),
              border: Border.all(color: pt.border),
            ),
            clipBehavior: isSticker ? Clip.none : Clip.hardEdge,
            child: inner,
          ),
        );
      },
    );
  }

  Widget _fallback(PyramidTheme pt, [String? error]) =>
      _mediaFallback(pt, error, widget.event.body);
}

// Downloads bytes for a PDF/SVG file and shows PdfInlinePreview or SvgImage.
class _DocPreviewContent extends ConsumerStatefulWidget {
  final Event event;
  final PyramidTheme pt;
  const _DocPreviewContent({required this.event, required this.pt});

  @override
  ConsumerState<_DocPreviewContent> createState() => _DocPreviewContentState();
}

class _DocPreviewContentState extends ConsumerState<_DocPreviewContent> {
  late Future<_MediaResult> _future;

  @override
  void initState() {
    super.initState();
    _future = _loadBytes();
  }

  Future<_MediaResult> _loadBytes() async {
    final event = widget.event;
    // Serve from cache if we already downloaded this attachment this session.
    final cached = _DocBytesCache.get(event.eventId);
    if (cached != null) return _MediaResult.bytes(cached);

    final client = await ref.read(matrixClientProvider.future);
    if (event.isAttachmentEncrypted) {
      try {
        final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
        _DocBytesCache.put(event.eventId, file.bytes);
        return _MediaResult.bytes(file.bytes);
      } catch (e) {
        return _MediaResult.error(_classifyMediaError(e));
      }
    }
    final mxcUri = event.attachmentMxcUrl;
    if (mxcUri == null) return _MediaResult.none();
    try {
      final httpUri = await mxcUri.getDownloadUri(client);
      final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
      if (res.statusCode != 200) return _MediaResult.error('HTTP ${res.statusCode}');
      _DocBytesCache.put(event.eventId, res.bodyBytes);
      return _MediaResult.bytes(res.bodyBytes);
    } catch (e) {
      return _MediaResult.error(e.toString().split('\n').first);
    }
  }

  Future<void> _saveFile(Uint8List bytes) async {
    try {
      Directory? dir;
      if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
        dir = await getDownloadsDirectory();
      }
      dir ??= await getTemporaryDirectory();
      final filename = widget.event.body.isNotEmpty ? widget.event.body : 'file';
      final ext = filename.contains('.') ? filename.split('.').last : 'bin';
      final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.$ext';
      final file = File('${dir.path}/$name');
      await file.writeAsBytes(bytes);
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(
          content: Text('Gespeichert: ${file.path}'),
          duration: const Duration(seconds: 4),
        ));
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e')));
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    final ext = widget.event.body.contains('.')
        ? widget.event.body.split('.').last.toLowerCase()
        : '';

    return FutureBuilder<_MediaResult>(
      future: _future,
      builder: (ctx, snap) {
        if (snap.connectionState == ConnectionState.waiting) {
          return Container(
            width: 280,
            height: 80,
            margin: const EdgeInsets.only(top: 4, bottom: 4),
            decoration: BoxDecoration(
              color: pt.bg2,
              borderRadius: BorderRadius.circular(pt.rBase),
              border: Border.all(color: pt.border),
            ),
            child: Center(child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent)),
          );
        }

        final result = snap.data;
        if (result?.bytes == null) {
          return _FileContent(event: widget.event, pt: pt, icon: Icons.picture_as_pdf_outlined);
        }

        final bytes = result!.bytes!;
        final sender = widget.event.senderFromMemoryOrFallback.calcDisplayname();

        if (ext == 'pdf' || ext == 'ai' || ext == 'eps') {
          return PdfInlinePreview(
            bytes: bytes,
            filename: widget.event.body,
            pt: pt,
            senderName: sender,
            onDownload: () => _saveFile(bytes),
          );
        }

        // SVG — show inline like an image
        return GestureDetector(
          onTap: () => DocumentViewer.show(
            context,
            filename: widget.event.body,
            bytes: bytes,
            senderName: sender,
          ),
          child: Container(
            margin: const EdgeInsets.only(top: 4, bottom: 4),
            constraints: const BoxConstraints(maxWidth: 300, maxHeight: 300),
            decoration: BoxDecoration(
              color: pt.bg1,
              borderRadius: BorderRadius.circular(pt.rBase),
              border: Border.all(color: pt.border),
            ),
            clipBehavior: Clip.hardEdge,
            child: SvgPicture.memory(bytes, fit: BoxFit.contain),
          ),
        );
      },
    );
  }
}

// Inline card for proprietary formats — shows immediately from event metadata,
// downloads lazily only when the user taps or hits the download button.
class _ProprietaryInlineCard extends ConsumerStatefulWidget {
  final Event event;
  final PyramidTheme pt;
  const _ProprietaryInlineCard({required this.event, required this.pt});

  @override
  ConsumerState<_ProprietaryInlineCard> createState() => _ProprietaryInlineCardState();
}

class _ProprietaryInlineCardState extends ConsumerState<_ProprietaryInlineCard> {
  bool _loading = false;
  Uint8List? _cachedBytes;

  String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file';
  String get _ext => _filename.contains('.') ? _filename.split('.').last.toLowerCase() : '';
  int? get _fileSize => widget.event.content.tryGetMap<String, dynamic>('info')?['size'] as int?;

  String _fmtSize(int b) {
    if (b < 1024) return '$b B';
    if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
    return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB';
  }

  IconData get _icon => switch (_ext) {
    'psd' || 'psb'                         => Icons.photo_camera_outlined,
    'ai' || 'eps'                          => Icons.gesture_outlined,
    'indd' || 'inx'                        => Icons.menu_book_outlined,
    'afphoto' || 'afdesign' || 'afpub'     => Icons.palette_outlined,
    'sketch' || 'fig' || 'xd'             => Icons.design_services_outlined,
    'doc'                                  => Icons.description_outlined,
    'ppt'                                  => Icons.slideshow_outlined,
    'xls'                                  => Icons.table_chart_outlined,
    _                                      => Icons.insert_drive_file_outlined,
  };

  Future<Uint8List> _download() async {
    if (_cachedBytes != null) return _cachedBytes!;
    final event = widget.event;
    final shared = _DocBytesCache.get(event.eventId);
    if (shared != null) return _cachedBytes = shared;

    final client = await ref.read(matrixClientProvider.future);
    if (event.isAttachmentEncrypted) {
      final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
      _DocBytesCache.put(event.eventId, file.bytes);
      return _cachedBytes = file.bytes;
    }
    final mxcUri = event.attachmentMxcUrl;
    if (mxcUri == null) throw Exception('Kein URI');
    final httpUri = await mxcUri.getDownloadUri(client);
    final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
    if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
    _DocBytesCache.put(event.eventId, res.bodyBytes);
    return _cachedBytes = res.bodyBytes;
  }

  Future<void> _openViewer() async {
    if (_loading) return;
    setState(() => _loading = true);
    try {
      final bytes = await _download();
      if (!mounted) return;
      await DocumentViewer.show(
        context,
        filename: _filename,
        bytes: bytes,
        senderName: widget.event.senderFromMemoryOrFallback.calcDisplayname(),
      );
    } catch (e) {
      if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)));
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  Future<void> _save() async {
    if (_loading) return;
    setState(() => _loading = true);
    try {
      final bytes = await _download();
      Directory? dir;
      if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
        dir = await getDownloadsDirectory();
      }
      dir ??= await getTemporaryDirectory();
      final outFile = File('${dir.path}/pyramid_${DateTime.now().millisecondsSinceEpoch}.$_ext');
      await outFile.writeAsBytes(bytes);
      if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Gespeichert: ${outFile.path}'), duration: const Duration(seconds: 4)));
    } catch (e) {
      if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)));
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    final extLabel = _ext.toUpperCase();
    final size = _fileSize;

    return GestureDetector(
      onTap: _openViewer,
      child: Container(
        width: 280,
        margin: const EdgeInsets.only(top: 4, bottom: 4),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(pt.rBase),
          border: Border.all(color: pt.border),
        ),
        clipBehavior: Clip.hardEdge,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            // Visual area
            Container(
              width: 280,
              height: 140,
              color: pt.accentSoft,
              child: Stack(
                alignment: Alignment.center,
                children: [
                  Icon(_icon, size: 56, color: pt.accent.withAlpha(180)),
                  Positioned(
                    bottom: 12,
                    right: 12,
                    child: Container(
                      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
                      decoration: BoxDecoration(
                        color: pt.accent,
                        borderRadius: BorderRadius.circular(4),
                      ),
                      child: Text(
                        extLabel.length > 4 ? extLabel.substring(0, 4) : extLabel,
                        style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700),
                      ),
                    ),
                  ),
                ],
              ),
            ),
            // Footer
            Container(
              color: pt.bg2,
              padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
              child: Row(
                children: [
                  Icon(_icon, size: 14, color: pt.fgDim),
                  const SizedBox(width: 6),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Text(_filename, style: TextStyle(color: pt.fg, fontSize: 12, fontWeight: FontWeight.w500), overflow: TextOverflow.ellipsis),
                        if (size != null)
                          Text(_fmtSize(size), style: TextStyle(color: pt.fgMuted, fontSize: 10)),
                      ],
                    ),
                  ),
                  if (_loading)
                    SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent))
                  else
                    GestureDetector(
                      onTap: _save,
                      behavior: HitTestBehavior.opaque,
                      child: Padding(
                        padding: const EdgeInsets.all(2),
                        child: Icon(Icons.download_rounded, size: 16, color: pt.fgMuted),
                      ),
                    ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _FileContent extends ConsumerStatefulWidget {
  final Event event;
  final PyramidTheme pt;
  final IconData icon;
  const _FileContent({required this.event, required this.pt, required this.icon});

  @override
  ConsumerState<_FileContent> createState() => _FileContentState();
}

class _FileContentState extends ConsumerState<_FileContent> {
  bool _saving = false;
  bool _previewing = false;

  String get _filename => widget.event.body.isNotEmpty ? widget.event.body : 'file';

  String _ext() {
    final n = _filename;
    return n.contains('.') ? n.split('.').last.toLowerCase() : 'bin';
  }

  Future<void> _preview() async {
    if (_previewing || _saving) return;
    setState(() => _previewing = true);
    try {
      final client = await ref.read(matrixClientProvider.future);
      final event = widget.event;
      Uint8List bytes;
      if (event.isAttachmentEncrypted) {
        final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
        bytes = file.bytes;
      } else {
        final mxcUri = event.attachmentMxcUrl;
        if (mxcUri == null) throw Exception('Kein URI');
        final httpUri = await mxcUri.getDownloadUri(client);
        final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
        if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
        bytes = res.bodyBytes;
      }
      if (!mounted) return;
      await DocumentViewer.show(
        context,
        filename: _filename,
        bytes: bytes,
        senderName: event.senderFromMemoryOrFallback.calcDisplayname(),
      );
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)),
        );
      }
    } finally {
      if (mounted) setState(() => _previewing = false);
    }
  }

  Future<void> _save({bool openAfter = false}) async {
    if (_saving) return;
    setState(() => _saving = true);
    try {
      final client = await ref.read(matrixClientProvider.future);
      final event = widget.event;
      Uint8List bytes;
      if (event.isAttachmentEncrypted) {
        final file = await event.downloadAndDecryptAttachment(getThumbnail: false, downloadCallback: _checkedDownload(client));
        bytes = file.bytes;
      } else {
        final mxcUri = event.attachmentMxcUrl;
        if (mxcUri == null) throw Exception('Kein URI');
        final httpUri = await mxcUri.getDownloadUri(client);
        final res = await client.httpClient.get(httpUri, headers: {'authorization': 'Bearer ${client.accessToken}'});
        if (res.statusCode != 200) throw Exception('HTTP ${res.statusCode}');
        bytes = res.bodyBytes;
      }

      Directory? dir;
      if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
        dir = await getDownloadsDirectory();
      }
      dir ??= await getTemporaryDirectory();
      final name = 'pyramid_${DateTime.now().millisecondsSinceEpoch}.${_ext()}';
      final outFile = File('${dir.path}/$name');
      await outFile.writeAsBytes(bytes);

      if (openAfter) {
        if (Platform.isWindows) {
          await Process.run('cmd', ['/c', 'start', '', outFile.path]);
        } else if (Platform.isMacOS) {
          await Process.run('open', [outFile.path]);
        } else {
          await Process.run('xdg-open', [outFile.path]);
        }
      }

      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(SnackBar(
          content: Text(openAfter ? 'Geöffnet: ${outFile.path}' : 'Gespeichert: ${outFile.path}'),
          duration: const Duration(seconds: 4),
        ));
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Fehler: $e'), duration: const Duration(seconds: 3)),
        );
      }
    } finally {
      if (mounted) setState(() => _saving = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    final isVideo = widget.event.messageType == 'm.video';

    return Container(
      margin: const EdgeInsets.only(top: 4, bottom: 4),
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
      constraints: const BoxConstraints(maxWidth: 340),
      decoration: BoxDecoration(
        color: pt.bg3,
        borderRadius: BorderRadius.circular(pt.rBase),
        border: Border.all(color: pt.border),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 40,
            height: 40,
            decoration: BoxDecoration(color: pt.accentSoft, borderRadius: BorderRadius.circular(pt.rSm)),
            child: Icon(widget.icon, size: 22, color: pt.accent),
          ),
          const SizedBox(width: 10),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(_filename, style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500), maxLines: 2, overflow: TextOverflow.ellipsis),
                const SizedBox(height: 2),
                Text(_ext().toUpperCase(), style: TextStyle(color: pt.fgDim, fontSize: 11)),
              ],
            ),
          ),
          const SizedBox(width: 8),
          if (_saving || _previewing)
            SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent))
          else
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                if (DocumentViewer.supportsPreview(_filename))
                  IconButton(
                    onPressed: _preview,
                    tooltip: 'Vorschau',
                    icon: Icon(Icons.visibility_outlined, size: 22, color: pt.fgMuted),
                    constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
                    padding: EdgeInsets.zero,
                  ),
                if (isVideo)
                  IconButton(
                    onPressed: () => _save(openAfter: true),
                    tooltip: 'Öffnen',
                    icon: Icon(Icons.play_circle_outline_rounded, size: 22, color: pt.fgMuted),
                    constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
                    padding: EdgeInsets.zero,
                  ),
                IconButton(
                  onPressed: () => _save(),
                  tooltip: 'Herunterladen',
                  icon: Icon(Icons.download_rounded, size: 22, color: pt.fgMuted),
                  constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
                  padding: EdgeInsets.zero,
                ),
              ],
            ),
        ],
      ),
    );
  }
}

String _classifyMediaError(Object e) {
  final s = e.toString();
  if (s.contains('HandshakeException') || s.contains('CERTIFICATE') || s.contains('SocketException')) {
    return 'Verbindungsfehler (TLS/Netzwerk)';
  }
  if (s.contains('HTTP 4') || s.contains('HTTP 5') || s.contains('Unknown error when fetching')) {
    return 'Datei nicht verfügbar (Serverfehler)';
  }
  if (s.contains('decrypt') || s.contains('Decrypt') || s.contains('OlmException')) {
    return 'Entschlüsselung fehlgeschlagen';
  }
  return 'Download fehlgeschlagen';
}

// Thin wrapper over the shared MediaCache so document previews share the same
// LRU byte budget as chat images (keeps the last-viewed media instantly
// available, evicts the oldest).
class _DocBytesCache {
  static Uint8List? get(String eventId) => MediaCache.instance.get(eventId);
  static void put(String eventId, Uint8List bytes) =>
      MediaCache.instance.put(eventId, bytes);
}

// Download callback that validates HTTP status before returning bytes.
// Prevents false "decryption failed" errors when the server returns an
// error page instead of the actual encrypted file bytes.
Future<Uint8List> Function(Uri) _checkedDownload(Client client) {
  return (Uri url) async {
    final res = await client.httpClient.get(
      url,
      headers: {'authorization': 'Bearer ${client.accessToken}'},
    );
    if (res.statusCode != 200) {
      throw Exception('HTTP ${res.statusCode}: ${res.body}');
    }
    return res.bodyBytes;
  };
}

Widget _mediaFallback(PyramidTheme pt, String? error, String body) => Padding(
  padding: const EdgeInsets.all(12),
  child: Row(
    mainAxisSize: MainAxisSize.min,
    children: [
      Icon(Icons.broken_image_outlined, size: 16, color: pt.fgDim),
      const SizedBox(width: 6),
      Flexible(child: Text(error ?? body, style: TextStyle(color: pt.fgDim, fontSize: 12), overflow: TextOverflow.ellipsis)),
    ],
  ),
);

class _MediaResult {
  final String? networkUrl;
  final Uint8List? bytes;
  final String? error;

  _MediaResult._({this.networkUrl, this.bytes, this.error});
  factory _MediaResult.network(String url) => _MediaResult._(networkUrl: url);
  factory _MediaResult.bytes(Uint8List b) => _MediaResult._(bytes: b);
  factory _MediaResult.error(String e) => _MediaResult._(error: e);
  factory _MediaResult.none() => _MediaResult._();
}

// ── Reactions row ─────────────────────────────────────────────────────────

class _ReactionsRow extends StatelessWidget {
  final Event event;
  final Timeline timeline;
  final Room? room;
  final String currentUserId;
  final PyramidTheme pt;

  const _ReactionsRow({
    required this.event,
    required this.timeline,
    required this.currentUserId,
    required this.pt,
    this.room,
  });

  @override
  Widget build(BuildContext context) {
    final reactionEvents = event.aggregatedEvents(timeline, RelationshipTypes.reaction);
    if (reactionEvents.isEmpty) return const SizedBox.shrink();

    // Group by emoji key → list of senders
    final Map<String, List<String>> grouped = {};
    for (final r in reactionEvents) {
      final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
      if (key == null || key.isEmpty) continue;
      (grouped[key] ??= []).add(r.senderId);
    }
    if (grouped.isEmpty) return const SizedBox.shrink();

    return Padding(
      padding: const EdgeInsets.only(top: 4, bottom: 2),
      child: Wrap(
        spacing: 4,
        runSpacing: 4,
        children: grouped.entries.map((e) {
          final myReaction = e.value.contains(currentUserId);
          return _ReactionChip(
            emoji: e.key,
            count: e.value.length,
            mine: myReaction,
            pt: pt,
            onTap: () {
              if (myReaction) {
                // Find own reaction event to redact
                for (final r in reactionEvents) {
                  final key = r.content.tryGetMap<String, dynamic>('m.relates_to')?['key'] as String?;
                  if (key == e.key && r.senderId == currentUserId) {
                    r.redactEvent().catchError((_) => null);
                    break;
                  }
                }
              } else {
                room?.sendReaction(event.eventId, e.key).catchError((_) => '');
                ReactionService.recordUsed(e.key);
              }
            },
          );
        }).toList(),
      ),
    );
  }
}

class _ReactionChip extends StatefulWidget {
  final String emoji;
  final int count;
  final bool mine;
  final PyramidTheme pt;
  final VoidCallback onTap;

  const _ReactionChip({
    required this.emoji,
    required this.count,
    required this.mine,
    required this.pt,
    required this.onTap,
  });

  @override
  State<_ReactionChip> createState() => _ReactionChipState();
}

class _ReactionChipState extends State<_ReactionChip> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    return MouseRegion(
      cursor: SystemMouseCursors.click,
      onEnter: (_) => setState(() => _hovered = true),
      onExit: (_) => setState(() => _hovered = false),
      child: GestureDetector(
        onTap: widget.onTap,
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 120),
          padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
          decoration: BoxDecoration(
            color: widget.mine
                ? pt.accentSoft
                : (_hovered ? pt.bgHover : pt.bg2),
            borderRadius: BorderRadius.circular(pt.rBase),
            border: Border.all(
              color: widget.mine ? pt.accent : (_hovered ? pt.borderStrong : pt.border),
            ),
          ),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(widget.emoji, style: const TextStyle(fontSize: 13)),
              const SizedBox(width: 4),
              Text(
                '${widget.count}',
                style: TextStyle(
                  color: widget.mine ? pt.accent : pt.fgMuted,
                  fontSize: 12,
                  fontWeight: FontWeight.w500,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _Avatar extends ConsumerStatefulWidget {
  final String initial;
  final Color color;
  final PyramidTheme pt;
  final double size;
  final Uri? avatarUri;

  const _Avatar({
    required this.initial,
    required this.color,
    required this.pt,
    required this.size,
    this.avatarUri,
  });

  @override
  ConsumerState<_Avatar> createState() => _AvatarState();
}

class _AvatarState extends ConsumerState<_Avatar> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final client = ref.watch(matrixClientProvider).valueOrNull;
    final radius = BorderRadius.circular(widget.size / 2);

    Widget child;
    if (client != null && widget.avatarUri != null) {
      child = MxcAvatar(
        mxcUri: widget.avatarUri,
        client: client,
        size: widget.size,
        borderRadius: radius,
        placeholder: (_) => _InitialBox(
          initial: widget.initial,
          color: widget.color,
          size: widget.size,
          radius: radius,
        ),
      );
    } else {
      child = _InitialBox(
        initial: widget.initial,
        color: widget.color,
        size: widget.size,
        radius: radius,
      );
    }

    return MouseRegion(
      cursor: SystemMouseCursors.click,
      onEnter: (_) => setState(() => _hovered = true),
      onExit: (_) => setState(() => _hovered = false),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 180),
        curve: Curves.easeOutBack,
        transform: _hovered
            ? Matrix4.diagonal3Values(1.06, 1.06, 1.0)
            : Matrix4.identity(),
        transformAlignment: Alignment.center,
        width: widget.size,
        height: widget.size,
        child: child,
      ),
    );
  }
}

class _InitialBox extends StatelessWidget {
  final String initial;
  final Color color;
  final double size;
  final BorderRadius radius;
  const _InitialBox({required this.initial, required this.color, required this.size, required this.radius});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: size,
      height: size,
      decoration: BoxDecoration(color: color, borderRadius: radius),
      child: Center(
        child: Text(initial, style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
      ),
    );
  }
}

class _HoverActions extends StatefulWidget {
  final PyramidTheme pt;
  final Event event;
  final List<Event> events;
  final Room? room;
  final String currentUserId;
  final VoidCallback? onReply;
  final ValueChanged<bool>? onOverlayStateChanged;

  const _HoverActions({
    required this.pt,
    required this.event,
    required this.events,
    required this.currentUserId,
    this.room,
    this.onReply,
    this.onOverlayStateChanged,
  });

  @override
  State<_HoverActions> createState() => _HoverActionsState();
}

class _HoverActionsState extends State<_HoverActions> {
  OverlayEntry? _reactionOverlay;
  List<String> _quickReactions = ReactionService.quickReactions;

  @override
  void initState() {
    super.initState();
    ReactionService.load().then((_) {
      if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
    });
  }

  void _closeReactionOverlay() {
    _reactionOverlay?.remove();
    _reactionOverlay = null;
    widget.onOverlayStateChanged?.call(false);
  }

  void _toggleReactionPicker() {
    if (_reactionOverlay != null) {
      _closeReactionOverlay();
      return;
    }

    final box = context.findRenderObject() as RenderBox?;
    final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;

    _reactionOverlay = OverlayEntry(builder: (ctx) {
      final screenSize = MediaQuery.sizeOf(ctx);
      const pickerW = 340.0;
      const pickerH = 380.0;
      double left = (offset.dx - pickerW / 2).clamp(8.0, screenSize.width - pickerW - 8);
      double top = (offset.dy - pickerH - 8).clamp(8.0, screenSize.height - pickerH - 8);

      return Material(
        type: MaterialType.transparency,
        child: Stack(children: [
          GestureDetector(
            onTap: _closeReactionOverlay,
            behavior: HitTestBehavior.translucent,
            child: const SizedBox.expand(),
          ),
          Positioned(
            left: left,
            top: top,
            child: EmojiPicker(
              onEmojiSelected: (emoji) {
                _closeReactionOverlay();
                widget.room?.sendReaction(widget.event.eventId, emoji)
                    .catchError((_) => '');
                ReactionService.recordUsed(emoji).then((_) {
                  if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
                });
              },
              onClose: _closeReactionOverlay,
            ),
          ),
        ]),
      );
    });
    widget.onOverlayStateChanged?.call(true);
    Overlay.of(context).insert(_reactionOverlay!);
  }

  void _showMoreMenu() {
    final box = context.findRenderObject() as RenderBox?;
    final offset = box?.localToGlobal(Offset.zero) ?? Offset.zero;
    final size = box?.size ?? Size.zero;

    final isOwnMessage = widget.event.senderId == widget.currentUserId;
    final text = widget.event.body;

    // Find any image or sticker event in the group that can be saved as a sticker.
    final imageEvent = widget.events.firstWhereOrNull(
      (e) => e.messageType == 'm.image' || e.type == EventTypes.Sticker,
    );

    // Capture messenger synchronously before showMenu's async gap so the
    // snackbar can be shown even if the widget is unmounted by then.
    final messenger = ScaffoldMessenger.maybeOf(context);

    showMenu<String>(
      context: context,
      position: RelativeRect.fromLTRB(
        offset.dx, offset.dy - size.height,
        offset.dx + size.width, offset.dy,
      ),
      items: [
        PopupMenuItem(value: 'copy', child: Row(children: [
          Icon(Icons.copy_rounded, size: 16, color: widget.pt.fgMuted),
          const SizedBox(width: 10),
          Text('Kopieren', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
        ])),
        if (imageEvent != null)
          PopupMenuItem(value: 'save_sticker', child: Row(children: [
            Icon(Icons.sticky_note_2_outlined, size: 16, color: widget.pt.fgMuted),
            const SizedBox(width: 10),
            Text('Als Sticker speichern', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
          ])),
        if (isOwnMessage)
          PopupMenuItem(value: 'delete', child: Row(children: [
            Icon(Icons.delete_outline_rounded, size: 16, color: widget.pt.danger),
            const SizedBox(width: 10),
            Text('Löschen', style: TextStyle(color: widget.pt.danger, fontSize: 14)),
          ])),
        PopupMenuItem(value: 'pin', child: Row(children: [
          Icon(Icons.push_pin_outlined, size: 16, color: widget.pt.fgMuted),
          const SizedBox(width: 10),
          Text('Anpinnen', style: TextStyle(color: widget.pt.fg, fontSize: 14)),
        ])),
      ],
    ).then((value) async {
      if (value == 'copy') {
        Clipboard.setData(ClipboardData(text: text));
      } else if (value == 'save_sticker' && imageEvent != null) {
        final ok = await GifFavoriteService.addImageAsStickerFavorite(imageEvent);
        messenger?.showSnackBar(SnackBar(
          content: Text(ok ? 'Als Sticker gespeichert' : 'Fehler beim Speichern'),
          duration: const Duration(seconds: 2),
        ));
      } else if (value == 'delete') {
        widget.event.redactEvent(reason: 'Vom Nutzer gelöscht')
            .catchError((_) => null);
      } else if (value == 'pin') {
        final room = widget.room;
        if (room == null) return;
        final pinned = List<String>.from(
          room.getState(EventTypes.RoomPinnedEvents)?.content['pinned'] as List? ?? [],
        );
        final eventId = widget.event.eventId;
        if (!pinned.contains(eventId)) pinned.add(eventId);
        room.setPinnedEvents(pinned).catchError((_) => '');
      }
    });
  }

  @override
  void dispose() {
    if (_reactionOverlay != null) {
      _reactionOverlay!.remove();
      _reactionOverlay = null;
      widget.onOverlayStateChanged?.call(false);
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final pt = widget.pt;
    return Container(
      padding: const EdgeInsets.all(2),
      decoration: BoxDecoration(
        color: pt.bg0,
        borderRadius: BorderRadius.circular(pt.rBase),
        border: Border.all(color: pt.border),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withAlpha(100),
            blurRadius: 12,
            offset: const Offset(0, 4),
          ),
        ],
      ),
      child: Row(
        children: [
          // Quick reactions
          ..._quickReactions.map((emoji) => _QuickReactionBtn(
            emoji: emoji,
            pt: pt,
            onTap: () {
              widget.room?.sendReaction(widget.event.eventId, emoji)
                  .catchError((_) => '');
              ReactionService.recordUsed(emoji).then((_) {
                if (mounted) setState(() => _quickReactions = ReactionService.quickReactions);
              });
            },
          )),
          Container(width: 1, height: 18, color: pt.border, margin: const EdgeInsets.symmetric(horizontal: 2)),
          _ActionBtn(
            icon: Icons.add_reaction_outlined,
            tooltip: 'Reaktion',
            pt: pt,
            accent: true,
            onTap: _toggleReactionPicker,
          ),
          _ActionBtn(
            icon: Icons.reply_rounded,
            tooltip: 'Antworten',
            pt: pt,
            onTap: widget.onReply,
          ),
          _ActionBtn(
            icon: Icons.more_horiz_rounded,
            tooltip: 'Mehr',
            pt: pt,
            onTap: _showMoreMenu,
          ),
        ],
      ),
    );
  }
}

class _QuickReactionBtn extends StatefulWidget {
  final String emoji;
  final PyramidTheme pt;
  final VoidCallback? onTap;

  const _QuickReactionBtn({required this.emoji, required this.pt, this.onTap});

  @override
  State<_QuickReactionBtn> createState() => _QuickReactionBtnState();
}

class _QuickReactionBtnState extends State<_QuickReactionBtn> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      cursor: SystemMouseCursors.click,
      onEnter: (_) => setState(() => _hovered = true),
      onExit: (_) => setState(() => _hovered = false),
      child: GestureDetector(
        onTap: widget.onTap,
        child: AnimatedContainer(
          duration: const Duration(milliseconds: 100),
          width: 28,
          height: 28,
          decoration: BoxDecoration(
            color: _hovered ? widget.pt.bgHover : Colors.transparent,
            borderRadius: BorderRadius.circular(widget.pt.rSm),
          ),
          child: Center(
            child: Text(widget.emoji, style: const TextStyle(fontSize: 15)),
          ),
        ),
      ),
    );
  }
}

class _ActionBtn extends StatefulWidget {
  final IconData icon;
  final String tooltip;
  final PyramidTheme pt;
  final bool accent;
  final VoidCallback? onTap;

  const _ActionBtn({
    required this.icon,
    required this.tooltip,
    required this.pt,
    this.accent = false,
    this.onTap,
  });

  @override
  State<_ActionBtn> createState() => _ActionBtnState();
}

class _ActionBtnState extends State<_ActionBtn> {
  bool _hovered = false;

  @override
  Widget build(BuildContext context) {
    final color = _hovered
        ? (widget.accent ? widget.pt.accent : widget.pt.fg)
        : widget.pt.fgMuted;

    return MouseRegion(
      cursor: SystemMouseCursors.click,
      onEnter: (_) => setState(() => _hovered = true),
      onExit: (_) => setState(() => _hovered = false),
      child: GestureDetector(
        onTap: widget.onTap,
        child: Tooltip(
          message: widget.tooltip,
          waitDuration: const Duration(milliseconds: 400),
          child: AnimatedContainer(
            duration: const Duration(milliseconds: 120),
            width: 32,
            height: 32,
            decoration: BoxDecoration(
              color: _hovered ? widget.pt.bgHover : Colors.transparent,
              borderRadius: BorderRadius.circular(widget.pt.rSm),
            ),
            child: Center(
              child: Icon(widget.icon, size: 16, color: color),
            ),
          ),
        ),
      ),
    );
  }
}

// Inline "Neue Nachrichten" divider rendered inside a MessageGroup when the
// fullyRead event is in the middle of the group (same sender, <5 min window).
class _InlineUnreadDivider extends StatelessWidget {
  final PyramidTheme pt;
  const _InlineUnreadDivider({required this.pt});

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.fromLTRB(0, 8, 0, 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)),
        ],
      ),
    );
  }
}

// Date divider between message groups
class DateDivider extends StatelessWidget {
  final DateTime date;

  const DateDivider({super.key, required this.date});

  @override
  Widget build(BuildContext context) {
    final pt = PyramidTheme.of(context);
    return Padding(
      padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
      child: Row(
        children: [
          Expanded(child: Divider(color: pt.border, height: 1)),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 12),
            child: Text(
              _formatDate(date),
              style: TextStyle(
                color: pt.fgDim,
                fontSize: 11,
                fontWeight: FontWeight.w600,
                letterSpacing: 0.06 * 11,
              ),
            ),
          ),
          Expanded(child: Divider(color: pt.border, height: 1)),
        ],
      ),
    );
  }

  String _formatDate(DateTime d) {
    final now = DateTime.now();
    if (d.year == now.year && d.month == now.month && d.day == now.day) {
      return 'Heute · ${d.day}. ${_monthName(d.month)}';
    }
    final yesterday = now.subtract(const Duration(days: 1));
    if (d.year == yesterday.year && d.month == yesterday.month && d.day == yesterday.day) {
      return 'Gestern';
    }
    return '${d.day}. ${_monthName(d.month)} ${d.year}';
  }

  String _monthName(int m) => const [
    '', 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
    'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
  ][m];
}

class _MessageStatus extends StatelessWidget {
  final Event lastEvent;
  final PyramidTheme pt;

  const _MessageStatus({required this.lastEvent, required this.pt});

  @override
  Widget build(BuildContext context) {
    final status = lastEvent.status;
    if (status == EventStatus.error) {
      return Icon(Icons.error_outline_rounded, size: 12, color: pt.danger);
    }
    if (status == EventStatus.sending) {
      return Icon(Icons.access_time_rounded, size: 12, color: pt.fgDim);
    }
    // Sent (delivered). "Read" is shown by the per-event ✓✓ marker.
    return Icon(Icons.done_rounded, size: 13, color: pt.fgDim);
  }
}

// Read receipt: a ✓✓ shown at the partner's last-read message (any sender),
// so it moves next to their own message when they reply.
class _ReadMarker extends StatelessWidget {
  final PyramidTheme pt;
  const _ReadMarker({required this.pt});

  // Goldener Doppelhaken — bewusst nicht die Akzentfarbe, damit "gelesen"
  // unabhängig vom Theme sofort erkennbar ist.
  static const _gold = Color(0xFFE6B422);

  @override
  Widget build(BuildContext context) {
    return const Padding(
      padding: EdgeInsets.only(right: 24, top: 1, bottom: 1),
      child: Align(
        alignment: Alignment.centerRight,
        child: Icon(Icons.done_all_rounded, size: 13, color: _gold),
      ),
    );
  }
}


// ─── Matrix HTML renderer ──────────────────────────────────────────────────────
//
// Handles the subset of HTML that Matrix clients emit in formatted_body:
//   <b>/<strong>  → bold
//   <i>/<em>      → italic
//   <del>/<s>     → strikethrough
//   <u>           → underline
//   <code>        → monospace, slightly different background
//   <a href>      → tappable link (url_launcher)
//   <br>          → newline
//   HTML entities → decoded
//   Anything else → text content preserved, tags stripped
//
// Note: <pre> and <blockquote> are simplified (rendered as indented code/text).

class _MatrixHtmlText extends StatelessWidget {
  final String html;
  final TextStyle baseStyle;
  final Color linkColor;

  const _MatrixHtmlText({
    required this.html,
    required this.baseStyle,
    required this.linkColor,
  });

  @override
  Widget build(BuildContext context) {
    final spans = _parseHtml(html, baseStyle, linkColor);
    return SelectionArea(
      child: RichText(text: TextSpan(children: spans, style: baseStyle)),
    );
  }

  List<InlineSpan> _parseHtml(String html, TextStyle base, Color linkColor) {
    // Normalise line endings and collapse whitespace around block elements.
    var src = html
        .replaceAll('\r\n', '\n')
        .replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
        .replaceAll(RegExp(r'</(p|div|blockquote|pre|li)>', caseSensitive: false), '\n')
        .replaceAll(RegExp(r'<(p|div|li)[^>]*>', caseSensitive: false), '')
        .replaceAll(RegExp(r'<blockquote[^>]*>', caseSensitive: false), '  ')
        .replaceAll(RegExp(r'<pre[^>]*>', caseSensitive: false), '');

    final spans = <InlineSpan>[];

    // Simple recursive stack-based parser.
    // We tokenise into (tag | text) chunks, then apply style based on open tags.
    final tagRe = RegExp(r'<(/?)(\w+)([^>]*)>', caseSensitive: false);

    var bold = 0;
    var italic = 0;
    var strike = 0;
    var underline = 0;
    var code = 0;
    String? pendingHref;

    int pos = 0;
    for (final m in tagRe.allMatches(src)) {
      // Text before this tag.
      if (m.start > pos) {
        final text = _decodeEntities(src.substring(pos, m.start));
        if (text.isNotEmpty) {
          spans.add(_buildSpan(
            text, base, bold > 0, italic > 0, strike > 0, underline > 0,
            code > 0, pendingHref, linkColor,
          ));
          if (pendingHref != null) pendingHref = null;
        }
      }
      pos = m.end;

      final closing = m.group(1) == '/';
      final tag = m.group(2)!.toLowerCase();
      final attrs = m.group(3) ?? '';

      if (closing) {
        switch (tag) {
          case 'b': case 'strong': bold = (bold - 1).clamp(0, 99); break;
          case 'i': case 'em':    italic = (italic - 1).clamp(0, 99); break;
          case 'del': case 's': case 'strike': strike = (strike - 1).clamp(0, 99); break;
          case 'u':   underline = (underline - 1).clamp(0, 99); break;
          case 'code': code = (code - 1).clamp(0, 99); break;
          case 'a':   pendingHref = null; break;
        }
      } else {
        switch (tag) {
          case 'b': case 'strong': bold++; break;
          case 'i': case 'em':    italic++; break;
          case 'del': case 's': case 'strike': strike++; break;
          case 'u':   underline++; break;
          case 'code': code++; break;
          case 'a':
            final hrefMatch = RegExp("""href=["']([^"']+)["']""", caseSensitive: false)
                .firstMatch(attrs);
            pendingHref = hrefMatch?.group(1);
            break;
        }
      }
    }

    // Remaining text after last tag.
    if (pos < src.length) {
      final text = _decodeEntities(src.substring(pos));
      if (text.isNotEmpty) {
        spans.add(_buildSpan(
          text, base, bold > 0, italic > 0, strike > 0, underline > 0,
          code > 0, pendingHref, linkColor,
        ));
      }
    }

    return spans;
  }

  InlineSpan _buildSpan(
    String text,
    TextStyle base,
    bool bold, bool italic, bool strike, bool underline, bool code,
    String? href, Color linkColor,
  ) {
    var style = base.copyWith(
      fontWeight: bold ? FontWeight.bold : null,
      fontStyle: italic ? FontStyle.italic : null,
      decoration: TextDecoration.combine([
        if (strike) TextDecoration.lineThrough,
        if (underline) TextDecoration.underline,
      ]),
      fontFamily: code ? 'monospace' : null,
      fontSize: code ? (base.fontSize ?? 14) * 0.9 : null,
    );

    if (href != null) {
      style = style.copyWith(
        color: linkColor,
        decoration: TextDecoration.underline,
        decorationColor: linkColor,
      );
      return TextSpan(
        text: text,
        style: style,
        recognizer: TapGestureRecognizer()
          ..onTap = () => launchUrl(Uri.parse(href), mode: LaunchMode.externalApplication),
      );
    }
    return TextSpan(text: text, style: style);
  }

  static String _decodeEntities(String s) => s
      .replaceAll('&amp;', '&')
      .replaceAll('&lt;', '<')
      .replaceAll('&gt;', '>')
      .replaceAll('&quot;', '"')
      .replaceAll('&#39;', "'")
      .replaceAll('&apos;', "'")
      .replaceAll('&nbsp;', ' ');
}

// ─── Plain text with clickable URLs ───────────────────────────────────────────

class _PlainLinkText extends StatelessWidget {
  final String text;
  final TextStyle baseStyle;
  final Color linkColor;

  const _PlainLinkText({
    required this.text,
    required this.baseStyle,
    required this.linkColor,
  });

  static final _urlRe = RegExp(
    r'https?://[^\s\]\)>]+',
    caseSensitive: false,
  );

  @override
  Widget build(BuildContext context) {
    final matches = _urlRe.allMatches(text).toList();
    if (matches.isEmpty) {
      return SelectableText(text, style: baseStyle);
    }

    final spans = <InlineSpan>[];
    int pos = 0;
    for (final m in matches) {
      if (m.start > pos) {
        spans.add(TextSpan(text: text.substring(pos, m.start)));
      }
      final url = m.group(0)!;
      spans.add(TextSpan(
        text: url,
        style: baseStyle.copyWith(
          color: linkColor,
          decoration: TextDecoration.underline,
          decorationColor: linkColor,
        ),
        recognizer: TapGestureRecognizer()
          ..onTap = () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
      ));
      pos = m.end;
    }
    if (pos < text.length) {
      spans.add(TextSpan(text: text.substring(pos)));
    }

    return SelectionArea(
      child: RichText(text: TextSpan(children: spans, style: baseStyle)),
    );
  }
}
