import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:mime/mime.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/connectivity_watcher.dart';
import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/state.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:path/path.dart' as pathlib;
import 'package:permission_handler/permission_handler.dart';

class XmppService {
  XmppService() :
    _currentlyOpenedChatJid = '',
    _xmppConnectionSubscription = null,
    _state = null,
    _eventHandler = EventHandler(),
    _appOpen = true,
    _loginTriggeredFromUI = false,
    _log = Logger('XmppService') {
      _eventHandler.addMatchers([
        EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
        EventTypeMatcher<ResourceBindingSuccessEvent>(_onResourceBindingSuccess),
        EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
        EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
        EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
        EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
        EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
        EventTypeMatcher<MessageEvent>(_onMessage),
        EventTypeMatcher<BlocklistBlockPushEvent>(_onBlocklistBlockPush),
        EventTypeMatcher<BlocklistUnblockPushEvent>(_onBlocklistUnblockPush),
        EventTypeMatcher<BlocklistUnblockAllPushEvent>(_onBlocklistUnblockAllPush),
        EventTypeMatcher<StanzaSendingCancelledEvent>(_onStanzaSendingCancelled),
      ]);
    }
  final Logger _log;
  final EventHandler _eventHandler;
  bool _loginTriggeredFromUI;
  bool _appOpen;
  String _currentlyOpenedChatJid;
  StreamSubscription<dynamic>? _xmppConnectionSubscription;
  XmppState? _state;

  Future<XmppState> getXmppState() async {
    if (_state != null) return _state!;

    _state = await GetIt.I.get<DatabaseService>().getXmppState();
    return _state!;
  }

