import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:whitenoise/providers/account_pubkey_provider.dart';
import 'package:whitenoise/providers/auth_provider.dart';
import 'package:whitenoise/providers/debug_view_provider.dart';
import 'package:whitenoise/providers/offline_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/screens/chat_info_screen.dart';
import 'package:whitenoise/screens/chat_list_screen.dart';
import 'package:whitenoise/screens/chat_raw_debug_screen.dart';
import 'package:whitenoise/screens/chat_screen.dart';
import 'package:whitenoise/screens/group_info_screen.dart';
import 'package:whitenoise/screens/message_actions_screen.dart';
import 'package:whitenoise/src/rust/api/account_groups.dart';
import 'package:whitenoise/src/rust/api/chat_list.dart';
import 'package:whitenoise/src/rust/api/chat_summary.dart';
import 'package:whitenoise/src/rust/api/drafts.dart';
import 'package:whitenoise/src/rust/api/groups.dart';
import 'package:whitenoise/src/rust/api/markdown.dart';
import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/src/rust/api/messages.dart';
import 'package:whitenoise/src/rust/api/metadata.dart';
import 'package:whitenoise/src/rust/api/mute_list.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
import 'package:whitenoise/utils/mention_text_editing_controller.dart';
import 'package:whitenoise/widgets/chat_media_upload_preview.dart';
import 'package:whitenoise/widgets/chat_message_quote.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_button.dart';
import 'package:whitenoise/widgets/wn_chat_message_input.dart';
import 'package:whitenoise/widgets/wn_message_bubble.dart';
import 'package:whitenoise/widgets/wn_slate.dart';
import 'package:whitenoise/widgets/wn_system_notice.dart';

import '../mocks/mock_wn_api.dart';
import '../test_helpers.dart';

const _testPubkey = testPubkeyA;
const _testGroupId = testGroupId;

class _MockImagePickerPlatform extends ImagePickerPlatform with MockPlatformInterfaceMixin {
  List<XFile> filesToReturn = [];

  @override
  Future<List<XFile>> getMultiImageWithOptions({
    MultiImagePickerOptions options = const MultiImagePickerOptions(),
  }) async {
    return filesToReturn;
  }

  @override
  Future<List<XFile>> getMedia({required MediaOptions options}) async {
    return filesToReturn;
  }
}

class _MockTag implements Tag {
  final List<String> vec;
  _MockTag(this.vec);

  @override
  void dispose() {}

  @override
  bool get isDisposed => false;
}

ChatMessage _message(
  String id,
  DateTime createdAt, {
  String pubkey = testPubkeyB,
  bool isDeleted = false,
  bool isReply = false,
  String? replyToId,
  ReactionSummary reactions = const ReactionSummary(
    byEmoji: [],
    userReactions: [],
  ),
  DeliveryStatus? deliveryStatus,
}) => ChatMessage(
  id: id,
  pubkey: pubkey,
  content: 'Message $id',
  createdAt: createdAt,
  tags: const [],
  isReply: isReply,
  replyToId: replyToId,
  isDeleted: isDeleted,
  contentTokens: const MarkdownDocument(blocks: []),
  reactions: reactions,
  mediaAttachments: const [],
  kind: 9,
  deliveryStatus: deliveryStatus,
);

class _MockApi extends MockWnApi {
  StreamController<MessageStreamItem>? controller;
  final List<StreamController<ChatListStreamItem>> chatListControllers = [];
  List<ChatMessage> initialMessages = [];
  String groupName = 'Test Group';
  final List<String> sentMessages = [];
  final List<({String groupId, int kind, List<Tag>? tags})> deletionCalls = [];
  final List<({String groupId, String message, int kind, List<Tag>? tags})> reactionCalls = [];
  final List<List<List<String>>> sentTextMessageTagVecs = [];
  final List<({String pubkey, String groupId, String eventId})> retryAttempts = [];
  Exception? sendError;
  Exception? deleteError;
  Exception? reactionError;
  Exception? retryError;
  int _sendCallCount = 0;
  bool isDm = false;
  Completer<ChatSummary>? chatSummaryCompleter;
  List<String> groupMembers = [];
  Completer<MediaFile>? uploadCompleter;
  Map<String, FlutterMetadata>? metadataByPubkey;
  Completer<List<ChatMessage>>? fetchOlderCompleter;
  Completer<List<MuteListEntry>>? blockedUsersCompleter;
  List<Completer<List<MuteListEntry>>> blockedUsersCompleters = [];
  int getBlockedUsersCallCount = 0;
  DateTime? removedAt;
  bool selfRemoved = false;
  bool shouldFailUnblockUser = false;
  Completer<void>? unblockUserCompleter;
  List<SearchResult> Function(String query)? searchOverride;
  Completer<void>? getAccountGroupCompleter;

  @override
  void reset() {
    super.reset();
    controller?.close();
    controller = null;
    for (final chatListController in [...chatListControllers]) {
      chatListController.close();
    }
    chatListControllers.clear();
    initialMessages = [];
    groupName = 'Test Group';
    sentMessages.clear();
    deletionCalls.clear();
    reactionCalls.clear();
    retryAttempts.clear();
    sentTextMessageTagVecs.clear();
    sendError = null;
    deleteError = null;
    reactionError = null;
    retryError = null;
    _sendCallCount = 0;
    isDm = false;
    chatSummaryCompleter = null;
    groupMembers = [];
    uploadCompleter = null;
    metadataByPubkey = null;
    fetchOlderCompleter = null;
    blockedUsersCompleter = null;
    blockedUsersCompleters = [];
    getBlockedUsersCallCount = 0;
    removedAt = null;
    selfRemoved = false;
    shouldFailUnblockUser = false;
    unblockUserCompleter = null;
    searchOverride = null;
    getAccountGroupCompleter = null;
  }

  @override
  Future<List<ChatMessage>> crateApiMessagesFetchAggregatedMessagesForGroup({
    required String pubkey,
    required String groupId,
    DateTime? before,
    String? beforeMessageId,
    int? limit,
  }) {
    if (fetchOlderCompleter != null) return fetchOlderCompleter!.future;
    if (before == null && beforeMessageId == null) {
      return Future.value(initialMessages);
    }
    if (before != null && beforeMessageId != null && initialMessages.isNotEmpty) {
      final sorted = [...initialMessages]..sort((a, b) => a.createdAt.compareTo(b.createdAt));
      final idx = sorted.indexWhere((m) => m.id == beforeMessageId);
      if (idx > 0) {
        final start = (idx - (limit ?? 2)).clamp(0, idx);
        return Future.value(sorted.sublist(start, idx));
      }
    }
    return Future.value([]);
  }

  @override
  Future<List<MuteListEntry>> crateApiMuteListGetBlockedUsers({
    required String accountPubkey,
  }) {
    final callIndex = getBlockedUsersCallCount;
    getBlockedUsersCallCount++;
    if (blockedUsersCompleters.isNotEmpty && callIndex < blockedUsersCompleters.length) {
      return blockedUsersCompleters[callIndex].future;
    }
    if (blockedUsersCompleter != null) return blockedUsersCompleter!.future;
    return super.crateApiMuteListGetBlockedUsers(accountPubkey: accountPubkey);
  }

  @override
  Stream<ChatListStreamItem> crateApiChatListSubscribeToChatList({
    required String accountPubkey,
  }) {
    final controller = StreamController<ChatListStreamItem>();
    chatListControllers.add(controller);
    Future.microtask(() {
      if (!controller.isClosed) {
        controller.add(
          ChatListStreamItem.initialSnapshot(items: [_chatSummary()]),
        );
      }
    });
    controller.onCancel = () {
      chatListControllers.remove(controller);
      if (!controller.isClosed) controller.close();
    };
    return controller.stream;
  }

  ChatSummary _chatSummary() {
    final groupType = isDm ? GroupType.directMessage : GroupType.group;
    final dmPeerPubkey = isDm ? groupMembers.where((p) => p != _testPubkey).firstOrNull : null;
    final name = isDm ? null : groupName;
    return ChatSummary(
      mlsGroupId: _testGroupId,
      groupType: groupType,
      name: name,
      createdAt: DateTime(2024),
      pendingConfirmation: false,
      selfRemoved: selfRemoved,
      unreadCount: BigInt.zero,
      removedAt: removedAt,
      dmPeerPubkey: dmPeerPubkey,
    );
  }

  @override
  Future<ChatSummary> crateApiChatSummaryGetChatSummary({
    required String accountPubkey,
    required String mlsGroupId,
  }) {
    if (chatSummaryCompleter != null) return chatSummaryCompleter!.future;
    return Future.value(_chatSummary());
  }

  void emitChatListUpdate(ChatListUpdateTrigger trigger) {
    final item = ChatListStreamItem.update(
      update: ChatListUpdate(item: _chatSummary(), trigger: trigger),
    );
    for (final controller in [...chatListControllers]) {
      if (!controller.isClosed) controller.add(item);
    }
  }

  @override
  Future<void> crateApiMessagesRetryMessagePublish({
    required String pubkey,
    required String groupId,
    required String eventId,
  }) async {
    retryAttempts.add((pubkey: pubkey, groupId: groupId, eventId: eventId));
    if (retryError != null) throw retryError!;
  }

  @override
  Future<AccountGroup> crateApiAccountGroupsGetAccountGroup({
    required String accountPubkey,
    required String mlsGroupId,
  }) async {
    if (getAccountGroupCompleter != null) {
      await getAccountGroupCompleter!.future;
    }
    return super.crateApiAccountGroupsGetAccountGroup(
      accountPubkey: accountPubkey,
      mlsGroupId: mlsGroupId,
    );
  }

  @override
  Future<Tag> crateApiUtilsTagFromVec({required List<String> vec}) async {
    return _MockTag(vec);
  }

  @override
  String crateApiUtilsEventIdToNeventUri({
    required String eventIdHex,
    required String pubkeyHex,
  }) {
    return 'nostr:nevent1mock$eventIdHex';
  }

  void emitMessage(ChatMessage message) {
    controller?.add(
      MessageStreamItem.update(
        update: MessageUpdate(
          trigger: UpdateTrigger.newMessage,
          message: message,
        ),
      ),
    );
  }

  @override
  Future<MessageWithTokens> crateApiMessagesSendMessageToGroup({
    required String pubkey,
    required String groupId,
    required String message,
    required int kind,
    List<Tag>? tags,
  }) async {
    _sendCallCount++;

    if (kind == 5) {
      if (deleteError != null) throw deleteError!;
      deletionCalls.add((groupId: groupId, kind: kind, tags: tags));
    } else if (kind == 7) {
      if (reactionError != null) throw reactionError!;
      reactionCalls.add((
        groupId: groupId,
        message: message,
        kind: kind,
        tags: tags,
      ));
    } else {
      if (sendError != null) throw sendError!;
      sentMessages.add(message);
      if (tags != null) {
        sentTextMessageTagVecs.add(
          tags.cast<_MockTag>().map((t) => t.vec).toList(),
        );
      }
    }
    return MessageWithTokens(
      id: 'mock_$_sendCallCount',
      pubkey: pubkey,
      kind: kind,
      createdAt: DateTime.now(),
      content: message,
      tokens: const MarkdownDocument(blocks: []),
    );
  }

