/*
 *   Famedly Matrix SDK
 *   Copyright (C) 2019, 2020, 2021 Famedly GmbH
 *
 *   This program is free software: you can redistribute it and/or modify
 *   it under the terms of the GNU Affero General Public License as
 *   published by the Free Software Foundation, either version 3 of the
 *   License, or (at your option) any later version.
 *
 *   This program is distributed in the hope that it will be useful,
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 *   GNU Affero General Public License for more details.
 *
 *   You should have received a copy of the GNU Affero General Public License
 *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

import 'dart:convert';
import 'dart:typed_data';

import 'package:http/http.dart' as http;

import '../matrix.dart';
import 'utils/event_localizations.dart';
import 'utils/html_to_text.dart';

abstract class RelationshipTypes {
  static const String reply = 'm.in_reply_to';
  static const String edit = 'm.replace';
  static const String reaction = 'm.annotation';
}

/// All data exchanged over Matrix is expressed as an "event". Typically each client action (e.g. sending a message) correlates with exactly one event.
class Event extends MatrixEvent {
  User get sender => room.getUserByMXIDSync(senderId);

  @Deprecated('Use [originServerTs] instead')
  DateTime get time => originServerTs;

  @Deprecated('Use [type] instead')
  String get typeKey => type;

  @Deprecated('Use [sender.calcDisplayname()] instead')
  String? get senderName => sender.calcDisplayname();

  /// The room this event belongs to. May be null.
  final Room room;

  /// The status of this event.
  EventStatus status;

  static const EventStatus defaultStatus = EventStatus.synced;

  /// Optional. The event that redacted this event, if any. Otherwise null.
  Event? get redactedBecause {
    final redacted_because = unsigned?['redacted_because'];
    final room = this.room;
    return (redacted_because is Map<String, dynamic>)
        ? Event.fromJson(redacted_because, room)
        : null;
  }

  bool get redacted => redactedBecause != null;

  User? get stateKeyUser => room.getUserByMXIDSync(stateKey!);

  Event({
    this.status = defaultStatus,
    required Map<String, dynamic> content,
    required String type,
    required String eventId,
    required String senderId,
    required DateTime originServerTs,
    Map<String, dynamic>? unsigned,
    Map<String, dynamic>? prevContent,
    String? stateKey,
    required this.room,
  }) : super(
          content: content,
          type: type,
          eventId: eventId,
          senderId: senderId,
          originServerTs: originServerTs,
          roomId: room.id,
        ) {
    this.eventId = eventId;
    this.unsigned = unsigned;
    // synapse unfortunately isn't following the spec and tosses the prev_content
    // into the unsigned block.
    // Currently we are facing a very strange bug in web which is impossible to debug.
    // It may be because of this line so we put this in try-catch until we can fix it.
    try {
      this.prevContent = (prevContent != null && prevContent.isNotEmpty)
          ? prevContent
          : (unsigned != null &&
                  unsigned.containsKey('prev_content') &&
                  unsigned['prev_content'] is Map)
              ? unsigned['prev_content']
              : null;
    } catch (_) {
      // A strange bug in dart web makes this crash
    }
    this.stateKey = stateKey;

    // Mark event as failed to send if status is `sending` and event is older
    // than the timeout. This should not happen with the deprecated Moor
    // database!
    if (status.isSending && room.client.database != null) {
      // Age of this event in milliseconds
      final age = DateTime.now().millisecondsSinceEpoch -
          originServerTs.millisecondsSinceEpoch;

      final room = this.room;
      if (age > room.client.sendMessageTimeoutSeconds * 1000) {
        // Update this event in database and open timelines
        final json = toJson();
        json['unsigned'] ??= <String, dynamic>{};
        json['unsigned'][messageSendingStatusKey] = EventStatus.error.intValue;
        room.client.handleSync(
          SyncUpdate(
            nextBatch: '',
            rooms: RoomsUpdate(
              join: {
                room.id: JoinedRoomUpdate(
                  timeline: TimelineUpdate(
                    events: [MatrixEvent.fromJson(json)],
                  ),
                )
              },
            ),
          ),
        );
      }
    }
  }

  static Map<String, dynamic> getMapFromPayload(dynamic payload) {
    if (payload is String) {
      try {
        return json.decode(payload);
      } catch (e) {
        return {};
      }
    }
    if (payload is Map<String, dynamic>) return payload;
    return {};
  }

  factory Event.fromMatrixEvent(
    MatrixEvent matrixEvent,
    Room room, {
    EventStatus status = defaultStatus,
  }) =>
      Event(
        status: status,
        content: matrixEvent.content,
        type: matrixEvent.type,
        eventId: matrixEvent.eventId,
        senderId: matrixEvent.senderId,
        originServerTs: matrixEvent.originServerTs,
        unsigned: matrixEvent.unsigned,
        prevContent: matrixEvent.prevContent,
        stateKey: matrixEvent.stateKey,
        room: room,
      );

  /// Get a State event from a table row or from the event stream.
  factory Event.fromJson(
    Map<String, dynamic> jsonPayload,
    Room room,
  ) {
    final content = Event.getMapFromPayload(jsonPayload['content']);
    final unsigned = Event.getMapFromPayload(jsonPayload['unsigned']);
    final prevContent = Event.getMapFromPayload(jsonPayload['prev_content']);
    return Event(
      status: eventStatusFromInt(jsonPayload['status'] ??
          unsigned[messageSendingStatusKey] ??
          defaultStatus.intValue),
      stateKey: jsonPayload['state_key'],
      prevContent: prevContent,
      content: content,
      type: jsonPayload['type'],
      eventId: jsonPayload['event_id'] ?? '',
      senderId: jsonPayload['sender'],
      originServerTs: jsonPayload.containsKey('origin_server_ts')
          ? DateTime.fromMillisecondsSinceEpoch(jsonPayload['origin_server_ts'])
          : DateTime.now(),
      unsigned: unsigned,
      room: room,
    );
  }

  @override
  Map<String, dynamic> toJson() {
    final data = <String, dynamic>{};
    if (stateKey != null) data['state_key'] = stateKey;
    if (prevContent?.isNotEmpty == true) {
      data['prev_content'] = prevContent;
    }
    data['content'] = content;
    data['type'] = type;
    data['event_id'] = eventId;
    data['room_id'] = roomId;
    data['sender'] = senderId;
    data['origin_server_ts'] = originServerTs.millisecondsSinceEpoch;
    if (unsigned?.isNotEmpty == true) {
      data['unsigned'] = unsigned;
    }
    return data;
  }

  User get asUser => User.fromState(
      // state key should always be set for member events
      stateKey: stateKey!,
      prevContent: prevContent,
      content: content,
      typeKey: type,
      eventId: eventId,
      roomId: roomId,
      senderId: senderId,
      originServerTs: originServerTs,
      unsigned: unsigned,
      room: room);

  String get messageType => type == EventTypes.Sticker
      ? MessageTypes.Sticker
      : (content['msgtype'] is String ? content['msgtype'] : MessageTypes.Text);

  void setRedactionEvent(Event redactedBecause) {
    unsigned = {
      'redacted_because': redactedBecause.toJson(),
    };
    prevContent = null;
    final contentKeyWhiteList = <String>[];
    switch (type) {
      case EventTypes.RoomMember:
        contentKeyWhiteList.add('membership');
        break;
      case EventTypes.RoomCreate:
        contentKeyWhiteList.add('creator');
        break;
      case EventTypes.RoomJoinRules:
        contentKeyWhiteList.add('join_rule');
        break;
      case EventTypes.RoomPowerLevels:
        contentKeyWhiteList.add('ban');
        contentKeyWhiteList.add('events');
        contentKeyWhiteList.add('events_default');
        contentKeyWhiteList.add('kick');
        contentKeyWhiteList.add('redact');
        contentKeyWhiteList.add('state_default');
        contentKeyWhiteList.add('users');
        contentKeyWhiteList.add('users_default');
        break;
      case EventTypes.RoomAliases:
        contentKeyWhiteList.add('aliases');
        break;
      case EventTypes.HistoryVisibility:
        contentKeyWhiteList.add('history_visibility');
        break;
      default:
        break;
    }
    content.removeWhere((k, v) => !contentKeyWhiteList.contains(k));
  }

  /// Returns the body of this event if it has a body.
  String get text => content['body'] is String ? content['body'] : '';

  /// Returns the formatted boy of this event if it has a formatted body.
  String get formattedText =>
      content['formatted_body'] is String ? content['formatted_body'] : '';

  /// Use this to get the body.
  String get body {
    if (redacted) return 'Redacted';
    if (text != '') return text;
    if (formattedText != '') return formattedText;
    return '$type';
  }

  /// Use this to get a plain-text representation of the event, stripping things
  /// like spoilers and thelike. Useful for plain text notifications.
  String get plaintextBody => content['format'] == 'org.matrix.custom.html'
      ? HtmlToText.convert(formattedText)
      : body;

  /// Returns a list of [Receipt] instances for this event.
  List<Receipt> get receipts {
    final room = this.room;
    final receipt = room.roomAccountData['m.receipt'];
    if (receipt == null) return [];
    return receipt.content.entries
        .where((entry) => entry.value['event_id'] == eventId)
        .map((entry) => Receipt(room.getUserByMXIDSync(entry.key),
            DateTime.fromMillisecondsSinceEpoch(entry.value['ts'])))
        .toList();
  }

  /// Removes this event if the status is [sending], [error] or [removed].
  /// This event will just be removed from the database and the timelines.
  /// Returns [false] if not removed.
  Future<bool> remove() async {
    final room = this.room;

    if (!status.isSent) {
      await room.client.database?.removeEvent(eventId, room.id);

      room.client.onEvent.add(EventUpdate(
        roomID: room.id,
        type: EventUpdateType.timeline,
        content: {
          'event_id': eventId,
          'status': EventStatus.removed.intValue,
          'content': {'body': 'Removed...'}
        },
      ));
      return true;
    }
    return false;
  }

  /// Try to send this event again. Only works with events of status -1.
  Future<String?> sendAgain({String? txid}) async {
    if (!status.isError) return null;
    // we do not remove the event here. It will automatically be updated
    // in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
    final newEventId = await room.sendEvent(
      content,
      txid: txid ?? unsigned?['transaction_id'] ?? eventId,
    );
    return newEventId;
  }

  /// Whether the client is allowed to redact this event.
  bool get canRedact => senderId == room.client.userID || room.canRedact;

  /// Redacts this event. Throws `ErrorResponse` on error.
  Future<String?> redactEvent({String? reason, String? txid}) async =>
      await room.redactEvent(eventId, reason: reason, txid: txid);

  /// Searches for the reply event in the given timeline.
  Future<Event?> getReplyEvent(Timeline timeline) async {
    if (relationshipType != RelationshipTypes.reply) return null;
    final relationshipEventId = this.relationshipEventId;
    return relationshipEventId == null
        ? null
        : await timeline.getEventById(relationshipEventId);
  }

  /// If this event is encrypted and the decryption was not successful because
  /// the session is unknown, this requests the session key from other devices
  /// in the room. If the event is not encrypted or the decryption failed because
  /// of a different error, this throws an exception.
  Future<void> requestKey() async {
    if (type != EventTypes.Encrypted ||
        messageType != MessageTypes.BadEncrypted ||
        content['can_request_session'] != true) {
      throw ('Session key not requestable');
    }
    await room.requestSessionKey(content['session_id'], content['sender_key']);
    return;
  }

  /// Gets the info map of file events, or a blank map if none present
  Map get infoMap =>
      content['info'] is Map ? content['info'] : <String, dynamic>{};

  /// Gets the thumbnail info map of file events, or a blank map if nonepresent
  Map get thumbnailInfoMap => infoMap['thumbnail_info'] is Map
      ? infoMap['thumbnail_info']
      : <String, dynamic>{};

  /// Returns if a file event has an attachment
  bool get hasAttachment => content['url'] is String || content['file'] is Map;

  /// Returns if a file event has a thumbnail
  bool get hasThumbnail =>
      infoMap['thumbnail_url'] is String || infoMap['thumbnail_file'] is Map;

  /// Returns if a file events attachment is encrypted
  bool get isAttachmentEncrypted => content['file'] is Map;

  /// Returns if a file events thumbnail is encrypted
  bool get isThumbnailEncrypted => infoMap['thumbnail_file'] is Map;

  /// Gets the mimetype of the attachment of a file event, or a blank string if not present
  String get attachmentMimetype => infoMap['mimetype'] is String
      ? infoMap['mimetype'].toLowerCase()
      : (content['file'] is Map && content['file']['mimetype'] is String
          ? content['file']['mimetype']
          : '');

  /// Gets the mimetype of the thumbnail of a file event, or a blank string if not present
  String get thumbnailMimetype => thumbnailInfoMap['mimetype'] is String
      ? thumbnailInfoMap['mimetype'].toLowerCase()
      : (infoMap['thumbnail_file'] is Map &&
              infoMap['thumbnail_file']['mimetype'] is String
          ? infoMap['thumbnail_file']['mimetype']
          : '');

  /// Gets the underlying mxc url of an attachment of a file event, or null if not present
  Uri? get attachmentMxcUrl {
    final url = isAttachmentEncrypted ? content['file']['url'] : content['url'];
    return url is String ? Uri.tryParse(url) : null;
  }

  /// Gets the underlying mxc url of a thumbnail of a file event, or null if not present
  Uri? get thumbnailMxcUrl {
    final url = isThumbnailEncrypted
        ? infoMap['thumbnail_file']['url']
        : infoMap['thumbnail_url'];
    return url is String ? Uri.tryParse(url) : null;
  }

  /// Gets the mxc url of an attachment/thumbnail of a file event, taking sizes into account, or null if not present
  Uri? attachmentOrThumbnailMxcUrl({bool getThumbnail = false}) {
    if (getThumbnail &&
        infoMap['size'] is int &&
        thumbnailInfoMap['size'] is int &&
        infoMap['size'] <= thumbnailInfoMap['size']) {
      getThumbnail = false;
    }
    if (getThumbnail && !hasThumbnail) {
      getThumbnail = false;
    }
    return getThumbnail ? thumbnailMxcUrl : attachmentMxcUrl;
  }

  // size determined from an approximate 800x800 jpeg thumbnail with method=scale
  static const _minNoThumbSize = 80 * 1024;

  /// Gets the attachment https URL to display in the timeline, taking into account if the original image is tiny.
  /// Returns null for encrypted rooms, if the image can't be fetched via http url or if the event does not contain an attachment.
  /// Set [getThumbnail] to true to fetch the thumbnail, set [width], [height] and [method]
  /// for the respective thumbnailing properties.
  /// [minNoThumbSize] is the minimum size that an original image may be to not fetch its thumbnail, defaults to 80k
  /// [useThumbnailMxcUrl] says weather to use the mxc url of the thumbnail, rather than the original attachment.
  ///  [animated] says weather the thumbnail is animated
  Uri? getAttachmentUrl(
      {bool getThumbnail = false,
      bool useThumbnailMxcUrl = false,
      double width = 800.0,
      double height = 800.0,
      ThumbnailMethod method = ThumbnailMethod.scale,
      int minNoThumbSize = _minNoThumbSize,
      bool animated = false}) {
    if (![EventTypes.Message, EventTypes.Sticker].contains(type) ||
        !hasAttachment ||
        isAttachmentEncrypted) {
      return null; // can't url-thumbnail in encrypted rooms
    }
    if (useThumbnailMxcUrl && !hasThumbnail) {
      return null; // can't fetch from thumbnail
    }
    final thisInfoMap = useThumbnailMxcUrl ? thumbnailInfoMap : infoMap;
    final thisMxcUrl =
        useThumbnailMxcUrl ? infoMap['thumbnail_url'] : content['url'];
    // if we have as method scale, we can return safely the original image, should it be small enough
    if (getThumbnail &&
        method == ThumbnailMethod.scale &&
        thisInfoMap['size'] is int &&
        thisInfoMap['size'] < minNoThumbSize) {
      getThumbnail = false;
    }
    // now generate the actual URLs
    if (getThumbnail) {
      return Uri.parse(thisMxcUrl).getThumbnail(
        room.client,
        width: width,
        height: height,
        method: method,
        animated: animated,
      );
    } else {
      return Uri.parse(thisMxcUrl).getDownloadLink(room.client);
    }
  }

  /// Returns if an attachment is in the local store
  Future<bool> isAttachmentInLocalStore({bool getThumbnail = false}) async {
    if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
      throw ("This event has the type '$type' and so it can't contain an attachment.");
    }
    final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
    if (mxcUrl == null) {
      throw "This event hasn't any attachment or thumbnail.";
    }
    getThumbnail = mxcUrl != attachmentMxcUrl;
    // Is this file storeable?
    final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
    final database = room.client.database;
    if (database == null) {
      return false;
    }

    final storeable = thisInfoMap['size'] is int &&
        thisInfoMap['size'] <= database.maxFileSize;

    Uint8List? uint8list;
    if (storeable) {
      uint8list = await database.getFile(mxcUrl);
    }
    return uint8list != null;
  }

  /// Downloads (and decrypts if necessary) the attachment of this
  /// event and returns it as a [MatrixFile]. If this event doesn't
  /// contain an attachment, this throws an error. Set [getThumbnail] to
  /// true to download the thumbnail instead.
  Future<MatrixFile> downloadAndDecryptAttachment(
      {bool getThumbnail = false,
      Future<Uint8List> Function(Uri)? downloadCallback}) async {
    if (![EventTypes.Message, EventTypes.Sticker].contains(type)) {
      throw ("This event has the type '$type' and so it can't contain an attachment.");
    }
    final database = room.client.database;
    final mxcUrl = attachmentOrThumbnailMxcUrl(getThumbnail: getThumbnail);
    if (mxcUrl == null) {
      throw "This event hasn't any attachment or thumbnail.";
    }
    getThumbnail = mxcUrl != attachmentMxcUrl;
    final isEncrypted =
        getThumbnail ? isThumbnailEncrypted : isAttachmentEncrypted;
    if (isEncrypted && !room.client.encryptionEnabled) {
      throw ('Encryption is not enabled in your Client.');
    }

    // Is this file storeable?
    final thisInfoMap = getThumbnail ? thumbnailInfoMap : infoMap;
    var storeable = database != null &&
        thisInfoMap['size'] is int &&
        thisInfoMap['size'] <= database.maxFileSize;

    Uint8List? uint8list;
    if (storeable) {
      uint8list = await room.client.database?.getFile(mxcUrl);
    }

    // Download the file
    if (uint8list == null) {
      downloadCallback ??= (Uri url) async => (await http.get(url)).bodyBytes;
      uint8list = await downloadCallback(mxcUrl.getDownloadLink(room.client));
      storeable = database != null &&
          storeable &&
          uint8list.lengthInBytes < database.maxFileSize;
      if (storeable) {
        await database.storeFile(
            mxcUrl, uint8list, DateTime.now().millisecondsSinceEpoch);
      }
    }

    // Decrypt the file
    if (isEncrypted) {
      final fileMap =
          getThumbnail ? infoMap['thumbnail_file'] : content['file'];
      if (!fileMap['key']['key_ops'].contains('decrypt')) {
        throw ("Missing 'decrypt' in 'key_ops'.");
      }
      final encryptedFile = EncryptedFile(
        data: uint8list,
        iv: fileMap['iv'],
        k: fileMap['key']['k'],
        sha256: fileMap['hashes']['sha256'],
      );
      uint8list = await room.client.runInBackground<Uint8List?, EncryptedFile>(
          decryptFile, encryptedFile);
      if (uint8list == null) {
        throw ('Unable to decrypt file');
      }
    }
    return MatrixFile(bytes: uint8list, name: body);
  }

  /// Returns if this is a known event type.
  bool get isEventTypeKnown =>
      EventLocalizations.localizationsMap.containsKey(type);

  /// Returns a localized String representation of this event. For a
  /// room list you may find [withSenderNamePrefix] useful. Set [hideReply] to
  /// crop all lines starting with '>'. With [plaintextBody] it'll use the
  /// plaintextBody instead of the normal body.
  String getLocalizedBody(
    MatrixLocalizations i18n, {
    bool withSenderNamePrefix = false,
    bool hideReply = false,
    bool hideEdit = false,
    bool plaintextBody = false,
  }) {
    if (redacted) {
      return i18n.removedBy(redactedBecause?.sender.calcDisplayname() ?? '');
    }
    var body = plaintextBody ? this.plaintextBody : this.body;

    // we need to know if the message is an html message to be able to determine
    // if we need to strip the reply fallback.
    var htmlMessage = content['format'] != 'org.matrix.custom.html';
    // If we have an edit, we want to operate on the new content
    if (hideEdit &&
        relationshipType == RelationshipTypes.edit &&
        content.tryGet<Map<String, dynamic>>('m.new_content') != null) {
      if (plaintextBody &&
          content['m.new_content']['format'] == 'org.matrix.custom.html') {
        htmlMessage = true;
        body = HtmlToText.convert(
            (content['m.new_content'] as Map<String, dynamic>)
                    .tryGet<String>('formatted_body') ??
                formattedText);
      } else {
        htmlMessage = false;
        body = (content['m.new_content'] as Map<String, dynamic>)
                .tryGet<String>('body') ??
            body;
      }
    }
    // Hide reply fallback
    // Be sure that the plaintextBody already stripped teh reply fallback,
    // if the message is formatted
    if (hideReply && (!plaintextBody || htmlMessage)) {
      body = body.replaceFirst(
          RegExp(r'^>( \*)? <[^>]+>[^\n\r]+\r?\n(> [^\n]*\r?\n)*\r?\n'), '');
    }
    final callback = EventLocalizations.localizationsMap[type];
    var localizedBody = i18n.unknownEvent(type);
    if (callback != null) {
      localizedBody = callback(this, i18n, body);
    }

    // Add the sender name prefix
    if (withSenderNamePrefix &&
        type == EventTypes.Message &&
        textOnlyMessageTypes.contains(messageType)) {
      final senderNameOrYou = senderId == room.client.userID
          ? i18n.you
          : (sender.calcDisplayname());
      localizedBody = '$senderNameOrYou: $localizedBody';
    }

    return localizedBody;
  }

  static const Set<String> textOnlyMessageTypes = {
    MessageTypes.Text,
    MessageTypes.Notice,
    MessageTypes.Emote,
    MessageTypes.None,
  };

  /// returns if this event matches the passed event or transaction id
  bool matchesEventOrTransactionId(String? search) {
    if (search == null) {
      return false;
    }
    if (eventId == search) {
      return true;
    }
    return unsigned?['transaction_id'] == search;
  }

  /// Get the relationship type of an event. `null` if there is none
  String? get relationshipType {
    if (content.tryGet<Map<String, dynamic>>('m.relates_to') == null) {
      return null;
    }
    if (content['m.relates_to'].containsKey('m.in_reply_to')) {
      return RelationshipTypes.reply;
    }
    return content
        .tryGet<Map<String, dynamic>>('m.relates_to')
        ?.tryGet<String>('rel_type');
  }

  /// Get the event ID that this relationship will reference. `null` if there is none
  String? get relationshipEventId {
    if (!(content['m.relates_to'] is Map)) {
      return null;
    }
    if (content['m.relates_to'].containsKey('event_id')) {
      return content['m.relates_to']['event_id'];
    }
    if (content['m.relates_to']['m.in_reply_to'] is Map &&
        content['m.relates_to']['m.in_reply_to'].containsKey('event_id')) {
      return content['m.relates_to']['m.in_reply_to']['event_id'];
    }
    return null;
  }

  /// Get whether this event has aggregated events from a certain [type]
  /// To be able to do that you need to pass a [timeline]
  bool hasAggregatedEvents(Timeline timeline, String type) =>
      timeline.aggregatedEvents[eventId]?.containsKey(type) == true;

  /// Get all the aggregated event objects for a given [type]. To be able to do this
  /// you have to pass a [timeline]
  Set<Event> aggregatedEvents(Timeline timeline, String type) =>
      timeline.aggregatedEvents[eventId]?[type] ?? <Event>{};

  /// Fetches the event to be rendered, taking into account all the edits and the like.
  /// It needs a [timeline] for that.
  Event getDisplayEvent(Timeline timeline) {
    if (redacted) {
      return this;
    }
    if (hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
      // alright, we have an edit
      final allEditEvents = aggregatedEvents(timeline, RelationshipTypes.edit)
          // we only allow edits made by the original author themself
          .where((e) => e.senderId == senderId && e.type == EventTypes.Message)
          .toList();
      // we need to check again if it isn't empty, as we potentially removed all
      // aggregated edits
      if (allEditEvents.isNotEmpty) {
        allEditEvents.sort((a, b) => a.originServerTs.millisecondsSinceEpoch -
                    b.originServerTs.millisecondsSinceEpoch >
                0
            ? 1
            : -1);
        final rawEvent = allEditEvents.last.toJson();
        // update the content of the new event to render
        if (rawEvent['content']['m.new_content'] is Map) {
          rawEvent['content'] = rawEvent['content']['m.new_content'];
        }
        return Event.fromJson(rawEvent, room);
      }
    }
    return this;
  }

  /// returns if a message is a rich message
  bool get isRichMessage =>
      content['format'] == 'org.matrix.custom.html' &&
      content['formatted_body'] is String;

  // regexes to fetch the number of emotes, including emoji, and if the message consists of only those
  // to match an emoji we can use the following regex:
  // (?:\x{00a9}|\x{00ae}|[\x{2600}-\x{27bf}]|[\x{2b00}-\x{2bff}]|\x{d83c}[\x{d000}-\x{dfff}]|\x{d83d}[\x{d000}-\x{dfff}]|\x{d83e}[\x{d000}-\x{dfff}])[\x{fe00}-\x{fe0f}]?
  // we need to replace \x{0000} with \u0000, the comment is left in the other format to be able to paste into regex101.com
  // to see if there is a custom emote, we use the following regex: <img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>
  // now we combind the two to have four regexes:
  // 1. are there only emoji, or whitespace
  // 2. are there only emoji, emotes, or whitespace
  // 3. count number of emoji
  // 4- count number of emoji or emotes
  static final RegExp _onlyEmojiRegex = RegExp(
      r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|\s)*$',
      caseSensitive: false,
      multiLine: false);
  static final RegExp _onlyEmojiEmoteRegex = RegExp(
      r'^((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>|\s)*$',
      caseSensitive: false,
      multiLine: false);
  static final RegExp _countEmojiRegex = RegExp(
      r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?)',
      caseSensitive: false,
      multiLine: false);
  static final RegExp _countEmojiEmoteRegex = RegExp(
      r'((?:\u00a9|\u00ae|[\u2600-\u27bf]|[\u2b00-\u2bff]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])[\ufe00-\ufe0f]?|<img[^>]+data-mx-(?:emote|emoticon)(?==|>|\s)[^>]*>)',
      caseSensitive: false,
      multiLine: false);

  /// Returns if a given event only has emotes, emojis or whitespace as content.
  /// If the body contains a reply then it is stripped.
  /// This is useful to determine if stand-alone emotes should be displayed bigger.
  bool get onlyEmotes {
    if (isRichMessage) {
      final formattedTextStripped = formattedText.replaceAll(
          RegExp('<mx-reply>.*<\/mx-reply>',
              caseSensitive: false, multiLine: false, dotAll: true),
          '');
      return _onlyEmojiEmoteRegex.hasMatch(formattedTextStripped);
    } else {
      return _onlyEmojiRegex.hasMatch(plaintextBody);
    }
  }

  /// Gets the number of emotes in a given message. This is useful to determine
  /// if the emotes should be displayed bigger.
  /// If the body contains a reply then it is stripped.
  /// WARNING: This does **not** test if there are only emotes. Use `event.onlyEmotes` for that!
  int get numberEmotes {
    if (isRichMessage) {
      final formattedTextStripped = formattedText.replaceAll(
          RegExp('<mx-reply>.*<\/mx-reply>',
              caseSensitive: false, multiLine: false, dotAll: true),
          '');
      return _countEmojiEmoteRegex.allMatches(formattedTextStripped).length;
    } else {
      return _countEmojiRegex.allMatches(plaintextBody).length;
    }
  }
}