  /// A wrapper to modify the [XmppState] and commit it.
  Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
    _state = func(_state!);
    await GetIt.I.get<DatabaseService>().saveXmppState(_state!);
  }

  /// Stores whether the app is open or not. Useful for notifications.
  void setAppState(bool open) {
    _appOpen = open;
  }
  
  Future<ConnectionSettings?> getConnectionSettings() async {
    final state = await getXmppState();

    if (state.jid == null || state.password == null) {
      return null;
    }

    return ConnectionSettings(
      jid: JID.fromString(state.jid!),
      password: state.password!,
      useDirectTLS: true,
      allowPlainAuth: true,
    );
  }

  /// Marks the conversation with jid [jid] as open and resets its unread counter if it is
  /// greater than 0.
  Future<void> setCurrentlyOpenedChatJid(String jid) async {
    final cs = GetIt.I.get<ConversationService>();

    _currentlyOpenedChatJid = jid;
    final conversation = await cs.getConversationByJid(jid);

    if (conversation != null && conversation.unreadCounter > 0) {
      final newConversation = await cs.updateConversation(
        conversation.id,
        unreadCounter: 0,
      );

      sendEvent(
        ConversationUpdatedEvent(conversation: newConversation),
      );
    }
  }

  /// Returns the JID of the chat that is currently opened. Null, if none is open.
  String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;

  /// Sends a message correction to [recipient] regarding the message with stanza id
  /// [oldId]. The old message's body gets corrected to [newBody]. [id] is the message's
  /// database id. [chatState] can be optionally specified to also include a chat state
  /// in the message.
  ///
  /// This function handles updating the message and optionally the corresponding
  /// conversation.
  Future<void> sendMessageCorrection(int id, String newBody, String oldId, String recipient, ChatState? chatState) async {
    final ms = GetIt.I.get<MessageService>();
    final cs = GetIt.I.get<ConversationService>();
    final conn = GetIt.I.get<XmppConnection>();
    final timestamp = DateTime.now().millisecondsSinceEpoch;

    // Update the database
    final msg = await ms.updateMessage(
      id,
      isEdited: true,
      body: newBody,
    );
    sendEvent(MessageUpdatedEvent(message: msg));

    final conv = await cs.getConversationByJid(msg.conversationJid);
    if (conv != null && conv.lastMessage?.id == id) {
      final newConv = await cs.updateConversation(
        conv.id,
        lastChangeTimestamp: timestamp,
        lastMessage: msg,
      );
      cs.setConversation(newConv);
      sendEvent(ConversationUpdatedEvent(conversation: newConv));
    }
    
    // Send the correction
    conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
      MessageDetails(
        to: recipient,
        body: newBody,
        lastMessageCorrectionId: oldId,
        chatState: chatState,
      ),
    );
  }
  
  /// Sends a message to JIDs in [recipients] with the body of [body].
  Future<void> sendMessage({
      required String body,
      required List<String> recipients,
      Message? quotedMessage,
      String? commandId,
      ChatState? chatState,
      sticker.Sticker? sticker,
  }) async {
    final ms = GetIt.I.get<MessageService>();
    final cs = GetIt.I.get<ConversationService>();
    final conn = GetIt.I.get<XmppConnection>();
    final timestamp = DateTime.now().millisecondsSinceEpoch;
    
    for (final recipient in recipients) {
      final sid = conn.generateId();
      final originId = conn.generateId();
      final conversation = await cs.getConversationByJid(recipient);
      final message = await ms.addMessageFromData(
        body,
        timestamp,
        conn.getConnectionSettings().jid.toString(),
        recipient,
        sticker != null,
        sid,
        false,
        conversation!.encrypted,
        // TODO(Unknown): Maybe make this depend on some setting
        false,
        originId: originId,
        quoteId: quotedMessage?.sid,
        stickerPackId: sticker?.stickerPackId,
        stickerHashKey: sticker?.hashKey,
        srcUrl: sticker?.urlSources.first,
        mediaType: sticker?.mediaType,
      );
      final newConversation = await cs.updateConversation(
        conversation.id,
        lastMessage: message,
        lastChangeTimestamp: timestamp,
      );

      // Using the same ID should be fine.
      sendEvent(
        MessageAddedEvent(message: message),
        id: commandId,
      );
      
      conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
        MessageDetails(
          to: recipient,
          body: body,
          requestDeliveryReceipt: true,
          id: sid,
          originId: originId,
          quoteBody: createFallbackBodyForQuotedMessage(quotedMessage),
          quoteFrom: quotedMessage?.sender,
          quoteId: quotedMessage?.sid,
          chatState: chatState,
          shouldEncrypt: newConversation.encrypted,
          stickerPackId: sticker?.stickerPackId,
          sfs: sticker == null ?
            null :
            StatelessFileSharingData(
              FileMetadataData(
                mediaType: sticker.mediaType,
                width: sticker.width,
                height: sticker.height,
                desc: sticker.desc,
                size: sticker.size,
                thumbnails: [],
                hashes: sticker.hashes,
              ),
              sticker.urlSources
                // ignore: unnecessary_lambdas
                .map((s) => StatelessFileSharingUrlSource(s))
                .toList(),
            ),
          setOOBFallbackBody: sticker != null ?
            false :
            true,
        ),
      );

      sendEvent(
        ConversationUpdatedEvent(conversation: newConversation),
      );
    }
  }

  MediaFileLocation? _getMessageSrcUrl(MessageEvent event) {
    if (event.sfs != null) {
      final source = firstWhereOrNull(
        event.sfs!.sources,
        (StatelessFileSharingSource source) {
          return source is StatelessFileSharingUrlSource || source is StatelessFileSharingEncryptedSource;
        },
      );

      final name = event.sfs?.metadata.name;
      if (source is StatelessFileSharingUrlSource) {
        return MediaFileLocation(
          source.url,
          name != null ?
            escapeFilename(name) :
            filenameFromUrl(source.url),
          null,
          null,
          null,
          event.sfs?.metadata.hashes,
          null,
        );
      } else {
        final esource = source! as StatelessFileSharingEncryptedSource;
        return MediaFileLocation(
          esource.source.url,
          name != null ?
            escapeFilename(name) :
            filenameFromUrl(esource.source.url),
          esource.encryption.toNamespace(),
          esource.key,
          esource.iv,
          event.sfs?.metadata.hashes,
          esource.hashes,
        );
      }
    } else if (event.oob != null) {
      return MediaFileLocation(
        event.oob!.url!,
        filenameFromUrl(event.oob!.url!),
        null,
        null,
        null,
        null,
        null,
      );
    }

    return null;
  }

  Future<void> _acknowledgeMessage(MessageEvent event) async {
    final result = await GetIt.I.get<XmppConnection>().getDiscoManager().discoInfoQuery(event.fromJid.toString());
    if (result.isType<DiscoError>()) return;

    final info = result.get<DiscoInfo>();
    if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
      unawaited(
        GetIt.I.get<XmppConnection>().sendStanza(
          Stanza.message(
            to: event.fromJid.toBare().toString(),
            type: event.type,
            children: [
              makeChatMarker('received', event.stanzaId.originId ?? event.sid)
            ],
          ),
        ),
      );
    } else if (event.deliveryReceiptRequested && info.features.contains(deliveryXmlns)) {
      unawaited(
        GetIt.I.get<XmppConnection>().sendStanza(
          Stanza.message(
            to: event.fromJid.toBare().toString(),
            type: event.type,
            children: [
              makeMessageDeliveryResponse(event.stanzaId.originId ?? event.sid)
            ],
          ),
        ),
      );
    }
  }

  /// Send a read marker to [to] in order to mark the message with stanza id [sid]
  /// as read. If sending chat markers is disabled in the preferences, then this
  /// function will do nothing.
  Future<void> sendReadMarker(String to, String sid) async {
    final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
    if (!prefs.sendChatMarkers) return;

    unawaited(
      GetIt.I.get<XmppConnection>().sendStanza(
        Stanza.message(
          to: to,
          type: 'chat',
          children: [
            makeChatMarker('displayed', sid),
          ],
        ),
      ),
    );
  }

  /// Returns true if we are allowed to automatically download a file
  Future<bool> _automaticFileDownloadAllowed() async {
    final prefs = await GetIt.I.get<PreferencesService>().getPreferences();

    final currentConnection = GetIt.I.get<ConnectivityService>().currentState;
    return prefs.autoDownloadWifi && currentConnection == ConnectivityResult.wifi
      || prefs.autoDownloadMobile && currentConnection == ConnectivityResult.mobile;
  }
 
  void installEventHandlers() {
    _xmppConnectionSubscription?.cancel();
    _xmppConnectionSubscription = GetIt.I.get<XmppConnection>().asBroadcastStream().listen(_eventHandler.run);
  }

  Future<void> connect(ConnectionSettings settings, bool triggeredFromUI) async {
    final lastResource = (await getXmppState()).resource;

    _loginTriggeredFromUI = triggeredFromUI;
    GetIt.I.get<XmppConnection>().setConnectionSettings(settings);
    unawaited(GetIt.I.get<XmppConnection>().connect(lastResource: lastResource));
    installEventHandlers();
  }

  Future<XmppConnectionResult> connectAwaitable(ConnectionSettings settings, bool triggeredFromUI) async {
    final lastResource = (await getXmppState()).resource;

    _loginTriggeredFromUI = triggeredFromUI;
    GetIt.I.get<XmppConnection>().setConnectionSettings(settings);
    installEventHandlers();
    return GetIt.I.get<XmppConnection>().connectAwaitable(lastResource: lastResource);
  }

  /// Wrapper function for creating shared media entries for the given paths.
  /// [messages] is the mapping of "File path -> Recipient -> Message" required for
  /// setting the single shared medium's message Id attribute.
  /// [paths] is the list of paths to create shared media entries for.
  /// [recipient] is the bare string JID that the messages will be sent to.
  /// [conversationId] is the database id of the conversation these shared media entries
  /// belong to.
  Future<List<SharedMedium>> _createSharedMedia(Map<String, Map<String, Message>> messages, List<String> paths, String recipient, int conversationId) async {
    final sharedMedia = List<SharedMedium>.empty(growable: true);
    for (final path in paths) {
      sharedMedia.add(
        await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
          path,
          DateTime.now().millisecondsSinceEpoch,
          conversationId,
          messages[path]![recipient]!.id,
          mime: lookupMimeType(path),
        ),
      );
    }
    return sharedMedia;
  }
  
  Future<void> sendFiles(List<String> paths, List<String> recipients) async {
    // Create a new message
    final ms = GetIt.I.get<MessageService>();
    final cs = GetIt.I.get<ConversationService>();
    final css = GetIt.I.get<ContactsService>();
    final prefs = await GetIt.I.get<PreferencesService>().getPreferences();

    // Path -> Recipient -> Message
    final messages = <String, Map<String, Message>>{};
    // Path -> Thumbnails
    final thumbnails = <String, List<Thumbnail>>{};
    // Path -> Dimensions
    final dimensions = <String, Size>{};
    // Recipient -> Should encrypt
    final encrypt = <String, bool>{};
    // Recipient -> Last message Id
    final lastMessages = <String, Message>{};

    // Create the messages and shared media entries
    final conn = GetIt.I.get<XmppConnection>();
    for (final path in paths) {
      final pathMime = lookupMimeType(path);

      for (final recipient in recipients) {
        final conversation = await cs.getConversationByJid(recipient);
        encrypt[recipient] = conversation?.encrypted ?? prefs.enableOmemoByDefault;

        // TODO(Unknown): Do the same for videos
        if (pathMime != null && pathMime.startsWith('image/')) {
          final imageSize = await getImageSizeFromPath(path);
          if (imageSize != null) {
            dimensions[path] = Size(
              imageSize.width,
              imageSize.height,
            );
          } else {
            _log.warning('Failed to get image dimensions for $path');
          }
        }

        final msg = await ms.addMessageFromData(
          '',
          DateTime.now().millisecondsSinceEpoch, 
          conn.getConnectionSettings().jid.toString(),
          recipient,
          true,
          conn.generateId(),
          false,
          encrypt[recipient]!,
          // TODO(Unknown): Maybe make this depend on some setting
          false,
          mediaUrl: path,
          mediaType: pathMime,
          originId: conn.generateId(),
          mediaWidth: dimensions[path]?.width.toInt(),
          mediaHeight: dimensions[path]?.height.toInt(),
          filename: pathlib.basename(path),
          isUploading: true,
        );
        if (messages.containsKey(path)) {
          messages[path]![recipient] = msg;
        } else {
          messages[path] = { recipient: msg };
        }

        if (path == paths.last) {
          lastMessages[recipient] = msg;
        }

        sendEvent(MessageAddedEvent(message: msg));
      }
    }

    // Create the shared media entries
    // Recipient -> [Shared Medium]
    final sharedMediaMap = <String, List<SharedMedium>>{};
    final rs = GetIt.I.get<RosterService>();
    for (final recipient in recipients) {
      final conversation = await cs.getConversationByJid(recipient);
      if (conversation != null) {
        // Update conversation
        var updatedConversation = await cs.updateConversation(
          conversation.id,
          lastMessage: lastMessages[recipient],
          lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
          open: true,
        );

        sharedMediaMap[recipient] = await _createSharedMedia(messages, paths, recipient, conversation.id);

        updatedConversation = updatedConversation.copyWith(
          sharedMedia: [
            ...sharedMediaMap[recipient]!,
            ...conversation.sharedMedia,
          ],
        );
        cs.setConversation(updatedConversation);
        sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
      } else {
        // Create conversation
        final rosterItem = await rs.getRosterItemByJid(recipient);
        final contactId = await css.getContactIdForJid(recipient);
        var newConversation = await cs.addConversationFromData(
          // TODO(Unknown): Should we use the JID parser?
          rosterItem?.title ?? recipient.split('@').first,
          lastMessages[recipient],
          rosterItem?.avatarUrl ?? '',
          recipient,
          0,
          DateTime.now().millisecondsSinceEpoch,
          true,
          prefs.defaultMuteState,
          prefs.enableOmemoByDefault,
          contactId,
          await css.getProfilePicturePathForJid(recipient),
          await css.getContactDisplayName(contactId),
        );

        sharedMediaMap[recipient] = await _createSharedMedia(messages, paths, recipient, newConversation.id);
        newConversation = newConversation.copyWith(
          sharedMedia: sharedMediaMap[recipient]!,
        );
        cs.setConversation(newConversation);

        // Notify the UI
        sendEvent(ConversationAddedEvent(conversation: newConversation));
      }
    }

    // Requesting Upload slots and uploading
    final hfts = GetIt.I.get<HttpFileTransferService>();
    for (final path in paths) {
      final pathMime = lookupMimeType(path);

      for (final recipient in recipients) {
        // TODO(PapaTutuWawa): Do this for videos
        // TODO(PapaTutuWawa): Maybe do this in a separate isolate
        if ((pathMime ?? '').startsWith('image/')) {
          // Generate a thumbnail only when we have to
          if (!thumbnails.containsKey(path)) {
            final thumbnail = await generateBlurhashThumbnail(path);
            if (thumbnail != null) {
              thumbnails[path] = [BlurhashThumbnail(thumbnail)];
            } else {
              _log.warning('Failed to generate thumbnail for $path');
            }
          }
        }

        // Send an upload notification
        conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
          MessageDetails(
            to: recipient,
            id: messages[path]![recipient]!.sid,
            fun: FileMetadataData(
              // TODO(Unknown): Maybe add media type specific metadata
              mediaType: lookupMimeType(path),
              name: pathlib.basename(path),
              size: File(path).statSync().size,
              thumbnails: thumbnails[path] ?? [],
            ),
            shouldEncrypt: encrypt[recipient]!,
          ),
        );
      }

      await hfts.uploadFile(
        FileUploadJob(
          recipients,
          path,
          pathMime,
          encrypt,
          messages[path]!,
          thumbnails[path] ?? [],
        ),
      );
    }

    _log.finest('File upload done');
  }

  Future<void> _initializeOmemoService(String jid) async {
    await GetIt.I.get<OmemoService>().initializeIfNeeded(jid);
    final result = await GetIt.I.get<OmemoService>().publishDeviceIfNeeded();
    if (result != null) {
      // Notify the user that we could not publish the Omemo ~identity~ titty
      await GetIt.I.get<NotificationsService>().showWarningNotification(
        'Encryption',
        t.errors.omemo.couldNotPublish,
      );
    }
  }

  /// Sets the permanent notification's title to the corresponding one for the
  /// XmppConnection's state [state].
  void setNotificationText(XmppConnectionState state) {
    switch (state) {
      case XmppConnectionState.connected:
        GetIt.I.get<BackgroundService>().setNotification(
          'Moxxy',
          t.notifications.permanent.ready,
        );
      break;
      case XmppConnectionState.connecting:
        GetIt.I.get<BackgroundService>().setNotification(
          'Moxxy',
          t.notifications.permanent.connecting,
        );
      break;
      case XmppConnectionState.notConnected:
        GetIt.I.get<BackgroundService>().setNotification(
          'Moxxy',
          t.notifications.permanent.disconnect,
        );
      break;
      case XmppConnectionState.error:
        GetIt.I.get<BackgroundService>().setNotification(
          'Moxxy',
          t.notifications.permanent.error,
        );
      break;
    }
  }
  
  Future<void> _onConnectionStateChanged(ConnectionStateChangedEvent event, { dynamic extra }) async {
    setNotificationText(event.state);

    await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
      event.before, event.state,
    );
    
    if (event.state == XmppConnectionState.connected) {
      final connection = GetIt.I.get<XmppConnection>();

      // TODO(Unknown): Maybe have something better
      final settings = connection.getConnectionSettings();
      await modifyXmppState((state) => state.copyWith(
          jid: settings.jid.toString(),
          password: settings.password,
      ),);

      _log.finest('Connection connected. Is resumed? ${event.resumed}');
      unawaited(_initializeOmemoService(settings.jid.toString()));
      
      if (!event.resumed) {
        // Reset the blocking service's cache
        GetIt.I.get<BlocklistService>().onNewConnection();

        // Reset the OMEMO cache
        GetIt.I.get<OmemoService>().onNewConnection();
        
        // Enable carbons
        final carbonsResult = await connection
          .getManagerById<CarbonsManager>(carbonsManager)!
          .enableCarbons();
        if (!carbonsResult) {
          _log.warning('Failed to enable carbons');
        }

        // In section 5 of XEP-0198 it says that a client should not request the roster
        // in case of a stream resumption.
        await connection
          .getManagerById<RosterManager>(rosterManager)!
          .requestRoster();
        
        // TODO(Unknown): Once groupchats come into the equation, this gets trickier
        final roster = await GetIt.I.get<RosterService>().getRoster();
        for (final item in roster) {
          await GetIt.I.get<AvatarService>().fetchAndUpdateAvatarForJid(item.jid, item.avatarHash);
        }

        await GetIt.I.get<BlocklistService>().getBlocklist();
      }
      
      // Make sure we display our own avatar correctly.
      // Note that this only requests the avatar if its hash differs from the locally cached avatar's.
      // TODO(Unknown): Maybe don't do this on mobile Internet
      unawaited(GetIt.I.get<AvatarService>().requestOwnAvatar());
      
      if (_loginTriggeredFromUI) {
        // TODO(Unknown): Trigger another event so the UI can see this aswell
        await modifyXmppState((state) => state.copyWith(
          jid: connection.getConnectionSettings().jid.toString(),
          displayName: connection.getConnectionSettings().jid.local,
          avatarUrl: '',
          avatarHash: '',
        ),);
      }
    }
  }

  Future<void> _onResourceBindingSuccess(ResourceBindingSuccessEvent event, { dynamic extra }) async {
    await modifyXmppState((state) => state.copyWith(
        resource: event.resource,
    ),);
  }

  Future<void> _onSubscriptionRequestReceived(SubscriptionRequestReceivedEvent event, { dynamic extra }) async {
    final prefs = await GetIt.I.get<PreferencesService>().getPreferences();

    if (prefs.autoAcceptSubscriptionRequests) {
      GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(
        event.from.toBare().toString(),
      );
    }

    if (!prefs.showSubscriptionRequests) return;
    
    final css = GetIt.I.get<ContactsService>();
    final cs = GetIt.I.get<ConversationService>();
    final conversation = await cs.getConversationByJid(event.from.toBare().toString());
    final timestamp = DateTime.now().millisecondsSinceEpoch;
    if (conversation != null && !conversation.open) {
      // TODO(Unknown): Make it configurable if this should happen
      final bare = event.from.toBare().toString();
      final contactId = await css.getContactIdForJid(bare);
      final conv = await cs.addConversationFromData(
        bare.split('@')[0],
        null,
        '', // TODO(Unknown): avatarUrl
        bare,
        0,
        timestamp,
        true,
        prefs.defaultMuteState,
        prefs.enableOmemoByDefault,
        contactId,
        await css.getProfilePicturePathForJid(bare),
        await css.getContactDisplayName(contactId),
      );

      sendEvent(ConversationAddedEvent(conversation: conv));
    }
  }

  Future<void> _onDeliveryReceiptReceived(DeliveryReceiptReceivedEvent event, { dynamic extra }) async {
    _log.finest('Received delivery receipt from ${event.from.toString()}');
    final db = GetIt.I.get<DatabaseService>();
    final ms = GetIt.I.get<MessageService>();
    final cs = GetIt.I.get<ConversationService>();
    final sender = event.from.toBare().toString();
    final dbMsg = await db.getMessageByXmppId(event.id, sender);
    if (dbMsg == null) {
      _log.warning('Did not find the message with id ${event.id} in the database!');
      return;
    }
    
    final msg = await ms.updateMessage(
      dbMsg.id,
      received: true,
    );
    sendEvent(MessageUpdatedEvent(message: msg));

    // Update the conversation
    final conv = await cs.getConversationByJid(sender);
    if (conv != null && conv.lastMessage?.id == msg.id) {
      final newConv = conv.copyWith(lastMessage: msg);
      cs.setConversation(newConv);
      _log.finest('Updating conversation');
      sendEvent(ConversationUpdatedEvent(conversation: newConv));
    }
  }

  Future<void> _onChatMarker(ChatMarkerEvent event, { dynamic extra }) async {
    _log.finest('Chat marker from ${event.from.toString()}');

    final db = GetIt.I.get<DatabaseService>();
    final ms = GetIt.I.get<MessageService>();
    final cs = GetIt.I.get<ConversationService>();
    final sender = event.from.toBare().toString();
    final dbMsg = await db.getMessageByXmppId(event.id, sender);
    if (dbMsg == null) {
      _log.warning('Did not find the message in the database!');
      return;
    }
    
    final msg = await ms.updateMessage(
      dbMsg.id,
      received: dbMsg.received || event.type == 'received' || event.type == 'displayed' || event.type == 'acknowledged',
      displayed: dbMsg.displayed || event.type == 'displayed' || event.type == 'acknowledged',
    );
    sendEvent(MessageUpdatedEvent(message: msg));

    
    // Update the conversation
    final conv = await cs.getConversationByJid(sender);
    if (conv != null && conv.lastMessage?.id == msg.id) {
      final newConv = conv.copyWith(lastMessage: msg);
      cs.setConversation(newConv);
      _log.finest('Updating conversation');
      sendEvent(ConversationUpdatedEvent(conversation: newConv));
    }
  }

  Future<void> _onChatState(ChatState state, String jid) async {
    final cs = GetIt.I.get<ConversationService>();
    final conversation = await cs.getConversationByJid(jid);
    if (conversation == null) return;

    final newConversation = conversation.copyWith(chatState: state);
    cs.setConversation(newConversation);
    sendEvent(
      ConversationUpdatedEvent(
        conversation: newConversation,
      ),
    );
  }

  /// Return true if [event] describes a message that we want to display.
  bool _isMessageEventMessage(MessageEvent event) {
    return event.body.isNotEmpty || event.sfs != null || event.fun != null;
  }

  /// Extract the thumbnail data from a message, if existent.
  String? _getThumbnailData(MessageEvent event) {
    final thumbnails = firstNotNull([
        event.sfs?.metadata.thumbnails,
        event.fun?.thumbnails,
    ]) ?? [];
    for (final i in thumbnails) {
      if (i is BlurhashThumbnail) {
        return i.hash;
      }
    }

    return null;
  }

  /// Extract the mime guess from a message, if existent.
  String? _getMimeGuess(MessageEvent event) {
    return firstNotNull([
      event.sfs?.metadata.mediaType,
      event.fun?.mediaType,
    ]);
  }

  /// Extract the embedded dimensions, if existent.
  Size? _getDimensions(MessageEvent event) {
    if (event.sfs != null && event.sfs?.metadata.width != null && event.sfs?.metadata.height != null) {
      return Size(
        event.sfs!.metadata.width!.toDouble(),
        event.sfs!.metadata.height!.toDouble(),
      );
    } else if (event.fun != null && event.fun?.width != null && event.fun?.height != null) {
      return Size(
        event.fun!.width!.toDouble(),
        event.fun!.height!.toDouble(),
      );
    }

    return null;
  }
  
  /// Returns true if a file is embedded in [event]. If not, returns false.
  /// [embeddedFile] is the possible source of the file. If no file is present, then
  /// [embeddedFile] is null.
  bool _isFileEmbedded(MessageEvent event, MediaFileLocation? embeddedFile) {
    // True if we determine a file to be embedded. Checks if the Url is using HTTPS and
    // that the message body and the OOB url are the same if the OOB url is not null.
    return embeddedFile != null
      && Uri.parse(embeddedFile.url).scheme == 'https'
      && implies(event.oob != null, event.body == event.oob?.url)
      && event.stickerPackId == null;
  }

  /// Handle a message retraction given the MessageEvent [event].
  Future<void> _handleMessageRetraction(MessageEvent event, String conversationJid) async {
    await GetIt.I.get<MessageService>().retractMessage(
      conversationJid,
      event.messageRetraction!.id,
      event.fromJid.toBare().toString(),
      false,
    );
  }
  
  /// Returns true if a file should be automatically downloaded. If it should not, it
  /// returns false.
  /// [conversationJid] refers to the JID of the conversation the message was received in.
  Future<bool> _shouldDownloadFile(String conversationJid) async {
    return (await Permission.storage.status).isGranted
      && await _automaticFileDownloadAllowed()
      && await GetIt.I.get<RosterService>().isInRoster(conversationJid);
  }

  /// Handles receiving a message stanza of type error.
  Future<void> _handleErrorMessage(MessageEvent event) async {
    if (event.error == null) {
      _log.warning('Received error for message ${event.sid} without an error element');
      return;
    }

    final ms = GetIt.I.get<MessageService>();
    final msg = await ms.getMessageByStanzaId(
      event.fromJid.toBare().toString(),
      event.sid,
    );

    if (msg == null) {
      _log.warning('Received error for message ${event.sid} we cannot find');
      return;
    }

    int error;
    switch (event.error!.error) {
      case 'service-unavailable':
      error = messageServiceUnavailable;
      break;
      case 'remote-server-timeout':
      error = messageRemoteServerTimeout;
      break;
      case 'remote-server-not-found':
      error = messageRemoteServerNotFound;
      break;
      default:
      error = unspecifiedError;
      break;
    }
    
    final newMsg = await ms.updateMessage(
      msg.id,
      errorType: error,
    );

    // TODO(PapaTutuWawa): Show a notification for certain error types, i.e. those
    //                     that mean that the message could not be delivered.
    sendEvent(MessageUpdatedEvent(message: newMsg));
  }

  Future<void> _handleMessageCorrection(MessageEvent event, String conversationJid) async {
    final ms = GetIt.I.get<MessageService>();
    final cs = GetIt.I.get<ConversationService>();
    final msg = await ms.getMessageByStanzaId(conversationJid, event.messageCorrectionId!);
    if (msg == null) {
      _log.warning('Received message correction for message ${event.messageCorrectionId} we cannot find.');
      return;
    }

    // Check if the Jid is allowed to do correct the message
    // TODO(Unknown): Maybe use the JID parser?
    final bareSender = event.fromJid.toBare().toString();
    if (msg.sender.split('/').first != bareSender) {
      _log.warning('Received a message correction from $bareSender for message that is not sent by $bareSender');
      return;
    }
    
    // Check if the message can be corrected
    if (!msg.canEdit(true)) {
      _log.warning('Received a message correction for a message that cannot be edited');
      return;
    }

    final newMsg = await ms.updateMessage(
      msg.id,
      body: event.body,
      isEdited: true,
    );
    sendEvent(MessageUpdatedEvent(message: newMsg));

    final conv = await cs.getConversationByJid(msg.conversationJid);
    if (conv != null && conv.lastMessage?.id == msg.id) {
      final newConv = conv.copyWith(
        lastMessage: newMsg,
      );
      cs.setConversation(newConv);
      sendEvent(ConversationUpdatedEvent(conversation: newConv));
    }
  }

  Future<void> _handleMessageReactions(MessageEvent event, String conversationJid) async {
    final ms = GetIt.I.get<MessageService>();
    // TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
    final msg = await ms.getMessageByStanzaOrOriginId(
      conversationJid,
      event.messageReactions!.messageId,
    );
    if (msg == null) {
      _log.warning('Received reactions for ${event.messageReactions!.messageId} from ${event.fromJid} for $conversationJid, but could not find message.');
      return;
    }

    final state = await getXmppState();
    final sender = event.fromJid.toBare().toString();
    final isCarbon = sender == state.jid;
    final reactions = List<Reaction>.from(msg.reactions);
    final emojis = event.messageReactions!.emojis;

    // Find out what emojis the sender has already sent
    final sentEmojis = msg.reactions
      .where((r) {
        return isCarbon ?
          r.reactedBySelf :
          r.senders.contains(sender);
      })
      .map((r) => r.emoji)
      .toList();
    // Find out what reactions were removed
    final removedEmojis = sentEmojis
      .where((e) => !emojis.contains(e));

    for (final emoji in emojis) {
      final i = reactions.indexWhere((r) => r.emoji == emoji);
      if (i == -1) {
        reactions.add(
          Reaction(
            isCarbon ?
              [] :
              [sender],
            emoji,
            isCarbon,
          ),
        );
      } else {
        List<String> senders;
        if (isCarbon) {
          senders = reactions[i].senders;
        } else {
          // Ensure that we don't add a sender multiple times to the same reaction
          if (reactions[i].senders.contains(sender)) {
            senders = reactions[i].senders;
          } else {
            senders = [
              ...reactions[i].senders,
              sender,
            ];
          }
        }
        
        reactions[i] = reactions[i].copyWith(
          senders: senders,
          reactedBySelf: isCarbon ?
            true :
            reactions[i].reactedBySelf,
        );
      }
    }

    for (final emoji in removedEmojis) {
      final i = reactions.indexWhere((r) => r.emoji == emoji);
      assert(i >= -1, 'The reaction must exist');

      if (isCarbon && reactions[i].senders.isEmpty ||
          !isCarbon && reactions[i].senders.length == 1 && !reactions[i].reactedBySelf) {
        reactions.removeAt(i);
      } else {
        reactions[i] = reactions[i].copyWith(
          senders: isCarbon ?
            reactions[i].senders :
            reactions[i].senders
              .where((s) => s != sender)
              .toList(),
          reactedBySelf: isCarbon ?
            false :
            reactions[i].reactedBySelf,
        );
      }
    }

    final newMessage = await ms.updateMessage(
      msg.id,
      reactions: reactions,
    );
    sendEvent(
      MessageUpdatedEvent(message: newMessage),
    );
  }
  
  Future<void> _onMessage(MessageEvent event, { dynamic extra }) async {
    // The jid this message event is meant for
    final conversationJid = event.isCarbon
      ? event.toJid.toBare().toString()
      : event.fromJid.toBare().toString();

    if (event.type == 'error') {
      await _handleErrorMessage(event);
      _log.finest('Processed error message. Ending event processing here.');
      return;
    }
      
    // Process the chat state update. Can also be attached to other messages
    if (event.chatState != null) await _onChatState(event.chatState!, conversationJid);

    // Process message corrections separately
    if (event.messageCorrectionId != null) {
      await _handleMessageCorrection(event, conversationJid);
      return;
    }
    
    // Process File Upload Notifications replacements separately
    if (event.funReplacement != null) {
      await _handleFileUploadNotificationReplacement(event, conversationJid);
      return;
    }

    if (event.messageRetraction != null) {
      await _handleMessageRetraction(event, conversationJid);
      return;
    }

    // Handle message reactions
    if (event.messageReactions != null) {
      await _handleMessageReactions(event, conversationJid);
      return;
    }
    
    // Stop the processing here if the event does not describe a displayable message
    if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return;

    final state = await getXmppState();
    final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
    // The (portential) roster item of the chat partner
    final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(conversationJid);
    // Is the conversation partner in our roster
    final isInRoster = rosterItem != null;
    // True if the message was sent by us (via a Carbon)
    final sent = event.isCarbon && event.fromJid.toBare().toString() == state.jid;
    // The timestamp at which we received the message
    final messageTimestamp = DateTime.now().millisecondsSinceEpoch;
    
    // Acknowledge the message if enabled
    if (event.deliveryReceiptRequested && isInRoster && prefs.sendChatMarkers) {
      // NOTE: We do not await it to prevent us being blocked if the IQ response id delayed
      await _acknowledgeMessage(event);
    }

    // Pre-process the message in case it is a reply to another message
    String? replyId;
    var messageBody = event.body;
    if (event.reply != null) {
      replyId = event.reply!.id;

      // Strip the compatibility fallback, if specified
      messageBody = event.reply!.removeFallback(messageBody);
      _log.finest('Removed message reply compatibility fallback from message');
    }

    // The Url of the file embedded in the message, if there is one.
    final embeddedFile = _getMessageSrcUrl(event);
    // True if we determine a file to be embedded. Checks if the Url is using HTTPS and
    // that the message body and the OOB url are the same if the OOB url is not null.
    final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
    // Indicates if we should auto-download the file, if a file is specified in the message
    final shouldDownload = await _shouldDownloadFile(conversationJid);
    // The thumbnail for the embedded file.
    final thumbnailData = _getThumbnailData(event);
    // Indicates if a notification should be created for the message.
    // The way this variable works is that if we can download the file, then the
    // notification will be created later by the [DownloadService]. If we don't want the
    // download to happen automatically, then the notification should happen immediately.
    var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
    // A guess for the Mime type of the embedded file.
    var mimeGuess = _getMimeGuess(event);
    // Guess a sticker hash key, if the message is a sticker
    final stickerHashKey = event.stickerPackId != null ?
      getStickerHashKey(event.sfs!.metadata.hashes) :
      null;
    // The potential sticker pack
    final stickerPack = event.stickerPackId != null ?
      await GetIt.I.get<StickersService>().getStickerPackById(
        event.stickerPackId!,
      ) :
      null;

    // Automatically download the sticker pack, if
    // - a sticker was received,
    // - the sender is in the roster,
    // - we don't have the sticker pack locally,
    // - and it is enabled in the settings
    if (event.stickerPackId != null &&
        stickerPack == null &&
        prefs.autoDownloadStickersFromContacts &&
        isInRoster) {
      unawaited(
        GetIt.I.get<StickersService>().importFromPubSubWithEvent(
          event.fromJid,
          event.stickerPackId!,
        ),
      );
    }
      
    // Create the message in the database
    final ms = GetIt.I.get<MessageService>();
    final dimensions = _getDimensions(event);
    var message = await ms.addMessageFromData(
      messageBody,
      messageTimestamp,
      event.fromJid.toString(),
      conversationJid,
      isFileEmbedded || event.fun != null || event.stickerPackId != null,
      event.sid,
      event.fun != null,
      event.encrypted,
      event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ?? false,
      srcUrl: embeddedFile?.url,
      filename: event.fun?.name ?? embeddedFile?.filename,
      key: embeddedFile?.keyBase64,
      iv: embeddedFile?.ivBase64,
      encryptionScheme: embeddedFile?.encryptionScheme,
      mediaType: mimeGuess,
      thumbnailData: thumbnailData,
      mediaWidth: dimensions?.width.toInt(),
      mediaHeight: dimensions?.height.toInt(),
      quoteId: replyId,
      originId: event.stanzaId.originId,
      errorType: errorTypeFromException(event.other['encryption_error']),
      plaintextHashes: event.sfs?.metadata.hashes,
      stickerPackId: event.stickerPackId,
      stickerHashKey: stickerHashKey,
    );
    
    // Attempt to auto-download the embedded file
    if (isFileEmbedded && shouldDownload) {
      final fts = GetIt.I.get<HttpFileTransferService>();
      final metadata = await peekFile(embeddedFile!.url);

      _log.finest('Advertised file MIME: ${metadata.mime}');
      if (metadata.mime != null) mimeGuess = metadata.mime;

      // Auto-download only if the file is below the set limit, if the limit is not set to
      // "always download".
      if (prefs.maximumAutoDownloadSize == -1
        || (metadata.size != null && metadata.size! < prefs.maximumAutoDownloadSize * 1000000)) {
        message = await ms.updateMessage(
          message.id,
          isDownloading: true,
        );
        await fts.downloadFile(
          FileDownloadJob(
            embeddedFile,
            message.id,
            conversationJid,
            mimeGuess,
          ),
        );
      } else {
        // Make sure we create the notification
        shouldNotify = true;
      }
    }

    final cs = GetIt.I.get<ConversationService>();
    final css = GetIt.I.get<ContactsService>();
    final ns = GetIt.I.get<NotificationsService>();
    // The body to be displayed in the conversations list
    final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody;
    // Specifies if we have the conversation this message goes to opened
    final isConversationOpened = _currentlyOpenedChatJid == conversationJid;
    // The conversation we're about to modify, if it exists
    final conversation = await cs.getConversationByJid(conversationJid);
    // If the conversation is muted
    final isMuted = conversation != null ? conversation.muted : prefs.defaultMuteState;
    // Whether to send the notification
    final sendNotification = !sent && shouldNotify && (!isConversationOpened || !_appOpen) && !isMuted;
    if (conversation != null) {
      // The conversation exists, so we can just update it
      final newConversation = await cs.updateConversation(
        conversation.id,
        lastMessage: message,
        lastChangeTimestamp: messageTimestamp,
        // Do not increment the counter for messages we sent ourselves (via Carbons)
        // or if we have the chat currently opened
        unreadCounter: isConversationOpened || sent
          ? conversation.unreadCounter
          : conversation.unreadCounter + 1,
        open: true, 
      );

      // Notify the UI of the update
      sendEvent(ConversationUpdatedEvent(conversation: newConversation));

      // Create the notification if we the user does not already know about the message
      if (sendNotification) {
        await ns.showNotification(
          newConversation,
          message,
          isInRoster ? newConversation.title : conversationJid,
          body: conversationBody,
        );
      }
    } else {
      // The conversation does not exist, so we must create it
      final contactId = await css.getContactIdForJid(conversationJid);
      final newConversation = await cs.addConversationFromData(
        rosterItem?.title ?? conversationJid.split('@')[0],
        message,
        rosterItem?.avatarUrl ?? '',
        conversationJid,
        sent ? 0 : 1,
        messageTimestamp,
        true,
        prefs.defaultMuteState,
        message.encrypted,
        contactId,
        await css.getProfilePicturePathForJid(conversationJid),
        await css.getContactDisplayName(contactId),
      );

      // Notify the UI
      sendEvent(ConversationAddedEvent(conversation: newConversation));

      // Creat the notification
      if (sendNotification) {
        await ns.showNotification(
          newConversation,
          message,
          isInRoster ? newConversation.title : conversationJid,
          body: messageBody,
        );
      }
    }

    // Mark the file as downlading when it includes a File Upload Notification
    if (event.fun != null) {
      message = await ms.updateMessage(
        message.id,
        isDownloading: true,
      );
    }

    // Notify the UI of the message
    sendEvent(MessageAddedEvent(message: message));
  }

  Future<void> _handleFileUploadNotificationReplacement(MessageEvent event, String conversationJid) async {
    final ms = GetIt.I.get<MessageService>();
    var message = await ms.getMessageByStanzaId(conversationJid, event.funReplacement!);
    if (message == null) {
      _log.warning('Received a FileUploadNotification replacement for unknown message');
      return;
    }

    // Check if we can even replace the message
    if (!message.isFileUploadNotification) {
      _log.warning('Received a FileUploadNotification replacement for message that is not marked as a FileUploadNotification');
      return;
    }

    // Check if the Jid is allowed to do so
    // TODO(Unknown): Maybe use the JID parser?
    final bareSender = event.fromJid.toBare().toString();
    if (message.sender.split('/').first != bareSender) {
      _log.warning('Received a FileUploadNotification replacement by $bareSender for message that is not sent by $bareSender');
      return;
    }
    
    // The Url of the file embedded in the message, if there is one.
    final embeddedFile = _getMessageSrcUrl(event);
    // Is there even a file we can download?
    final isFileEmbedded = _isFileEmbedded(event, embeddedFile);

    if (isFileEmbedded) {
      final shouldDownload = await _shouldDownloadFile(conversationJid);
      message = await ms.updateMessage(
        message.id,
        srcUrl: embeddedFile!.url,
        key: embeddedFile.keyBase64,
        iv: embeddedFile.ivBase64,
        isFileUploadNotification: false,
        isDownloading: shouldDownload,
        sid: event.sid,
        originId: event.stanzaId.originId,
      );

      // Tell the UI
      sendEvent(MessageUpdatedEvent(message: message));

      if (shouldDownload) {
        _log.finest('Advertised file MIME: ${_getMimeGuess(event)}');
        await GetIt.I.get<HttpFileTransferService>().downloadFile(
          FileDownloadJob(
            embeddedFile,
            message.id,
            conversationJid,
            _getMimeGuess(event),
            shouldShowNotification: false,
          ),
        );
      }
    } else {
      _log.warning('Received a File Upload Notification replacement but the replacement contains no file!');
    }
  }
  
  Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async {
    await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
  }
  
  Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async {
    final jid = JID.fromString(event.stanza.to!).toBare().toString();
    final ms = GetIt.I.get<MessageService>();
    final cs = GetIt.I.get<ConversationService>();
    final msg = await ms.getMessageByStanzaId(jid, event.stanza.id!);
    if (msg != null) {
      // Ack the message
      final newMsg = await ms.updateMessage(msg.id, acked: true);
      sendEvent(MessageUpdatedEvent(message: newMsg));

      // Ack the conversation
      final conv = await cs.getConversationByJid(jid);
      if (conv != null && conv.lastMessage?.id == newMsg.id) {
        final newConv = conv.copyWith(lastMessage: msg);
        cs.setConversation(newConv);
        sendEvent(ConversationUpdatedEvent(conversation: newConv));
      }
    } else {
      _log.finest('Wanted to mark message as acked but did not find the message to ack');
    }
  }

  Future<void> _onBlocklistBlockPush(BlocklistBlockPushEvent event, { dynamic extra }) async {
    await GetIt.I.get<BlocklistService>().onBlocklistPush(BlockPushType.block, event.items);
  }

  Future<void> _onBlocklistUnblockPush(BlocklistUnblockPushEvent event, { dynamic extra }) async {
    await GetIt.I.get<BlocklistService>().onBlocklistPush(BlockPushType.unblock, event.items);
  }

  Future<void> _onBlocklistUnblockAllPush(BlocklistUnblockAllPushEvent event, { dynamic extra }) async {
    GetIt.I.get<BlocklistService>().onUnblockAllPush();
  }

  Future<void> _onStanzaSendingCancelled(StanzaSendingCancelledEvent event, { dynamic extra }) async {
    // We only really care about messages
    if (event.data.stanza.tag != 'message') return;

    final ms = GetIt.I.get<MessageService>();
    final message = await ms.getMessageByStanzaId(
      JID.fromString(event.data.stanza.to!).toBare().toString(),
      event.data.stanza.id!,
    );

    if (message == null) {
      _log.warning('Message could not be sent but we cannot find it in the database');
      return;
    }

    _log.finest('Cancel reason: ${event.data.cancelReason}');
    final newMessage = await ms.updateMessage(
      message.id,
      errorType: errorTypeFromException(event.data.cancelReason),
    );


    // Tell the UI
    sendEvent(MessageUpdatedEvent(message: newMessage));
  }

  /// Creates the fallback body for quoted messages.
  /// If the quoted message contains text, it simply quotes the text.
  /// If it contains a media file, the messageEmoji (usually an emoji
  /// representing the mime type) is shown together with the file size
  /// (from experience this information is sufficient, as most clients show
  /// the file size, and including time information might be confusing and a
  /// potential privacy issue).
  /// This information is complemented either the srcUrl or – if unavailable –
  /// by the body of the quoted message. For non-media messages, we always use
  /// the body as fallback.
  String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
    if (quotedMessage == null) {
      return null;
    }

    if (quotedMessage.isMedia) {
      // Create formatted size string, if size is stored
      String quoteMessageSize;
      if (quotedMessage.mediaSize != null && quotedMessage.mediaSize! > 0) {
        quoteMessageSize = '(${fileSizeToString(quotedMessage.mediaSize!)}) ';
      } else {
        quoteMessageSize = '';
      }

      // Create media url string, or use body if no srcUrl is stored
      String quotedMediaUrl;
      if (quotedMessage.srcUrl != null && quotedMessage.srcUrl!.isNotEmpty) {
        quotedMediaUrl = '• ${quotedMessage.srcUrl!}';
      } else if (quotedMessage.body.isNotEmpty){
        quotedMediaUrl = '• ${quotedMessage.body}';
      } else {
        quotedMediaUrl = '';
      }

      // Concatenate emoji, size string, and media url and return
      return '${quotedMessage.messageEmoji} $quoteMessageSize$quotedMediaUrl';
    } else {
      return quotedMessage.body;
    }
  }
}