  @override
  Stream<MessageStreamItem> crateApiMessagesSubscribeToGroupMessages({
    String? pubkey,
    required String groupId,
  }) {
    controller?.close();
    controller = StreamController<MessageStreamItem>.broadcast();
    Future.microtask(() {
      controller?.add(
        MessageStreamItem.initialSnapshot(messages: initialMessages),
      );
    });
    return controller!.stream;
  }

  @override
  Future<FlutterMetadata> crateApiUsersUserMetadata({
    required bool blockingDataSync,
    required String pubkey,
  }) async {
    if (metadataByPubkey != null && metadataByPubkey!.containsKey(pubkey)) {
      return metadataByPubkey![pubkey]!;
    }
    return super.crateApiUsersUserMetadata(
      blockingDataSync: blockingDataSync,
      pubkey: pubkey,
    );
  }

  @override
  Future<Group> crateApiGroupsGetGroup({
    required String accountPubkey,
    required String groupId,
  }) {
    return Future.value(
      Group(
        mlsGroupId: groupId,
        nostrGroupId: 'nostr_$groupId',
        name: groupName,
        description: '',
        adminPubkeys: const [],
        epoch: BigInt.zero,
        state: GroupState.active,
      ),
    );
  }

  @override
  Future<String?> crateApiGroupsGetGroupImagePath({
    required String accountPubkey,
    required String groupId,
  }) {
    return Future.value('https://example.com/group.jpg');
  }

  @override
  Future<bool> crateApiGroupsGroupIsDirectMessageType({
    required Group that,
    required String accountPubkey,
  }) {
    return Future.value(isDm);
  }

  @override
  Future<List<String>> crateApiGroupsGroupMembers({
    required String pubkey,
    required String groupId,
  }) {
    return Future.value(groupMembers);
  }

  @override
  Future<List<String>> crateApiGroupsGroupAdmins({
    required String pubkey,
    required String groupId,
  }) {
    return Future.value([]);
  }

  @override
  Future<List<SearchResult>> crateApiMessagesSearchMessagesInGroup({
    required String pubkey,
    required String groupId,
    required String query,
    int? limit,
  }) async {
    if (searchOverride != null) return searchOverride!(query);
    final matches = initialMessages
        .where((m) => m.content.toLowerCase().contains(query.toLowerCase()))
        .toList();
    return matches
        .asMap()
        .entries
        .map(
          (entry) => SearchResult(
            message: entry.value,
            mlsGroupId: groupId,
            highlightSpans: [
              HighlightSpan(
                start: entry.value.content.toLowerCase().indexOf(
                  query.toLowerCase(),
                ),
                end:
                    entry.value.content.toLowerCase().indexOf(
                      query.toLowerCase(),
                    ) +
                    query.length,
              ),
            ],
            position: BigInt.from(entry.key),
          ),
        )
        .toList();
  }

  @override
  Future<bool> crateApiMuteListIsUserBlocked({
    required String accountPubkey,
    required String targetPubkey,
  }) async => blockedPubkeys.contains(targetPubkey);

  @override
  Future<void> crateApiMuteListUnblockUser({
    required String accountPubkey,
    required String targetPubkey,
  }) async {
    if (unblockUserCompleter != null) await unblockUserCompleter!.future;
    if (shouldFailUnblockUser) throw Exception('Unblock failed');
    blockedPubkeys.remove(targetPubkey);
  }

  @override
  Future<MediaFile> crateApiMediaFilesUploadChatMedia({
    required String accountPubkey,
    required String groupId,
    required String filePath,
  }) async {
    if (uploadCompleter != null) {
      return uploadCompleter!.future;
    }
    return MediaFile(
      id: 'media_${filePath.hashCode}',
      mlsGroupId: groupId,
      accountPubkey: accountPubkey,
      filePath: filePath,
      encryptedFileHash: 'encrypted_hash',
      mimeType: 'image/jpeg',
      mediaType: 'image',
      blossomUrl: 'https://example.com/media',
      nostrKey: 'nostr_key',
      createdAt: DateTime(2024),
    );
  }
}

class _MockAuthNotifier extends AuthNotifier {
  @override
  Future<String?> build() async => _testPubkey;
}

class _MockAccountPubkeyNotifier extends AccountPubkeyNotifier {
  @override
  String build() => _testPubkey;
}

class _MockDebugViewNotifier extends DebugViewNotifier {
  @override
  Future<bool> build() async => true;
}

final _api = _MockApi();

void main() {
  setUpAll(() {
    mockPathProvider();
    RustLib.initMock(api: _api);
  });
  setUp(() => _api.reset());

  Future<void> pumpChatScreen(
    WidgetTester tester, {
    List overrides = const [],
    bool isOffline = false,
    bool settle = true,
  }) async {
    await mountTestApp(
      tester,
      overrides: [
        authProvider.overrideWith(() => _MockAuthNotifier()),
        offlineProvider.overrideWith((ref) => Stream.value(isOffline)),
        ...overrides,
      ],
    );
    await tester.pumpAndSettle();
    Routes.goToChat(tester.element(find.byType(Scaffold)), _testGroupId);
    if (settle) {
      await tester.pumpAndSettle();
    } else {
      await tester.pump();
    }
  }

  group('ChatScreen', () {
    testWidgets('displays group name in header', (tester) async {
      _api.groupName = 'My Chat Group';
      await pumpChatScreen(tester);

      expect(find.text('My Chat Group'), findsOneWidget);
    });

    testWidgets('displays Unknown group when group name is empty', (
      tester,
    ) async {
      _api.groupName = '';
      await pumpChatScreen(tester);

      expect(find.text('Unknown group'), findsOneWidget);
    });

    testWidgets('displays disabled chat input', (tester) async {
      await pumpChatScreen(tester);

      expect(find.text('Message'), findsOneWidget);
    });

    testWidgets('displays back button', (tester) async {
      await pumpChatScreen(tester);

      expect(find.byKey(const Key('back_button')), findsOneWidget);
    });

    testWidgets('displays avatar', (tester) async {
      await pumpChatScreen(tester);

      expect(find.byType(WnAvatar), findsOneWidget);
    });

    group('avatar color', () {
      group('when group', () {
        testWidgets('uses group ID color', (tester) async {
          await pumpChatScreen(tester);

          final avatar = tester.widget<WnAvatar>(find.byType(WnAvatar));
          expect(avatar.color, AvatarColor.violet);
        });
      });

      group('when DM', () {
        setUp(() {
          _api.isDm = true;
          _api.groupMembers = [_testPubkey, testPubkeyC];
        });

        testWidgets('uses other member pubkey color', (tester) async {
          await pumpChatScreen(tester);

          final avatar = tester.widget<WnAvatar>(find.byType(WnAvatar));
          expect(avatar.color, AvatarColor.blue);
        });

        testWidgets('displays Unknown user when display name is null', (
          tester,
        ) async {
          _api.metadataByPubkey = {
            testPubkeyC: const FlutterMetadata(custom: {}),
          };
          await pumpChatScreen(tester);

          expect(find.text('Unknown user'), findsOneWidget);
        });
      });
    });

    group('with no messages', () {
      testWidgets('shows empty state', (tester) async {
        await pumpChatScreen(tester);

        expect(find.text('No messages yet'), findsOneWidget);
      });
    });

    group('loading older messages indicator', () {
      testWidgets('hidden when not loading older messages', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        expect(
          find.byKey(const Key('loading_older_messages_indicator')),
          findsNothing,
        );
      });

      testWidgets('visible while older messages are being fetched', (
        tester,
      ) async {
        // Use enough messages to create a scrollable list with sufficient height so
        // the top-threshold scroll listener fires when scrolled to the oldest end.
        _api.initialMessages = List.generate(
          20,
          (i) => _message('m$i', DateTime(2024, 1, i + 1)),
        );
        _api.lastReadMessageId = 'm19';
        final completer = Completer<List<ChatMessage>>();
        _api.fetchOlderCompleter = completer;
        await pumpChatScreen(tester);
        await tester.pumpAndSettle();

        // The list is reversed (newest at pixels=0). Scroll to maxScrollExtent
        // (oldest end) to cross the top-threshold and trigger loadOlderMessages.
        final position = Scrollable.of(
          tester.element(find.byType(WnMessageBubble).first),
        ).position;
        position.jumpTo(position.maxScrollExtent);
        // Notify listeners manually since jumpTo in tests does not always fire them.
        await tester.pump();
        await tester.pump();

        expect(
          find.byKey(const Key('loading_older_messages_indicator')),
          findsOneWidget,
        );

        completer.complete([]);
        await tester.pumpAndSettle();

        expect(
          find.byKey(const Key('loading_older_messages_indicator')),
          findsNothing,
        );
      });
    });

    group('with messages', () {
      testWidgets('displays messages', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024, 1, 2)),
          _message('m2', DateTime(2024, 1, 3)),
        ];
        await pumpChatScreen(tester);

        expect(find.byType(WnMessageBubble), findsNWidgets(2));
      });

      testWidgets('displays message content', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024, 1, 4))];
        await pumpChatScreen(tester);

        expect(find.textContaining('Message m1'), findsOneWidget);
      });

      testWidgets('does not display deleted message text', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024, 1, 2)),
          _message('m2', DateTime(2024, 1, 3), isDeleted: true),
        ];
        await pumpChatScreen(tester);

        expect(find.textContaining('Message m2'), findsNothing);
      });
    });

    group('bubble grouping', () {
      Finder avatarsInBubbles() => find.descendant(
        of: find.byType(WnMessageBubble),
        matching: find.byType(WnAvatar),
      );

      testWidgets('same sender <5 min apart: only older message shows avatar', (
        tester,
      ) async {
        final base = DateTime(2024, 1, 1, 12);
        _api.initialMessages = [
          _message('m1', base),
          _message('m2', base.add(const Duration(minutes: 2))),
        ];
        await pumpChatScreen(tester);

        expect(avatarsInBubbles(), findsOneWidget);
      });

      testWidgets('same sender >=5 min apart: both messages show avatar', (
        tester,
      ) async {
        final base = DateTime(2024, 1, 1, 12);
        _api.initialMessages = [
          _message('m1', base),
          _message('m2', base.add(const Duration(minutes: 5))),
        ];
        await pumpChatScreen(tester);

        expect(avatarsInBubbles(), findsNWidgets(2));
      });

      testWidgets('different senders: both messages show avatar', (
        tester,
      ) async {
        final base = DateTime(2024, 1, 1, 12);
        _api.initialMessages = [
          _message('m1', base),
          _message(
            'm2',
            base.add(const Duration(minutes: 1)),
            pubkey: testPubkeyC,
          ),
        ];
        await pumpChatScreen(tester);

        expect(avatarsInBubbles(), findsNWidgets(2));
      });

      testWidgets('own messages never show avatar', (tester) async {
        final base = DateTime(2024, 1, 1, 12);
        _api.initialMessages = [
          _message('m1', base, pubkey: _testPubkey),
          _message(
            'm2',
            base.add(const Duration(minutes: 10)),
            pubkey: _testPubkey,
          ),
        ];
        await pumpChatScreen(tester);

        expect(avatarsInBubbles(), findsNothing);
      });

      testWidgets('single incoming message shows avatar', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        expect(avatarsInBubbles(), findsOneWidget);
      });

      testWidgets(
        'DM never shows bubble avatar while chat profile is loading',
        (tester) async {
          _api.initialMessages = [_message('m1', DateTime(2024))];
          _api.isDm = true;
          _api.chatSummaryCompleter = Completer<ChatSummary>();
          _api.groupMembers = [_testPubkey, testPubkeyC];

          await mountWidget(
            const ChatScreen(groupId: _testGroupId),
            tester,
            overrides: [
              accountPubkeyProvider.overrideWith(
                _MockAccountPubkeyNotifier.new,
              ),
              authProvider.overrideWith(() => _MockAuthNotifier()),
              offlineProvider.overrideWith((ref) => Stream.value(false)),
            ],
          );
          await tester.pump();
          await tester.pump();

          expect(find.byType(WnMessageBubble), findsOneWidget);
          expect(avatarsInBubbles(), findsNothing);

          _api.chatSummaryCompleter!.complete(
            ChatSummary(
              mlsGroupId: _testGroupId,
              groupType: GroupType.directMessage,
              createdAt: DateTime(2024),
              pendingConfirmation: false,
              selfRemoved: false,
              unreadCount: BigInt.zero,
              dmPeerPubkey: testPubkeyC,
            ),
          );
          await tester.pumpAndSettle();

          expect(avatarsInBubbles(), findsNothing);
        },
      );

      testWidgets('same sender <5 min apart: only older message has tail', (
        tester,
      ) async {
        final base = DateTime(2024, 1, 1, 12);
        _api.initialMessages = [
          _message('m1', base),
          _message('m2', base.add(const Duration(minutes: 2))),
        ];
        await pumpChatScreen(tester);

        final tails = find.descendant(
          of: find.byType(WnMessageBubble),
          matching: find.byType(CustomPaint),
        );
        expect(tails, findsOneWidget);
      });

      testWidgets('same sender >=5 min apart: both messages have tail', (
        tester,
      ) async {
        final base = DateTime(2024, 1, 1, 12);
        _api.initialMessages = [
          _message('m1', base),
          _message('m2', base.add(const Duration(minutes: 5))),
        ];
        await pumpChatScreen(tester);

        final tails = find.descendant(
          of: find.byType(WnMessageBubble),
          matching: find.byType(CustomPaint),
        );
        expect(tails, findsNWidgets(2));
      });
    });

    group('navigation', () {
      testWidgets('back button navigates to chat list', (tester) async {
        await pumpChatScreen(tester);
        await tester.tap(find.byKey(const Key('back_button')));
        await tester.pumpAndSettle();
        expect(find.byType(ChatListScreen), findsOneWidget);
      });

      testWidgets('OS back navigates to chat list', (tester) async {
        await pumpChatScreen(tester);
        await tester.binding.handlePopRoute();
        await tester.pumpAndSettle();
        expect(find.byType(ChatListScreen), findsOneWidget);
      });

      testWidgets('avatar navigates to group info screen for group chat', (
        tester,
      ) async {
        await pumpChatScreen(tester);
        await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
        await tester.pumpAndSettle();
        expect(find.byType(GroupInfoScreen), findsOneWidget);
      });

      testWidgets('avatar navigates to chat info screen for DM', (
        tester,
      ) async {
        _api.isDm = true;
        _api.groupMembers = [_testPubkey, testPubkeyC];
        await pumpChatScreen(tester);
        await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
        await tester.pumpAndSettle();
        expect(find.byType(ChatInfoScreen), findsOneWidget);
      });

      testWidgets('name navigates to chat info screen for DM', (tester) async {
        _api.isDm = true;
        _api.groupMembers = [_testPubkey, testPubkeyC];
        await pumpChatScreen(tester);
        await tester.tap(find.byKey(const Key('header_name_tap_area')));
        await tester.pumpAndSettle();
        expect(find.byType(ChatInfoScreen), findsOneWidget);
      });

      testWidgets('returning from chat info reloads blocked pubkeys', (
        tester,
      ) async {
        _api.isDm = true;
        _api.groupMembers = [_testPubkey, testPubkeyC];
        await pumpChatScreen(tester);

        final callCountBefore = _api.getBlockedUsersCallCount;
        await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
        await tester.pumpAndSettle();
        expect(find.byType(ChatInfoScreen), findsOneWidget);

        await tester.tap(find.byKey(const Key('slate_back_button')));
        await tester.pumpAndSettle();

        expect(
          _api.getBlockedUsersCallCount,
          greaterThanOrEqualTo(callCountBefore + 1),
        );
      });
    });

    group('message sending', () {
      testWidgets('send button appears when text is entered', (tester) async {
        await pumpChatScreen(tester);
        await tester.enterText(find.byType(TextField), 'Hello');
        await tester.pumpAndSettle();

        expect(find.byKey(const Key('send_button')), findsOneWidget);
      });

      testWidgets('input is cleared after sending', (tester) async {
        await pumpChatScreen(tester);
        await tester.enterText(find.byType(TextField), 'Hello');
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('send_button')));
        await tester.pumpAndSettle();

        final textField = tester.widget<TextField>(find.byType(TextField));
        expect(textField.controller!.text, isEmpty);
      });

      testWidgets('send button disappears after sending', (tester) async {
        await pumpChatScreen(tester);
        await tester.enterText(find.byType(TextField), 'Hello');
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('send_button')));
        await tester.pumpAndSettle();

        expect(find.byKey(const Key('send_button')), findsNothing);
      });

      group('when sending fails', () {
        Future<void> attemptSend(WidgetTester tester) async {
          _api.sendError = Exception('Network error');
          await pumpChatScreen(tester);
          await tester.enterText(find.byType(TextField), 'Hello');
          await tester.pumpAndSettle();
          await tester.tap(find.byKey(const Key('send_button')));
          await tester.pumpAndSettle();
        }

        testWidgets('input is not cleared', (tester) async {
          await attemptSend(tester);

          final textField = tester.widget<TextField>(find.byType(TextField));
          expect(textField.controller!.text, 'Hello');
        });

        testWidgets('shows system notice', (tester) async {
          await attemptSend(tester);

          expect(find.byType(WnSystemNotice), findsOneWidget);
          expect(
            find.text('Failed to send message. Please try again.'),
            findsOneWidget,
          );
        });

        testWidgets('dismisses notice after auto-hide duration', (
          tester,
        ) async {
          await attemptSend(tester);
          expect(find.byType(WnSystemNotice), findsOneWidget);

          await tester.pump(const Duration(seconds: 3));
          await tester.pumpAndSettle();

          expect(find.byType(WnSystemNotice), findsNothing);
        });

        testWidgets('system notice is rendered inside WnSlate', (tester) async {
          await attemptSend(tester);

          final noticeFinder = find.byType(WnSystemNotice);
          expect(noticeFinder, findsOneWidget);
          expect(
            find.ancestor(of: noticeFinder, matching: find.byType(WnSlate)),
            findsOneWidget,
          );
        });
      });
    });

    group('message reception', () {
      testWidgets('message bubble appears when stream emits update', (
        tester,
      ) async {
        await pumpChatScreen(tester);
        _api.emitMessage(_message('new_msg', DateTime.now()));
        await tester.pumpAndSettle();

        expect(find.textContaining('Message new_msg'), findsOneWidget);
      });

      testWidgets('reloads messages from snapshot when app resumes', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];

        await pumpChatScreen(tester);

        expect(find.textContaining('Message m1'), findsOneWidget);
        expect(find.textContaining('Message m2'), findsNothing);

        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 2)),
        ];
        tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
        await tester.pumpAndSettle();
        tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
        await tester.pumpAndSettle();

        expect(find.textContaining('Message m2'), findsOneWidget);
      });

      testWidgets('does not show initial messages from blocked authors', (tester) async {
        _api.blockedPubkeys.add(testPubkeyB);
        _api.initialMessages = [
          _message('blocked', DateTime(2024)),
          _message('visible', DateTime(2024, 1, 2), pubkey: testPubkeyC),
        ];

        await pumpChatScreen(tester);

        expect(find.textContaining('Message blocked'), findsNothing);
        expect(find.textContaining('Message visible'), findsOneWidget);
      });

      testWidgets('does not show new messages from blocked authors', (
        tester,
      ) async {
        _api.blockedPubkeys.add(testPubkeyB);
        _api.initialMessages = [
          _message('visible', DateTime(2024), pubkey: testPubkeyC),
        ];

        await pumpChatScreen(tester);
        _api.emitMessage(_message('blocked', DateTime.now()));
        await tester.pumpAndSettle();

        expect(find.textContaining('Message blocked'), findsNothing);
        expect(find.textContaining('Message visible'), findsOneWidget);
      });

      testWidgets(
        'does not show new messages from users blocked before screen loaded',
        (tester) async {
          _api.blockedPubkeys.add(testPubkeyB);
          _api.initialMessages = [
            _message('visible', DateTime(2024), pubkey: testPubkeyC),
          ];

          await pumpChatScreen(tester);
          _api.emitMessage(_message('blocked', DateTime.now()));
          await tester.pumpAndSettle();

          expect(find.textContaining('Message blocked'), findsNothing);
          expect(find.textContaining('Message visible'), findsOneWidget);
        },
      );

      testWidgets('does not show messages while blocked users are loading', (
        tester,
      ) async {
        _api.blockedUsersCompleter = Completer<List<MuteListEntry>>();
        _api.initialMessages = [
          _message('blocked', DateTime(2024)),
          _message('visible', DateTime(2024, 1, 2), pubkey: testPubkeyC),
        ];

        await mountTestApp(
          tester,
          overrides: [
            authProvider.overrideWith(() => _MockAuthNotifier()),
            offlineProvider.overrideWith((ref) => Stream.value(false)),
          ],
        );
        await tester.pumpAndSettle();
        Routes.goToChat(tester.element(find.byType(Scaffold)), _testGroupId);
        await tester.pump();
        await tester.pump();
        await tester.pump();

        expect(find.byType(WnMessageBubble), findsNothing);

        _api.blockedUsersCompleter!.complete([
          MuteListEntry(
            accountPubkey: _testPubkey,
            mutedPubkey: testPubkeyB,
            isPrivate: true,
            createdAt: DateTime(2024),
          ),
        ]);
        await tester.pumpAndSettle();

        expect(find.textContaining('Message blocked'), findsNothing);
        expect(find.textContaining('Message visible'), findsOneWidget);
      });
    });

    group('retry failed message', () {
      setUp(() {
        _api.initialMessages = [
          _message(
            'failed1',
            DateTime(2024),
            pubkey: _testPubkey,
            deliveryStatus: const DeliveryStatus.failed(reason: 'timeout'),
          ),
        ];
      });

      testWidgets('calls retryMessagePublish on tap', (tester) async {
        await pumpChatScreen(tester);
        await tester.tap(find.byKey(const Key('status_tap_area')));
        await tester.pumpAndSettle();

        expect(_api.retryAttempts.length, 1);
        expect(_api.retryAttempts.first.eventId, 'failed1');
      });

      testWidgets('shows system notice when retry fails', (tester) async {
        _api.retryError = Exception('retry failed');
        await pumpChatScreen(tester);
        await tester.tap(find.byKey(const Key('status_tap_area')));
        await tester.pumpAndSettle();

        expect(find.byType(WnSystemNotice), findsOneWidget);
        expect(
          find.text('Failed to send message. Please try again.'),
          findsOneWidget,
        );
      });

      testWidgets('does not show retry for non-own messages', (tester) async {
        _api.initialMessages = [
          _message(
            'failed2',
            DateTime(2024),
            deliveryStatus: const DeliveryStatus.failed(reason: 'timeout'),
          ),
        ];
        await pumpChatScreen(tester);

        expect(find.byKey(const Key('status_tap_area')), findsNothing);
      });
    });

    group('focus management', () {
      testWidgets('tapping outside unfocuses input', (tester) async {
        await pumpChatScreen(tester);
        await tester.tap(find.byType(TextField));
        await tester.pumpAndSettle();

        await tester.tap(find.text('No messages yet'));
        await tester.pumpAndSettle();

        final textField = tester.widget<TextField>(find.byType(TextField));
        expect(textField.focusNode!.hasFocus, isFalse);
      });
    });

    group('auto-scroll', () {
      setUp(() {
        _api.initialMessages = List.generate(
          20,
          (i) => _message('m$i', DateTime(2024, 1, i + 1)),
        );
        _api.lastReadMessageId = 'm19';
      });

      ScrollPosition getScrollPosition(WidgetTester tester) {
        return Scrollable.of(
          tester.element(find.byType(WnMessageBubble).first),
        ).position;
      }

      testWidgets('scrolls to bottom on initial load', (tester) async {
        await pumpChatScreen(tester);
        await tester.pumpAndSettle();

        final position = getScrollPosition(tester);
        expect(position.pixels, 0);
      });

      testWidgets('scrolls to bottom when own message arrives', (tester) async {
        await pumpChatScreen(tester);
        _api.emitMessage(_message('own', DateTime.now(), pubkey: _testPubkey));
        await tester.pumpAndSettle();

        final position = getScrollPosition(tester);
        expect(position.pixels, 0);
      });

      testWidgets('scrolls when at bottom and other message arrives', (
        tester,
      ) async {
        await pumpChatScreen(tester);
        await tester.pumpAndSettle();

        final position = getScrollPosition(tester);
        expect(position.pixels, 0);

        _api.emitMessage(_message('other', DateTime.now()));
        await tester.pumpAndSettle();

        expect(position.pixels, 0);
      });

      testWidgets(
        'does not scroll when not at bottom and other message arrives',
        (tester) async {
          await pumpChatScreen(tester);
          await tester.pumpAndSettle();

          final position = getScrollPosition(tester);
          position.jumpTo(position.maxScrollExtent);
          await tester.pumpAndSettle();

          final positionBeforeMessage = position.pixels;

          _api.emitMessage(_message('other', DateTime.now()));
          await tester.pumpAndSettle();

          expect(position.pixels, positionBeforeMessage);
        },
      );

      testWidgets('scrolls to bottom when input is focused', (tester) async {
        await pumpChatScreen(tester);
        await tester.pumpAndSettle();

        final position = getScrollPosition(tester);
        position.jumpTo(position.maxScrollExtent);
        await tester.pumpAndSettle();

        await tester.tap(find.byType(TextField));
        final animationDelay = const Duration(milliseconds: 400);
        await tester.pump(animationDelay);
        await tester.pumpAndSettle();

        expect(position.pixels, 0);
      });
    });

    group('message actions', () {
      Future<void> longPressMessage(
        WidgetTester tester,
        String messageId,
      ) async {
        final messageFinder = find.textContaining('Message $messageId');
        await tester.longPress(messageFinder);
        await tester.pumpAndSettle();
      }

      testWidgets('opens on long press', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        await longPressMessage(tester, 'm1');

        expect(find.byType(MessageActionsScreen), findsOneWidget);
      });

      testWidgets('shows Delete button for own message', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024), pubkey: _testPubkey),
        ];
        await pumpChatScreen(tester);

        await longPressMessage(tester, 'm1');

        expect(find.text('Delete'), findsOneWidget);
      });

      testWidgets('hides Delete button for other user message', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024), pubkey: 'other_user'),
        ];
        await pumpChatScreen(tester);

        await longPressMessage(tester, 'm1');

        expect(find.text('Delete'), findsNothing);
      });

      testWidgets('closes when tapping outside', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        await longPressMessage(tester, 'm1');

        await tester.tapAt(const Offset(10, 10));
        await tester.pumpAndSettle();

        expect(find.byType(MessageActionsScreen), findsNothing);
      });

      testWidgets('unfocuses text field when opening', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        await tester.tap(find.byType(TextField));
        await tester.pumpAndSettle();
        final textField = tester.widget<TextField>(find.byType(TextField));
        expect(textField.focusNode!.hasFocus, isTrue);

        await longPressMessage(tester, 'm1');

        expect(textField.focusNode!.hasFocus, isFalse);
      });

      testWidgets('unfocuses text field when closing message actions', (
        tester,
      ) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        await tester.tap(find.byType(TextField));
        await tester.pumpAndSettle();
        final textField = tester.widget<TextField>(find.byType(TextField));
        expect(textField.focusNode!.hasFocus, isTrue);

        await longPressMessage(tester, 'm1');
        expect(textField.focusNode!.hasFocus, isFalse);

        await tester.tapAt(const Offset(10, 10));
        await tester.pumpAndSettle();

        expect(find.byType(MessageActionsScreen), findsNothing);
        expect(textField.focusNode!.hasFocus, isFalse);
      });

      testWidgets('unfocuses text field after selecting reaction', (
        tester,
      ) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        await tester.tap(find.byType(TextField));
        await tester.pumpAndSettle();
        final textField = tester.widget<TextField>(find.byType(TextField));
        expect(textField.focusNode!.hasFocus, isTrue);

        await longPressMessage(tester, 'm1');
        expect(textField.focusNode!.hasFocus, isFalse);

        await tester.tap(find.text('❤'));
        await tester.pumpAndSettle();

        expect(find.byType(MessageActionsScreen), findsNothing);
        expect(textField.focusNode!.hasFocus, isFalse);
      });

      group('message deletion', () {
        testWidgets('calls API when Delete is tapped', (tester) async {
          _api.initialMessages = [
            _message('m1', DateTime(2024), pubkey: _testPubkey),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('Delete'));
          await tester.pumpAndSettle();

          expect(_api.deletionCalls.length, 1);
        });

        testWidgets('close messages actions after deletion', (tester) async {
          _api.initialMessages = [
            _message('m1', DateTime(2024), pubkey: _testPubkey),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('Delete'));
          await tester.pumpAndSettle();

          expect(find.byType(MessageActionsScreen), findsNothing);
        });

        testWidgets('sends deletion to correct group', (tester) async {
          _api.initialMessages = [
            _message('m1', DateTime(2024), pubkey: _testPubkey),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('Delete'));
          await tester.pumpAndSettle();

          expect(_api.deletionCalls.first.groupId, _testGroupId);
        });

        testWidgets('deletes expected message id', (tester) async {
          _api.initialMessages = [
            _message('msg_to_delete', DateTime(2024), pubkey: _testPubkey),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'msg_to_delete');

          await tester.tap(find.text('Delete'));
          await tester.pumpAndSettle();

          final tags = _api.deletionCalls.first.tags!.cast<_MockTag>();
          expect(tags[0].vec, ['e', 'msg_to_delete']);
        });

        testWidgets('shows system notice when deletion fails', (tester) async {
          _api.deleteError = Exception('Network error');
          _api.initialMessages = [
            _message('m1', DateTime(2024), pubkey: _testPubkey),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('Delete'));
          await tester.pumpAndSettle();

          expect(_api.deletionCalls.length, 0);
          expect(find.byType(WnSystemNotice), findsOneWidget);
          expect(
            find.text('Failed to delete message. Please try again.'),
            findsOneWidget,
          );
          expect(find.byType(MessageActionsScreen), findsOneWidget);
        });
      });

      group('message reactions', () {
        testWidgets('calls API when reaction is tapped', (tester) async {
          _api.initialMessages = [_message('m1', DateTime(2024))];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('❤'));
          await tester.pumpAndSettle();

          expect(_api.reactionCalls.length, 1);
        });

        testWidgets('sends correct emoji as reaction', (tester) async {
          _api.initialMessages = [_message('m1', DateTime(2024))];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('🤣'));
          await tester.pumpAndSettle();

          expect(_api.reactionCalls.first.message, '🤣');
        });

        testWidgets('sends reaction to correct group', (tester) async {
          _api.initialMessages = [_message('m1', DateTime(2024))];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('❤'));
          await tester.pumpAndSettle();

          expect(_api.reactionCalls.first.groupId, _testGroupId);
        });

        testWidgets('includes message reference in reaction tags', (
          tester,
        ) async {
          _api.initialMessages = [_message('msg_to_react', DateTime(2024))];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'msg_to_react');

          await tester.tap(find.text('❤'));
          await tester.pumpAndSettle();

          final tags = _api.reactionCalls.first.tags!.cast<_MockTag>();
          expect(tags[0].vec, ['e', 'msg_to_react']);
        });

        testWidgets('closes message actions after sending reaction', (
          tester,
        ) async {
          _api.initialMessages = [_message('m1', DateTime(2024))];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('❤'));
          await tester.pumpAndSettle();

          expect(find.byType(MessageActionsScreen), findsNothing);
        });

        testWidgets('shows system notice when reaction fails', (tester) async {
          _api.reactionError = Exception('Network error');
          _api.initialMessages = [_message('m1', DateTime(2024))];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');

          await tester.tap(find.text('❤'));
          await tester.pumpAndSettle();

          expect(_api.reactionCalls.length, 0);
          expect(find.byType(WnSystemNotice), findsOneWidget);
          expect(
            find.text('Failed to send reaction. Please try again.'),
            findsOneWidget,
          );
          expect(find.byType(MessageActionsScreen), findsOneWidget);
        });
      });

      group('reaction deletion from message actions', () {
        ReactionSummary ownReaction(String emoji, String reactionId) => ReactionSummary(
          byEmoji: [
            EmojiReaction(
              emoji: emoji,
              count: BigInt.one,
              users: const [_testPubkey],
            ),
          ],
          userReactions: [
            UserReaction(
              reactionId: reactionId,
              emoji: emoji,
              user: _testPubkey,
              createdAt: DateTime(2024),
            ),
          ],
        );

        testWidgets('calls delete API when tapping selected emoji', (
          tester,
        ) async {
          _api.initialMessages = [
            _message(
              'm1',
              DateTime(2024),
              reactions: ownReaction('❤', 'reaction_1'),
            ),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');
          await tester.tap(find.byKey(const Key('reaction_❤')));
          await tester.pumpAndSettle();

          expect(_api.deletionCalls.length, 1);
        });

        testWidgets('sends correct reaction ID in deletion tags', (
          tester,
        ) async {
          _api.initialMessages = [
            _message(
              'm1',
              DateTime(2024),
              reactions: ownReaction('❤', 'reaction_to_remove'),
            ),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');
          await tester.tap(find.byKey(const Key('reaction_❤')));
          await tester.pumpAndSettle();

          final tags = _api.deletionCalls.first.tags!.cast<_MockTag>();
          expect(tags[0].vec, ['e', 'reaction_to_remove']);
        });

        testWidgets('closes message actions after removing reaction', (
          tester,
        ) async {
          _api.initialMessages = [
            _message(
              'm1',
              DateTime(2024),
              reactions: ownReaction('❤', 'reaction_1'),
            ),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');
          await tester.tap(find.byKey(const Key('reaction_❤')));
          await tester.pumpAndSettle();

          expect(find.byType(MessageActionsScreen), findsNothing);
        });

        testWidgets('shows system notice when reaction removal fails', (
          tester,
        ) async {
          _api.deleteError = Exception('Network error');
          _api.initialMessages = [
            _message(
              'm1',
              DateTime(2024),
              reactions: ownReaction('❤', 'reaction_1'),
            ),
          ];
          await pumpChatScreen(tester);

          await longPressMessage(tester, 'm1');
          await tester.tap(find.byKey(const Key('reaction_❤')));
          await tester.pumpAndSettle();

          expect(_api.deletionCalls.length, 0);
          expect(find.byType(WnSystemNotice), findsOneWidget);
          expect(
            find.text('Failed to remove reaction. Please try again.'),
            findsOneWidget,
          );
          expect(find.byType(MessageActionsScreen), findsOneWidget);
        });
      });

      testWidgets('shows reply preview for reply message', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 1, 2), isReply: true, replyToId: 'm1'),
        ];
        await pumpChatScreen(tester);
        await longPressMessage(tester, 'm2');

        expect(
          find.descendant(
            of: find.byType(MessageActionsScreen),
            matching: find.byType(ChatMessageQuote),
          ),
          findsOneWidget,
        );
      });

      testWidgets('shows sender picture in avatar', (tester) async {
        _api.metadataByPubkey = {
          testPubkeyB: const FlutterMetadata(
            name: 'Sender',
            displayName: 'Sender',
            picture: 'https://example.com/avatar.jpg',
            custom: {},
          ),
        };
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);
        await longPressMessage(tester, 'm1');

        final avatar = tester.widget<WnAvatar>(
          find.descendant(
            of: find.byType(MessageActionsScreen),
            matching: find.byType(WnAvatar),
          ),
        );
        expect(avatar.pictureUrl, 'https://example.com/avatar.jpg');
      });
    });

    group('replies', () {
      testWidgets('displays reply preview in bubble when message is a reply', (
        tester,
      ) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message(
            'm2',
            DateTime(2024, 1, 2),
            isReply: true,
            replyToId: 'm1',
            pubkey: _testPubkey,
          ),
        ];
        await pumpChatScreen(tester);

        expect(find.textContaining('Message m1'), findsWidgets);
        expect(find.textContaining('Message m2'), findsOneWidget);
        expect(find.byType(ChatMessageQuote), findsOneWidget);
      });

      testWidgets('swiping message bubble shows reply preview in input', (
        tester,
      ) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);
        expect(find.byType(ChatMessageQuote), findsNothing);

        await tester.fling(
          find.textContaining('Message m1'),
          const Offset(500, 0),
          1000,
        );
        await tester.pumpAndSettle();

        expect(find.byType(ChatMessageQuote), findsOneWidget);
        expect(find.textContaining('Message m1'), findsWidgets);
      });

      testWidgets(
        'tapping Reply in message actions shows reply preview in input',
        (tester) async {
          _api.initialMessages = [_message('m1', DateTime(2024))];
          await pumpChatScreen(tester);
          expect(find.byType(ChatMessageQuote), findsNothing);

          await tester.longPress(find.textContaining('Message m1'));
          await tester.pumpAndSettle();
          await tester.tap(find.byKey(const Key('reply_button')));
          await tester.pumpAndSettle();

          expect(find.byType(ChatMessageQuote), findsOneWidget);
          expect(find.textContaining('Message m1'), findsWidgets);
        },
      );

      testWidgets('sending while replying includes reply reference in send', (
        tester,
      ) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);
        await tester.longPress(find.textContaining('Message m1'));
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('reply_button')));
        await tester.pumpAndSettle();
        await tester.enterText(find.byType(TextField), 'My reply');
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('send_button')));
        await tester.pumpAndSettle();

        expect(_api.sentMessages.last, contains('My reply'));
        expect(_api.sentMessages.last, startsWith('nostr:nevent1'));
        expect(_api.sentTextMessageTagVecs, isNotEmpty);
        final tagVecs = _api.sentTextMessageTagVecs.last;
        expect(tagVecs.any((t) => t.isNotEmpty && t[0] == 'q'), isTrue);
      });

      testWidgets('cancel reply hides reply preview in input', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);
        await tester.longPress(find.textContaining('Message m1'));
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('reply_button')));
        await tester.pumpAndSettle();
        expect(find.byType(ChatMessageQuote), findsOneWidget);

        await tester.tap(find.byKey(const Key('cancel_quote_button')));
        await tester.pumpAndSettle();

        expect(find.byType(ChatMessageQuote), findsNothing);
      });

      testWidgets('hides reply preview when replied message is deleted', (
        tester,
      ) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        await tester.longPress(find.textContaining('Message m1'));
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('reply_button')));
        await tester.pumpAndSettle();

        expect(find.byType(ChatMessageQuote), findsOneWidget);

        _api.emitMessage(_message('m1', DateTime(2024), isDeleted: true));
        await tester.pumpAndSettle();

        expect(find.byType(ChatMessageQuote), findsNothing);
      });

      testWidgets('tapping reply preview scrolls to original message', (
        tester,
      ) async {
        _api.initialMessages = [
          ...List.generate(
            20,
            (i) => _message('m$i', DateTime(2024, 1, i + 1)),
          ),
          _message(
            'reply_msg',
            DateTime(2024, 1, 22),
            isReply: true,
            replyToId: 'm0',
            pubkey: _testPubkey,
          ),
        ];
        // Mark newest message as read so chat starts at bottom
        _api.lastReadMessageId = 'reply_msg';
        await pumpChatScreen(tester);
        await tester.pumpAndSettle();

        final position = Scrollable.of(
          tester.element(find.byType(WnMessageBubble).first),
        ).position;
        expect(position.pixels, 0);

        await tester.tap(find.byKey(const Key('message_quote_tap_area')));
        await tester.pumpAndSettle();

        expect(position.pixels, greaterThan(0));
      });
    });

    group('draft restoration', () {
      Future<void> pumpChatScreenWithDraftSettle(WidgetTester tester) async {
        await pumpChatScreen(tester);
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 100)),
        );
        await tester.pumpAndSettle();
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 100)),
        );
        await tester.pumpAndSettle();
      }

      group('with draft content', () {
        setUp(() {
          _api.loadDraftResult = Draft(
            accountPubkey: _testPubkey,
            mlsGroupId: _testGroupId,
            content: 'unsent message',
            mediaAttachments: const [],
            createdAt: DateTime(2024),
            updatedAt: DateTime(2024),
          );
        });

        testWidgets('restores content into input field', (tester) async {
          await pumpChatScreenWithDraftSettle(tester);

          final textField = tester.widget<TextField>(find.byType(TextField));
          expect(textField.controller!.text, 'unsent message');
        });

        testWidgets('shows send button after restoring content', (
          tester,
        ) async {
          await pumpChatScreenWithDraftSettle(tester);

          expect(find.byKey(const Key('send_button')), findsOneWidget);
        });
      });

      group('when draft reply message not in list', () {
        setUp(() {
          _api.initialMessages = [];
          _api.loadDraftResult = Draft(
            accountPubkey: _testPubkey,
            mlsGroupId: _testGroupId,
            content: 'draft',
            replyToId: 'missing-id',
            mediaAttachments: const [],
            createdAt: DateTime(2024),
            updatedAt: DateTime(2024),
          );
        });

        testWidgets('does not show reply preview', (tester) async {
          await pumpChatScreenWithDraftSettle(tester);

          expect(find.byType(ChatMessageQuote), findsNothing);
        });
      });
    });

    group('reaction pills', () {
      testWidgets('appears when reaction event arrives', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);
        expect(find.text('🎉'), findsNothing);

        final reactions = ReactionSummary(
          byEmoji: [
            EmojiReaction(
              emoji: '🎉',
              count: BigInt.one,
              users: const ['other'],
            ),
          ],
          userReactions: const [],
        );
        _api.emitMessage(_message('m1', DateTime(2024), reactions: reactions));
        await tester.pumpAndSettle();

        expect(find.text('🎉'), findsWidgets);
      });

      group('when user has no reaction to the message', () {
        ReactionSummary reactionFromOther(String emoji) => ReactionSummary(
          byEmoji: [
            EmojiReaction(
              emoji: emoji,
              count: BigInt.one,
              users: const ['other'],
            ),
          ],
          userReactions: const [],
        );

        testWidgets('calls API when reaction pill is tapped', (tester) async {
          _api.initialMessages = [
            _message('m1', DateTime(2024), reactions: reactionFromOther('👍')),
          ];
          await pumpChatScreen(tester);

          await tester.tap(find.text('👍'));
          await tester.pumpAndSettle();

          expect(_api.reactionCalls.length, 1);
        });

        testWidgets('sends correct emoji from tapped pill', (tester) async {
          _api.initialMessages = [
            _message('m1', DateTime(2024), reactions: reactionFromOther('🔥')),
          ];
          await pumpChatScreen(tester);

          await tester.tap(find.text('🔥'));
          await tester.pumpAndSettle();

          expect(_api.reactionCalls.first.message, '🔥');
        });

        testWidgets('includes message reference in reaction tags', (
          tester,
        ) async {
          _api.initialMessages = [
            _message(
              'msg_with_reaction',
              DateTime(2024),
              reactions: reactionFromOther('👍'),
            ),
          ];
          await pumpChatScreen(tester);

          await tester.tap(find.text('👍'));
          await tester.pumpAndSettle();

          final tags = _api.reactionCalls.first.tags!.cast<_MockTag>();
          expect(tags[0].vec, ['e', 'msg_with_reaction']);
        });
      });

      group('when user has a reaction to the message', () {
        testWidgets('calls delete API when tapping own reaction', (
          tester,
        ) async {
          final ownReaction = ReactionSummary(
            byEmoji: [
              EmojiReaction(
                emoji: '👍',
                count: BigInt.one,
                users: const [_testPubkey],
              ),
            ],
            userReactions: [
              UserReaction(
                reactionId: 'reaction_to_delete',
                emoji: '👍',
                user: _testPubkey,
                createdAt: DateTime(2024),
              ),
            ],
          );
          _api.initialMessages = [
            _message('m1', DateTime(2024), reactions: ownReaction),
          ];
          await pumpChatScreen(tester);

          await tester.tap(find.text('👍'));
          await tester.pumpAndSettle();

          expect(_api.deletionCalls.length, 1);
          final tags = _api.deletionCalls.first.tags!.cast<_MockTag>();
          expect(tags[0].vec, ['e', 'reaction_to_delete']);
        });
      });
    });

    group('unread indicator', () {
      setUp(() {
        _api.initialMessages = List.generate(
          20,
          (i) => _message('m$i', DateTime(2024, 1, i + 1)),
        );
        _api.lastReadMessageId = 'm19';
      });

      Future<ScrollPosition> scrollUpAndReceiveMessage(
        WidgetTester tester,
      ) async {
        await pumpChatScreen(tester);
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();

        final position = Scrollable.of(
          tester.element(find.byType(WnMessageBubble).first),
        ).position;
        position.jumpTo(position.maxScrollExtent);
        await tester.pumpAndSettle();

        _api.emitMessage(_message('m_new', DateTime(2024, 2)));
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();
        return position;
      }

      testWidgets('hidden when no messages', (tester) async {
        _api.initialMessages = [];
        _api.lastReadMessageId = null;
        await pumpChatScreen(tester);
        await tester.pumpAndSettle();

        expect(find.byKey(const Key('scroll_down_button')), findsNothing);
      });

      testWidgets('shows when scrolled up with unread messages', (
        tester,
      ) async {
        await scrollUpAndReceiveMessage(tester);

        expect(find.byKey(const Key('scroll_down_button')), findsOneWidget);
      });

      testWidgets('hidden when scrolled up with all messages already read', (
        tester,
      ) async {
        await pumpChatScreen(tester);
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();

        final position = Scrollable.of(
          tester.element(find.byType(WnMessageBubble).first),
        ).position;
        position.jumpTo(position.maxScrollExtent);
        await tester.pumpAndSettle();

        expect(find.byKey(const Key('scroll_down_button')), findsNothing);
      });

      testWidgets('tapping scrolls to bottom', (tester) async {
        final position = await scrollUpAndReceiveMessage(tester);
        expect(position.pixels, greaterThan(0));

        await tester.tap(find.byKey(const Key('scroll_down_button')));
        await tester.pumpAndSettle();

        expect(position.pixels, 0);
      });
    });

    group('mark as read', () {
      setUp(() {
        _api.initialMessages = List.generate(
          20,
          (i) => _message('m$i', DateTime(2024, 1, i + 1)),
        );
        _api.lastReadMessageId = 'm19';
      });

      testWidgets('marks incoming message as read when at bottom', (
        tester,
      ) async {
        await pumpChatScreen(tester);
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();

        _api.emitMessage(_message('m_new', DateTime(2024, 2)));
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();

        expect(_api.markedAsReadMessages, contains('m_new'));
      });

      testWidgets('does not mark message as read when scrolled up', (
        tester,
      ) async {
        await pumpChatScreen(tester);
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();

        final position = Scrollable.of(
          tester.element(find.byType(WnMessageBubble).first),
        ).position;
        position.jumpTo(position.maxScrollExtent);
        await tester.pumpAndSettle();

        _api.emitMessage(_message('m_new', DateTime(2024, 2)));
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();

        expect(_api.markedAsReadMessages, isEmpty);
      });

      testWidgets('marks own message as read', (tester) async {
        await pumpChatScreen(tester);
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();

        final position = Scrollable.of(
          tester.element(find.byType(WnMessageBubble).first),
        ).position;
        position.jumpTo(position.maxScrollExtent);
        await tester.pumpAndSettle();

        _api.emitMessage(
          _message('m_own', DateTime(2024, 2), pubkey: _testPubkey),
        );
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 50)),
        );
        await tester.pumpAndSettle();

        expect(_api.markedAsReadMessages, contains('m_own'));
      });
    });
    group('media attachment', () {
      testWidgets('displays attach button always when no media attached', (
        tester,
      ) async {
        await pumpChatScreen(tester);

        expect(find.byKey(const Key('add_button')), findsOneWidget);
      });

      testWidgets('unfocuses input when attach button is tapped', (
        tester,
      ) async {
        await pumpChatScreen(tester);
        await tester.tap(find.byType(TextField));
        await tester.pumpAndSettle();

        final textField = tester.widget<TextField>(find.byType(TextField));
        expect(textField.focusNode!.hasFocus, isTrue);

        await tester.tap(find.byKey(const Key('add_button')));
        await tester.pumpAndSettle();

        expect(textField.focusNode!.hasFocus, isFalse);
      });
    });

    group('message search', () {
      Future<void> openSearch(WidgetTester tester) async {
        _api.isDm = true;
        _api.groupMembers = [_testPubkey, testPubkeyC];
        await pumpChatScreen(tester);
        await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('search_button')));
        await tester.pumpAndSettle();
      }

      Future<void> openGroupSearch(WidgetTester tester) async {
        await pumpChatScreen(tester);
        await tester.tap(find.byKey(const Key('header_avatar_tap_area')));
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('group_search_button')));
        await tester.pumpAndSettle();
      }

      group('opened from group info', () {
        testWidgets('search bar appears after tapping search in group info', (
          tester,
        ) async {
          await openGroupSearch(tester);
          expect(find.byKey(const Key('chat_search_bar')), findsOneWidget);
          expect(find.byKey(const Key('chat_search_field')), findsOneWidget);
        });

        testWidgets('search bar is not shown before tapping search', (
          tester,
        ) async {
          await pumpChatScreen(tester);
          expect(find.byKey(const Key('chat_search_bar')), findsNothing);
        });

        testWidgets('navigation bar appears when query is entered', (
          tester,
        ) async {
          _api.initialMessages = [
            _message('m1', DateTime(2024)),
            _message('m2', DateTime(2024, 2)),
          ];
          await openGroupSearch(tester);
          await tester.enterText(
            find.byKey(const Key('chat_search_field')),
            'Message',
          );
          await tester.pumpAndSettle();
          expect(
            find.byKey(const Key('chat_search_navigation')),
            findsOneWidget,
          );
        });

        testWidgets('closing search hides search bar', (tester) async {
          await openGroupSearch(tester);
          expect(find.byKey(const Key('chat_search_bar')), findsOneWidget);

          await tester.tap(find.byKey(const Key('back_button')));
          await tester.pumpAndSettle();
          expect(find.byKey(const Key('chat_search_bar')), findsNothing);
        });
      });

      testWidgets('search bar is hidden by default', (tester) async {
        await pumpChatScreen(tester);
        expect(find.byKey(const Key('chat_search_bar')), findsNothing);
        expect(find.byKey(const Key('chat_search_field')), findsNothing);
      });

      testWidgets('search bar appears after tapping search in chat info', (
        tester,
      ) async {
        await openSearch(tester);
        expect(find.byKey(const Key('chat_search_bar')), findsOneWidget);
        expect(find.byKey(const Key('chat_search_field')), findsOneWidget);
      });

      testWidgets('navigation bar is hidden when search query is empty', (
        tester,
      ) async {
        await openSearch(tester);
        expect(find.byKey(const Key('chat_search_navigation')), findsNothing);
      });

      testWidgets('navigation bar appears when search query is not empty', (
        tester,
      ) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 2)),
        ];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message',
        );
        await tester.pumpAndSettle();
        expect(find.byKey(const Key('chat_search_navigation')), findsOneWidget);
      });

      testWidgets('shows no results when query has no matches', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'zzznomatch',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        expect(
          find.byKey(const Key('chat_search_match_count')),
          findsOneWidget,
        );
        expect(find.text('No results'), findsOneWidget);
      });

      testWidgets('shows singular match count for one result', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message m1',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        expect(
          find.byKey(const Key('chat_search_match_count')),
          findsOneWidget,
        );
        expect(find.text('1 of 1 match'), findsOneWidget);
      });

      testWidgets('shows plural match count for multiple results', (
        tester,
      ) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 2)),
        ];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        expect(
          find.byKey(const Key('chat_search_match_count')),
          findsOneWidget,
        );
        expect(find.text('1 of 2 matches'), findsOneWidget);
      });

      testWidgets('shows prev and next navigation buttons', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 2)),
        ];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        expect(
          find.byKey(const Key('chat_search_prev_button')),
          findsOneWidget,
        );
        expect(
          find.byKey(const Key('chat_search_next_button')),
          findsOneWidget,
        );
      });

      testWidgets('tapping next button advances match index', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 2)),
        ];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        expect(find.text('1 of 2 matches'), findsOneWidget);

        await tester.tap(find.byKey(const Key('chat_search_next_button')));
        await tester.pumpAndSettle();
        expect(find.text('2 of 2 matches'), findsOneWidget);
      });

      testWidgets('tapping prev button goes to previous match', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 2)),
        ];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        await tester.tap(find.byKey(const Key('chat_search_next_button')));
        await tester.pumpAndSettle();
        expect(find.text('2 of 2 matches'), findsOneWidget);

        await tester.tap(find.byKey(const Key('chat_search_prev_button')));
        await tester.pumpAndSettle();
        expect(find.text('1 of 2 matches'), findsOneWidget);
      });

      testWidgets(
        'does not show no messages text when search is active with no results',
        (tester) async {
          await openSearch(tester);
          await tester.enterText(
            find.byKey(const Key('chat_search_field')),
            'zzznomatch',
          );
          await tester.pump();
          await tester.pump(const Duration(milliseconds: 300));
          await tester.pump();
          await tester.pump();
          expect(find.text('No messages yet'), findsNothing);
        },
      );

      testWidgets('closing search hides search bar', (tester) async {
        await openSearch(tester);
        expect(find.byKey(const Key('chat_search_bar')), findsOneWidget);

        await tester.tap(find.byKey(const Key('back_button')));
        await tester.pumpAndSettle();
        expect(find.byKey(const Key('chat_search_bar')), findsNothing);
      });

      testWidgets('match index resets when query changes', (tester) async {
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 2)),
        ];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        await tester.tap(find.byKey(const Key('chat_search_next_button')));
        await tester.pumpAndSettle();
        expect(find.text('2 of 2 matches'), findsOneWidget);

        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message m1',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        expect(find.text('1 of 1 match'), findsOneWidget);
      });

      testWidgets('hides message input when search is active', (tester) async {
        await openSearch(tester);

        expect(find.byType(WnChatMessageInput), findsNothing);
      });

      testWidgets('shows message input after closing search', (tester) async {
        await openSearch(tester);
        await tester.tap(find.byKey(const Key('back_button')));
        await tester.pumpAndSettle();

        expect(find.byType(WnChatMessageInput), findsOneWidget);
      });

      testWidgets('renders separator between distant match groups', (
        tester,
      ) async {
        // m1 matches "alpha", gap of 5 non-matching messages, m7 matches "alpha".
        // Context windows: [m1] and [m5, m6, m7] — no overlap, so a separator is expected.
        _api.initialMessages = [
          _message('m1', DateTime(2024)),
          _message('m2', DateTime(2024, 2)),
          _message('m3', DateTime(2024, 3)),
          _message('m4', DateTime(2024, 4)),
          _message('m5', DateTime(2024, 5)),
          _message('m6', DateTime(2024, 6)),
          _message('m7', DateTime(2024, 7)),
        ];
        // Override search to return only m1 and m7
        _api.searchOverride = (query) => [
          SearchResult(
            message: _api.initialMessages[0],
            mlsGroupId: _testGroupId,
            highlightSpans: [const HighlightSpan(start: 0, end: 5)],
            position: BigInt.zero,
          ),
          SearchResult(
            message: _api.initialMessages[6],
            mlsGroupId: _testGroupId,
            highlightSpans: [const HighlightSpan(start: 0, end: 5)],
            position: BigInt.from(6),
          ),
        ];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'alpha',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        await tester.pump();

        expect(find.textContaining('Message m1'), findsOneWidget);
        expect(find.textContaining('Message m7'), findsOneWidget);
        expect(
          find.byKey(const Key('search_separator_0')).evaluate().isNotEmpty ||
              find.byKey(const Key('search_separator_1')).evaluate().isNotEmpty ||
              find.byKey(const Key('search_separator_2')).evaluate().isNotEmpty ||
              find.byKey(const Key('search_separator_3')).evaluate().isNotEmpty,
          isTrue,
        );
      });

      testWidgets('tapping search result exits search mode', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await openSearch(tester);
        await tester.enterText(
          find.byKey(const Key('chat_search_field')),
          'Message m1',
        );
        await tester.pump();
        await tester.pump(const Duration(milliseconds: 300));
        await tester.pump();
        await tester.pump();
        await tester.pump();

        await tester.tap(find.byKey(const Key('search_match_0')));
        await tester.pumpAndSettle();

        expect(find.byKey(const Key('chat_search_bar')), findsNothing);
        expect(find.byType(WnChatMessageInput), findsOneWidget);
      });
    });

    group('attachment area with reply and media', () {
      late _MockImagePickerPlatform mockImagePicker;

      setUp(() {
        mockImagePicker = _MockImagePickerPlatform();
        ImagePickerPlatform.instance = mockImagePicker;
      });

      testWidgets('shows media upload preview when media is picked', (
        tester,
      ) async {
        mockImagePicker.filesToReturn = [XFile('/tmp/test_image.jpg')];
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        await tester.tap(find.byKey(const Key('add_button')));
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 100)),
        );
        await tester.pumpAndSettle();

        expect(find.byType(ChatMediaUploadPreview), findsOneWidget);
      });

      testWidgets('shows both reply preview and media preview with spacer', (
        tester,
      ) async {
        mockImagePicker.filesToReturn = [XFile('/tmp/test_image.jpg')];
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        await tester.longPress(find.textContaining('Message m1'));
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('reply_button')));
        await tester.pumpAndSettle();

        expect(find.byType(ChatMessageQuote), findsOneWidget);

        await tester.tap(find.byKey(const Key('add_button')));
        await tester.runAsync(
          () => Future.delayed(const Duration(milliseconds: 100)),
        );
        await tester.pumpAndSettle();

        expect(find.byType(ChatMessageQuote), findsOneWidget);
        expect(find.byType(ChatMediaUploadPreview), findsOneWidget);
      });
    });

    group('debug view', () {
      Future<void> pumpChatScreenWithDebug(WidgetTester tester) async {
        await mountTestApp(
          tester,
          overrides: [
            authProvider.overrideWith(() => _MockAuthNotifier()),
            debugViewProvider.overrideWith(() => _MockDebugViewNotifier()),
          ],
        );
        await tester.pumpAndSettle();
        Routes.goToChat(tester.element(find.byType(Scaffold)), _testGroupId);
        await tester.pumpAndSettle();
      }

      testWidgets('shows debug button when debug view is enabled', (
        tester,
      ) async {
        await pumpChatScreenWithDebug(tester);
        expect(find.byKey(const Key('chat_raw_debug_button')), findsOneWidget);
      });

      testWidgets('debug button navigates to raw debug screen', (tester) async {
        await pumpChatScreenWithDebug(tester);
        await tester.tap(find.byKey(const Key('chat_raw_debug_button')));
        await tester.pumpAndSettle();
        expect(find.byType(ChatRawDebugScreen), findsOneWidget);
      });

      testWidgets('back button in raw debug screen returns to chat', (
        tester,
      ) async {
        await pumpChatScreenWithDebug(tester);
        await tester.tap(find.byKey(const Key('chat_raw_debug_button')));
        await tester.pumpAndSettle();

        await tester.tap(find.byKey(const Key('slate_back_button')));
        await tester.pumpAndSettle();

        expect(find.byType(ChatRawDebugScreen), findsNothing);
      });
    });

    group('removed from group', () {
      setUp(() => _api.removedAt = DateTime(2024, 6));

      testWidgets('shows WnSystemNotice with removed title', (tester) async {
        await pumpChatScreen(tester);

        expect(
          find.byKey(const Key('removed_from_group_notice')),
          findsOneWidget,
        );
        expect(find.text('You were removed from this group'), findsOneWidget);
      });

      testWidgets('shows description text', (tester) async {
        await pumpChatScreen(tester);

        expect(
          find.textContaining('You can still view saved messages'),
          findsOneWidget,
        );
      });

      testWidgets('notice is expanded by default', (tester) async {
        await pumpChatScreen(tester);

        final notice = tester.widget<WnSystemNotice>(
          find.byKey(const Key('removed_from_group_notice')),
        );
        expect(notice.variant, WnSystemNoticeVariant.expanded);
      });

      testWidgets('notice collapses when chevron is tapped', (tester) async {
        await pumpChatScreen(tester);

        await tester.tap(find.byKey(const Key('systemNotice_actionIcon')));
        await tester.pumpAndSettle();

        final notice = tester.widget<WnSystemNotice>(
          find.byKey(const Key('removed_from_group_notice')),
        );
        expect(notice.variant, WnSystemNoticeVariant.collapsed);
      });

      testWidgets('notice expands again after second chevron tap', (
        tester,
      ) async {
        await pumpChatScreen(tester);

        await tester.tap(find.byKey(const Key('systemNotice_actionIcon')));
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('systemNotice_actionIcon')));
        await tester.pumpAndSettle();

        final notice = tester.widget<WnSystemNotice>(
          find.byKey(const Key('removed_from_group_notice')),
        );
        expect(notice.variant, WnSystemNoticeVariant.expanded);
      });

      testWidgets('hides the message input', (tester) async {
        await pumpChatScreen(tester);

        expect(find.byType(TextField), findsNothing);
      });

      testWidgets('still shows messages', (tester) async {
        _api.initialMessages = [_message('m1', DateTime(2024))];
        await pumpChatScreen(tester);

        expect(find.byType(WnMessageBubble), findsOneWidget);
      });

      testWidgets(
        'archive button shows Archive when not archived in removed notice',
        (tester) async {
          await pumpChatScreen(tester);

          final button = tester.widget<WnButton>(
            find.byKey(const Key('removed_notice_archive_button')),
          );
          expect(button.text, 'Archive');
        },
      );

      testWidgets(
        'archive button updates to Unarchive after archiving in removed notice',
        (tester) async {
          await pumpChatScreen(tester);

          await tester.tap(
            find.byKey(const Key('removed_notice_archive_button')),
          );
          await tester.pumpAndSettle();

          final button = tester.widget<WnButton>(
            find.byKey(const Key('removed_notice_archive_button')),
          );
          expect(button.text, 'Unarchive');
        },
      );

      testWidgets(
        'shows error notice when archive action fails from removed notice',
        (tester) async {
          _api.shouldFailArchiveChat = true;
          await pumpChatScreen(tester);

          await tester.tap(
            find.byKey(const Key('removed_notice_archive_button')),
          );
          await tester.pumpAndSettle();

          expect(
            find.text('Failed to archive chat. Please try again.'),
            findsOneWidget,
          );
        },
      );

      testWidgets(
        'archive button is disabledwhile archive state is initializing',
        (tester) async {
          _api.getAccountGroupCompleter = Completer<void>();
          await pumpChatScreen(tester, settle: false);
          await tester.pump(const Duration(milliseconds: 500));
          await tester.pump();

          expect(
            find.byKey(const Key('removed_notice_archive_button')),
            findsOneWidget,
          );
          final button = tester.widget<WnButton>(
            find.byKey(const Key('removed_notice_archive_button')),
          );
          expect(button.disabled, isTrue);
          expect(button.loading, isFalse);

          _api.getAccountGroupCompleter!.complete();
          await tester.pumpAndSettle();
        },
      );
    });

    group('left group voluntarily', () {
      setUp(() {
        _api.removedAt = DateTime(2024, 6);
        _api.selfRemoved = true;
      });

      testWidgets('shows WnSystemNotice with left title', (tester) async {
        await pumpChatScreen(tester);

        expect(
          find.byKey(const Key('removed_from_group_notice')),
          findsOneWidget,
        );
        expect(find.text('You left this group'), findsOneWidget);
      });

      testWidgets('does not show removed title', (tester) async {
        await pumpChatScreen(tester);

        expect(find.text('You were removed from this group'), findsNothing);
      });

      testWidgets('hides the message input', (tester) async {
        await pumpChatScreen(tester);

        expect(find.byType(TextField), findsNothing);
      });
    });

    group('not removed from group', () {
      testWidgets('does not show removed notice', (tester) async {
        await pumpChatScreen(tester);

        expect(
          find.byKey(const Key('removed_from_group_notice')),
          findsNothing,
        );
      });

      testWidgets('shows the message input', (tester) async {
        await pumpChatScreen(tester);

        expect(find.byType(TextField), findsOneWidget);
      });

      testWidgets('archive button not shown when not removed from group', (
        tester,
      ) async {
        await pumpChatScreen(tester);

        expect(
          find.byKey(const Key('removed_notice_archive_button')),
          findsNothing,
        );
      });
    });

    group('blocked user', () {
      setUp(() {
        _api.isDm = true;
        _api.groupMembers = [_testPubkey, testPubkeyC];
        _api.blockedPubkeys.add(testPubkeyC);
      });

      testWidgets('shows blocked notice when peer is blocked', (tester) async {
        await pumpChatScreen(tester);

        expect(find.byKey(const Key('user_blocked_notice')), findsOneWidget);
        expect(find.text('You blocked this user'), findsOneWidget);
      });

      testWidgets('blocked notice is expanded by default', (tester) async {
        await pumpChatScreen(tester);

        final notice = tester.widget<WnSystemNotice>(
          find.byKey(const Key('user_blocked_notice')),
        );
        expect(notice.variant, WnSystemNoticeVariant.expanded);
      });

      testWidgets('blocked notice uses neutral type', (tester) async {
        await pumpChatScreen(tester);

        final notice = tester.widget<WnSystemNotice>(
          find.byKey(const Key('user_blocked_notice')),
        );
        expect(notice.type, WnSystemNoticeType.neutral);
      });

      testWidgets('shows blocked notice description', (tester) async {
        await pumpChatScreen(tester);

        expect(
          find.textContaining('You won\'t receive new messages'),
          findsOneWidget,
        );
      });

      testWidgets('blocked notice collapses when chevron is tapped', (
        tester,
      ) async {
        await pumpChatScreen(tester);

        await tester.tap(find.byKey(const Key('systemNotice_actionIcon')));
        await tester.pumpAndSettle();

        final notice = tester.widget<WnSystemNotice>(
          find.byKey(const Key('user_blocked_notice')),
        );
        expect(notice.variant, WnSystemNoticeVariant.collapsed);
      });

      testWidgets('shows error notice when unblock action fails', (
        tester,
      ) async {
        _api.shouldFailUnblockUser = true;
        await pumpChatScreen(tester);

        await tester.tap(
          find.byKey(const Key('blocked_notice_unblock_button')),
        );
        await tester.pumpAndSettle();

        expect(
          find.text('Failed to unblock user. Please try again.'),
          findsOneWidget,
        );
      });

      testWidgets(
        'does not update state after unblock completes on disposed screen',
        (tester) async {
          _api.unblockUserCompleter = Completer<void>();
          await pumpChatScreen(tester);

          await tester.tap(
            find.byKey(const Key('blocked_notice_unblock_button')),
          );
          await tester.pump();
          await tester.pumpWidget(const SizedBox());

          _api.unblockUserCompleter!.complete();
          await tester.pump();

          expect(tester.takeException(), isNull);
        },
      );

      testWidgets('archive button shows Archive when not archived', (
        tester,
      ) async {
        await pumpChatScreen(tester);

        final button = tester.widget<WnButton>(
          find.byKey(const Key('blocked_notice_archive_button')),
        );
        expect(button.text, 'Archive');
      });

      testWidgets('archive button updates to Unarchive after archiving', (
        tester,
      ) async {
        await pumpChatScreen(tester);

        await tester.tap(
          find.byKey(const Key('blocked_notice_archive_button')),
        );
        await tester.pumpAndSettle();

        final button = tester.widget<WnButton>(
          find.byKey(const Key('blocked_notice_archive_button')),
        );
        expect(button.text, 'Unarchive');
      });

      testWidgets(
        'shows error notice when archive action fails from blocked notice',
        (tester) async {
          _api.shouldFailArchiveChat = true;
          await pumpChatScreen(tester);

          await tester.tap(
            find.byKey(const Key('blocked_notice_archive_button')),
          );
          await tester.pumpAndSettle();

          expect(
            find.text('Failed to archive chat. Please try again.'),
            findsOneWidget,
          );
        },
      );

      testWidgets('unblocking reloads blocked pubkeys', (tester) async {
        await pumpChatScreen(tester);

        final callCountBefore = _api.getBlockedUsersCallCount;
        await tester.tap(
          find.byKey(const Key('blocked_notice_unblock_button')),
        );
        await tester.pumpAndSettle();

        expect(
          _api.getBlockedUsersCallCount,
          greaterThanOrEqualTo(callCountBefore + 1),
        );
      });

      testWidgets('messages from unblocked peer become visible after unblock', (
        tester,
      ) async {
        final firstCompleter = Completer<List<MuteListEntry>>();
        final secondCompleter = Completer<List<MuteListEntry>>();
        _api.blockedPubkeys.add(testPubkeyC);
        _api.blockedUsersCompleters = [firstCompleter, secondCompleter];
        _api.initialMessages = [
          _message('peer_msg', DateTime(2024), pubkey: testPubkeyC),
        ];

        await mountTestApp(
          tester,
          overrides: [
            authProvider.overrideWith(() => _MockAuthNotifier()),
            offlineProvider.overrideWith((ref) => Stream.value(false)),
          ],
        );
        await tester.pumpAndSettle();
        Routes.goToChat(tester.element(find.byType(Scaffold)), _testGroupId);
        await tester.pump();
        await tester.pump();

        firstCompleter.complete([
          MuteListEntry(
            accountPubkey: _testPubkey,
            mutedPubkey: testPubkeyC,
            isPrivate: true,
            createdAt: DateTime(2024),
          ),
        ]);
        await tester.pumpAndSettle();

        expect(find.textContaining('Message peer_msg'), findsNothing);

        await tester.tap(
          find.byKey(const Key('blocked_notice_unblock_button')),
        );
        await tester.pump();

        secondCompleter.complete([]);
        await tester.pumpAndSettle();

        expect(find.textContaining('Message peer_msg'), findsOneWidget);
      });
    });

    group('not blocked user', () {
      testWidgets('does not show blocked notice', (tester) async {
        await pumpChatScreen(tester);

        expect(find.byKey(const Key('user_blocked_notice')), findsNothing);
      });
    });

    group('removed from group takes priority over blocked', () {
      setUp(() {
        _api.removedAt = DateTime(2024, 6);
        _api.isDm = true;
        _api.groupMembers = [_testPubkey, testPubkeyC];
        _api.blockedPubkeys.add(testPubkeyC);
      });

      testWidgets('shows removed notice and not blocked notice', (
        tester,
      ) async {
        await pumpChatScreen(tester);

        expect(
          find.byKey(const Key('removed_from_group_notice')),
          findsOneWidget,
        );
        expect(find.byKey(const Key('user_blocked_notice')), findsNothing);
      });
    });

    group('offline state', () {
      testWidgets('shows offline_notice when offline', (tester) async {
        await pumpChatScreen(tester, isOffline: true);

        expect(find.byKey(const Key('offline_notice')), findsOneWidget);
      });

      testWidgets('text field remains enabled when offline', (tester) async {
        await pumpChatScreen(tester, isOffline: true);

        final textField = tester.widget<TextField>(find.byType(TextField));
        expect(textField.enabled, isNot(false));
      });

      testWidgets('send button is not present when offline', (tester) async {
        await pumpChatScreen(tester, isOffline: true);

        await tester.enterText(find.byType(TextField), 'Hello');
        await tester.pumpAndSettle();

        expect(find.byKey(const Key('send_button')), findsNothing);
      });

      testWidgets(
        'Online does not show offline_notice and enables send button',
        (tester) async {
          await pumpChatScreen(tester);

          expect(find.byKey(const Key('offline_notice')), findsNothing);

          await tester.enterText(find.byType(TextField), 'Hello');
          await tester.pumpAndSettle();

          final sendButton = find.byKey(const Key('send_button'));
          expect(sendButton, findsOneWidget);
          await tester.tap(sendButton);
          await tester.pumpAndSettle();
          expect(_api.sentMessages, isNotEmpty);
        },
      );

      group('message actions when offline', () {
        setUp(() {
          _api.initialMessages = [
            _message('m1', DateTime(2024), pubkey: _testPubkey),
          ];
        });

        Future<void> openMessageMenuOffline(WidgetTester tester) async {
          await pumpChatScreen(tester, isOffline: true);
          await tester.longPress(find.textContaining('Message m1'));
          await tester.pumpAndSettle();
        }

        testWidgets('hides delete button for own message', (tester) async {
          await openMessageMenuOffline(tester);

          expect(find.byType(MessageActionsScreen), findsOneWidget);
          expect(find.byKey(const Key('delete_button')), findsNothing);
        });

        testWidgets('hides reactions row', (tester) async {
          await openMessageMenuOffline(tester);

          expect(find.byKey(const Key('reaction_❤')), findsNothing);
          expect(find.byKey(const Key('emoji_picker_button')), findsNothing);
        });
      });
    });

    group('mention picker', () {
      setUp(() {
        _api.groupMembers = [_testPubkey, testPubkeyC];
        _api.metadataByPubkey = {
          testPubkeyC: const FlutterMetadata(displayName: 'Trent', custom: {}),
        };
      });

      testWidgets('shows picker when @ is typed and inserts raw npub on tap', (tester) async {
        await pumpChatScreen(tester);

        expect(find.byKey(const Key('mention_suggestions_menu')), findsNothing);

        await tester.enterText(find.byType(TextField), '@');
        await tester.pumpAndSettle();

        expect(find.byKey(const Key('mention_suggestions_menu')), findsOneWidget);
        const suggestionKey = Key('mention_suggestion_$testPubkeyC');
        expect(find.byKey(suggestionKey), findsOneWidget);

        await tester.tap(find.byKey(suggestionKey));
        await tester.pumpAndSettle();

        final textField = tester.widget<TextField>(find.byType(TextField));
        final controller = textField.controller! as MentionTextEditingController;
        expect(controller.text, '@Trent ');
        expect(controller.messageText, '@$testNpubC ');
        expect(find.byKey(const Key('mention_suggestions_menu')), findsNothing);
      });

      testWidgets('filters picker by typed query', (tester) async {
        _api.metadataByPubkey = {
          testPubkeyC: const FlutterMetadata(displayName: 'Trent', custom: {}),
        };
        await pumpChatScreen(tester);
        await tester.enterText(find.byType(TextField), '@zz');
        await tester.pumpAndSettle();
        expect(find.byKey(const Key('mention_suggestions_menu')), findsNothing);
      });

      testWidgets('does not show picker in a DM', (tester) async {
        _api.isDm = true;
        await pumpChatScreen(tester);
        await tester.enterText(find.byType(TextField), '@');
        await tester.pumpAndSettle();
        expect(find.byKey(const Key('mention_suggestions_menu')), findsNothing);
      });

      testWidgets('excludes self from the picker', (tester) async {
        _api.groupMembers = [_testPubkey];
        await pumpChatScreen(tester);
        await tester.enterText(find.byType(TextField), '@');
        await tester.pumpAndSettle();
        expect(find.byKey(const Key('mention_suggestions_menu')), findsNothing);
      });

      testWidgets('does not re-open the picker when the cursor sits inside an existing mention', (
        tester,
      ) async {
        await pumpChatScreen(tester);

        // Insert a mention via the picker.
        await tester.enterText(find.byType(TextField), '@');
        await tester.pumpAndSettle();
        await tester.tap(find.byKey(const Key('mention_suggestion_$testPubkeyC')));
        await tester.pumpAndSettle();

        final textField = tester.widget<TextField>(find.byType(TextField));
        final controller = textField.controller! as MentionTextEditingController;
        expect(controller.text, '@Trent ');

        // Move cursor into the middle of '@Trent'.
        controller.selection = const TextSelection.collapsed(offset: 3);
        await tester.pumpAndSettle();

        // The picker must not pop back open.
        expect(find.byKey(const Key('mention_suggestions_menu')), findsNothing);
      });

      testWidgets('keeps the input element stable when the picker appears', (tester) async {
        await pumpChatScreen(tester);

        final inputFinder = find.byKey(const ValueKey('chat_message_input'));
        final beforeElement = tester.elementList(inputFinder).last;

        await tester.enterText(find.byType(TextField), 'hello @');
        await tester.pumpAndSettle();

        expect(find.byKey(const Key('mention_suggestions_menu')), findsOneWidget);
        final afterElement = tester.elementList(inputFinder).last;
        expect(identical(beforeElement, afterElement), isTrue);

        final textField = tester.widget<TextField>(find.byType(TextField));
        expect(textField.controller!.selection.baseOffset, 7);
      });

      testWidgets(
        'renders mention picker BELOW images when media is attached',
        (tester) async {
          final mockImagePicker = _MockImagePickerPlatform()
            ..filesToReturn = [XFile('/tmp/test_image.jpg')];
          ImagePickerPlatform.instance = mockImagePicker;

          await pumpChatScreen(tester);

          await tester.tap(find.byKey(const Key('add_button')));
          await tester.runAsync(
            () => Future.delayed(const Duration(milliseconds: 100)),
          );
          await tester.pumpAndSettle();

          await tester.enterText(find.byType(TextField), '@');
          await tester.pumpAndSettle();

          expect(find.byType(ChatMediaUploadPreview), findsOneWidget);
          expect(find.byKey(const Key('mention_suggestions_menu')), findsOneWidget);

          // Picker should live inside the attachment area, not above the
          // input. That guarantees the visual order is image -> picker.
          expect(
            find.descendant(
              of: find.byKey(const Key('attachment_area')),
              matching: find.byKey(const Key('mention_suggestions_menu')),
            ),
            findsOneWidget,
          );

          final mediaBottom = tester.getBottomLeft(find.byType(ChatMediaUploadPreview)).dy;
          final menuTop = tester.getTopLeft(find.byKey(const Key('mention_suggestions_menu'))).dy;
          expect(menuTop, greaterThanOrEqualTo(mediaBottom));
        },
      );

      testWidgets(
        'mention picker sits ABOVE the input when no media is attached',
        (tester) async {
          await pumpChatScreen(tester);
          await tester.enterText(find.byType(TextField), '@');
          await tester.pumpAndSettle();

          expect(find.byKey(const Key('mention_suggestions_menu')), findsOneWidget);
          // The picker is NOT inside the attachment area when there is no
          // media (the attachment area itself isn't rendered).
          expect(find.byKey(const Key('attachment_area')), findsNothing);

          final inputTop = tester.getTopLeft(find.byType(WnChatMessageInput)).dy;
          final menuTop = tester.getTopLeft(find.byKey(const Key('mention_suggestions_menu'))).dy;
          expect(menuTop, lessThan(inputTop));
        },
      );
    });
  });
}
