/*
 *   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 .
 */
import 'dart:async';
import 'dart:convert';
import 'package:html_unescape/html_unescape.dart';
import 'package:matrix/src/utils/space_child.dart';
import '../matrix.dart';
import 'client.dart';
import 'event.dart';
import 'timeline.dart';
import 'user.dart';
import 'utils/crypto/encrypted_file.dart';
import 'utils/event_update.dart';
import 'utils/markdown.dart';
import 'utils/marked_unread.dart';
import 'utils/matrix_file.dart';
import 'utils/matrix_localizations.dart';
enum PushRuleState { notify, mentionsOnly, dontNotify }
enum JoinRules { public, knock, invite, private }
enum GuestAccess { canJoin, forbidden }
enum HistoryVisibility { invited, joined, shared, worldReadable }
const Map _guestAccessMap = {
  GuestAccess.canJoin: 'can_join',
  GuestAccess.forbidden: 'forbidden',
};
const Map _historyVisibilityMap = {
  HistoryVisibility.invited: 'invited',
  HistoryVisibility.joined: 'joined',
  HistoryVisibility.shared: 'shared',
  HistoryVisibility.worldReadable: 'world_readable',
};
const String messageSendingStatusKey =
    'com.famedly.famedlysdk.message_sending_status';
const String sortOrderKey = 'com.famedly.famedlysdk.sort_order';
/// Represents a Matrix room.
class Room {
  /// The full qualified Matrix ID for the room in the format '!localid:server.abc'.
  final String id;
  /// Membership status of the user for this room.
  Membership membership;
  /// The count of unread notifications.
  int notificationCount;
  /// The count of highlighted notifications.
  int highlightCount;
  /// A token that can be supplied to the from parameter of the rooms/{roomId}/messages endpoint.
  String prev_batch;
  RoomSummary summary;
  @deprecated
  List get mHeroes => summary.mHeroes;
  @deprecated
  int get mJoinedMemberCount => summary.mJoinedMemberCount;
  @deprecated
  int get mInvitedMemberCount => summary.mInvitedMemberCount;
  /// The room states are a key value store of the key (`type`,`state_key`) => State(event).
  /// In a lot of cases the `state_key` might be an empty string. You **should** use the
  /// methods `getState()` and `setState()` to interact with the room states.
  Map> states = {};
  /// Key-Value store for ephemerals.
  Map ephemerals = {};
  /// Key-Value store for private account data only visible for this user.
  Map roomAccountData = {};
  double _newestSortOrder;
  double _oldestSortOrder;
  Map toJson() => {
        'id': id,
        'membership': membership.toString().split('.').last,
        'highlight_count': highlightCount,
        'notification_count': notificationCount,
        'prev_batch': prev_batch,
        'summary': summary.toJson(),
        'newest_sort_order': 0,
        'oldest_sort_order': 0,
      };
  factory Room.fromJson(Map json, [Client client]) => Room(
        client: client,
        id: json['id'],
        membership: Membership.values.singleWhere(
          (m) => m.toString() == 'Membership.${json['membership']}',
          orElse: () => Membership.join,
        ),
        notificationCount: json['notification_count'],
        highlightCount: json['highlight_count'],
        prev_batch: json['prev_batch'],
        summary:
            RoomSummary.fromJson(Map.from(json['summary'])),
        newestSortOrder: json['newest_sort_order'].toDouble(),
        oldestSortOrder: json['oldest_sort_order'].toDouble(),
      );
  double get newSortOrder {
    var now = DateTime.now().millisecondsSinceEpoch.toDouble();
    if (_newestSortOrder >= now) {
      now = _newestSortOrder + 1;
    }
    _newestSortOrder = now;
    return _newestSortOrder;
  }
  double get oldSortOrder {
    _oldestSortOrder--;
    return _oldestSortOrder;
  }
  void resetSortOrder() {
    _oldestSortOrder = _newestSortOrder = 0.0;
  }
  Future updateSortOrder() async {
    await client.database?.updateRoomSortOrder(
        _oldestSortOrder, _newestSortOrder, client.id, id);
  }
  /// Flag if the room is partial, meaning not all state events have been loaded yet
  bool partial = true;
  /// Post-loads the room.
  /// This load all the missing state events for the room from the database
  /// If the room has already been loaded, this does nothing.
  Future postLoad() async {
    if (!partial || client.database == null) {
      return;
    }
    final allStates = await client.database
        .getUnimportantRoomEventStatesForRoom(
            client.id, client.importantStateEvents.toList(), this);
    for (final state in allStates) {
      setState(state);
    }
    partial = false;
  }
  /// Returns the [Event] for the given [typeKey] and optional [stateKey].
  /// If no [stateKey] is provided, it defaults to an empty string.
  Event getState(String typeKey, [String stateKey = '']) =>
      states[typeKey] != null ? states[typeKey][stateKey] : null;
  /// Adds the [state] to this room and overwrites a state with the same
  /// typeKey/stateKey key pair if there is one.
  void setState(Event state) {
    // Decrypt if necessary
    if (state.type == EventTypes.Encrypted && client.encryptionEnabled) {
      try {
        state = client.encryption.decryptRoomEventSync(id, state);
      } catch (e, s) {
        Logs().e('[LibOlm] Could not decrypt room state', e, s);
      }
    }
    if (!(state.stateKey is String) &&
            ![EventTypes.Message, EventTypes.Sticker, EventTypes.Encrypted]
                .contains(state.type) ||
        (state.type == EventTypes.Message &&
            state.messageType.startsWith('m.room.verification.'))) {
      return;
    }
    final oldStateEvent = getState(state.type, state.stateKey ?? '');
    if (oldStateEvent != null && oldStateEvent.sortOrder >= state.sortOrder) {
      return;
    }
    if (!states.containsKey(state.type)) {
      states[state.type] = {};
    }
    states[state.type][state.stateKey ?? ''] = state;
  }
  /// ID of the fully read marker event.
  String get fullyRead => roomAccountData['m.fully_read'] != null
      ? roomAccountData['m.fully_read'].content['event_id']
      : '';
  /// If something changes, this callback will be triggered. Will return the
  /// room id.
  final StreamController onUpdate = StreamController.broadcast();
  /// If there is a new session key received, this will be triggered with
  /// the session ID.
  final StreamController onSessionKeyReceived =
      StreamController.broadcast();
  /// The name of the room if set by a participant.
  String get name => getState(EventTypes.RoomName) != null &&
          getState(EventTypes.RoomName).content['name'] is String
      ? getState(EventTypes.RoomName).content['name']
      : '';
  /// The pinned events for this room. If there are none this returns an empty
  /// list.
  List get pinnedEventIds => getState(EventTypes.RoomPinnedEvents) !=
          null
      ? (getState(EventTypes.RoomPinnedEvents).content['pinned'] is List
          ? getState(EventTypes.RoomPinnedEvents).content['pinned']
          : [])
      : [];
  /// Returns a localized displayname for this server. If the room is a groupchat
  /// without a name, then it will return the localized version of 'Group with Alice' instead
  /// of just 'Alice' to make it different to a direct chat.
  /// Empty chats will become the localized version of 'Empty Chat'.
  /// This method requires a localization class which implements [MatrixLocalizations]
  String getLocalizedDisplayname(MatrixLocalizations i18n) {
    if ((name?.isEmpty ?? true) &&
        (canonicalAlias?.isEmpty ?? true) &&
        !isDirectChat &&
        (summary.mHeroes != null && summary.mHeroes.isNotEmpty)) {
      return i18n.groupWith(displayname);
    }
    if (displayname?.isNotEmpty ?? false) {
      return displayname;
    }
    return i18n.emptyChat;
  }
  /// The topic of the room if set by a participant.
  String get topic => getState(EventTypes.RoomTopic) != null &&
          getState(EventTypes.RoomTopic).content['topic'] is String
      ? getState(EventTypes.RoomTopic).content['topic']
      : '';
  /// The avatar of the room if set by a participant.
  Uri get avatar {
    if (getState(EventTypes.RoomAvatar) != null &&
        getState(EventTypes.RoomAvatar).content['url'] is String) {
      return Uri.tryParse(getState(EventTypes.RoomAvatar).content['url']);
    }
    if (summary.mHeroes != null &&
        summary.mHeroes.length == 1 &&
        getState(EventTypes.RoomMember, summary.mHeroes.first) != null) {
      return getState(EventTypes.RoomMember, summary.mHeroes.first)
          .asUser
          .avatarUrl;
    }
    if (membership == Membership.invite &&
        getState(EventTypes.RoomMember, client.userID) != null) {
      return getState(EventTypes.RoomMember, client.userID).sender.avatarUrl;
    }
    return null;
  }
  /// The address in the format: #roomname:homeserver.org.
  String get canonicalAlias =>
      getState(EventTypes.RoomCanonicalAlias) != null &&
              getState(EventTypes.RoomCanonicalAlias).content['alias'] is String
          ? getState(EventTypes.RoomCanonicalAlias).content['alias']
          : '';
  /// Sets the canonical alias. If the [canonicalAlias] is not yet an alias of
  /// this room, it will create one.
  Future setCanonicalAlias(String canonicalAlias) async {
    final aliases = await client.getLocalAliases(id);
    if (!aliases.contains(canonicalAlias)) {
      await client.setRoomAlias(canonicalAlias, id);
    }
    await client.setRoomStateWithKey(id, EventTypes.RoomCanonicalAlias, '', {
      'alias': canonicalAlias,
    });
  }
  /// If this room is a direct chat, this is the matrix ID of the user.
  /// Returns null otherwise.
  String get directChatMatrixID {
    String returnUserId;
    if (membership == Membership.invite) {
      final invitation = getState(EventTypes.RoomMember, client.userID);
      if (invitation != null && invitation.content['is_direct'] == true) {
        return invitation.senderId;
      }
    }
    if (client.directChats is Map) {
      client.directChats.forEach((String userId, dynamic roomIds) {
        if (roomIds is List && roomIds.contains(id)) {
          returnUserId = userId;
        }
      });
    }
    return returnUserId;
  }
  /// Wheither this is a direct chat or not
  bool get isDirectChat => directChatMatrixID != null;
  /// Must be one of [all, mention]
  String notificationSettings;
  Event get lastEvent {
    // as lastEvent calculation is based on the state events we unfortunately cannot
    // use sortOrder here: With many state events we just know which ones are the
    // newest ones, without knowing in which order they actually happened. As such,
    // using the origin_server_ts is the best guess for this algorithm. While not
    // perfect, it is only used for the room preview in the room list and sorting
    // said room list, so it should be good enough.
    var lastTime = DateTime.fromMillisecondsSinceEpoch(0);
    final lastEvents =
        client.roomPreviewLastEvents.map(getState).where((e) => e != null);
    var lastEvent = lastEvents.isEmpty
        ? null
        : lastEvents.reduce((a, b) {
            if (a.sortOrder == b.sortOrder) {
              // if two events have the same sort order we want to give encrypted events a lower priority
              // This is so that if the same event exists in the state both encrypted *and* unencrypted,
              // the unencrypted one is picked
              return a.type == EventTypes.Encrypted ? b : a;
            }
            return a.sortOrder > b.sortOrder ? a : b;
          });
    if (lastEvent == null) {
      states.forEach((final String key, final entry) {
        if (!entry.containsKey('')) return;
        final state = entry[''];
        if (state.originServerTs != null &&
            state.originServerTs.millisecondsSinceEpoch >
                lastTime.millisecondsSinceEpoch) {
          lastTime = state.originServerTs;
          lastEvent = state;
        }
      });
    }
    return lastEvent;
  }
  /// Returns a list of all current typing users.
  List get typingUsers {
    if (!ephemerals.containsKey('m.typing')) return [];
    final List typingMxid = ephemerals['m.typing'].content['user_ids'];
    return typingMxid.cast().map(getUserByMXIDSync).toList();
  }
  /// Your current client instance.
  final Client client;
  Room({
    this.id,
    this.membership = Membership.join,
    int notificationCount,
    int highlightCount,
    String prev_batch,
    this.client,
    this.notificationSettings,
    Map roomAccountData,
    double newestSortOrder = 0.0,
    double oldestSortOrder = 0.0,
    RoomSummary summary,
  })  : _newestSortOrder = newestSortOrder,
        _oldestSortOrder = oldestSortOrder,
        notificationCount = notificationCount ?? 0,
        highlightCount = highlightCount ?? 0,
        prev_batch = prev_batch ?? '',
        roomAccountData = roomAccountData ?? {},
        summary = summary ??
            RoomSummary.fromJson({
              'm.joined_member_count': 0,
              'm.invited_member_count': 0,
              'm.heroes': [],
            });
  /// The default count of how much events should be requested when requesting the
  /// history of this room.
  static const int defaultHistoryCount = 100;
  /// Calculates the displayname. First checks if there is a name, then checks for a canonical alias and
  /// then generates a name from the heroes.
  String get displayname {
    if (name != null && name.isNotEmpty) return name;
    if (canonicalAlias != null &&
        canonicalAlias.isNotEmpty &&
        canonicalAlias.length > 3) {
      return canonicalAlias.localpart;
    }
    var heroes = [];
    if (summary.mHeroes != null &&
        summary.mHeroes.isNotEmpty &&
        summary.mHeroes.any((h) => h.isNotEmpty)) {
      heroes = summary.mHeroes;
    } else {
      if (states[EventTypes.RoomMember] is Map) {
        for (final entry in states[EventTypes.RoomMember].entries) {
          final state = entry.value;
          if (state.type == EventTypes.RoomMember &&
              state.stateKey != client?.userID) heroes.add(state.stateKey);
        }
      }
    }
    if (heroes.isNotEmpty) {
      return heroes
          .where((hero) => hero.isNotEmpty)
          .map((hero) => getUserByMXIDSync(hero).calcDisplayname())
          .join(', ');
    }
    if (membership == Membership.invite &&
        getState(EventTypes.RoomMember, client.userID) != null) {
      return getState(EventTypes.RoomMember, client.userID)
          .sender
          .calcDisplayname();
    }
    return 'Empty chat';
  }
  @Deprecated('Use [lastEvent.body] instead')
  String get lastMessage {
    if (lastEvent != null) {
      return lastEvent.body;
    } else {
      return '';
    }
  }
  /// When the last message received.
  DateTime get timeCreated {
    if (lastEvent != null) {
      return lastEvent.originServerTs;
    }
    return DateTime.now();
  }
  /// Call the Matrix API to change the name of this room. Returns the event ID of the
  /// new m.room.name event.
  Future setName(String newName) => client.setRoomStateWithKey(
        id,
        EventTypes.RoomName,
        '',
        {'name': newName},
      );
  /// Call the Matrix API to change the topic of this room.
  Future setDescription(String newName) => client.setRoomStateWithKey(
        id,
        EventTypes.RoomTopic,
        '',
        {'topic': newName},
      );
  /// Add a tag to the room.
  Future addTag(String tag, {double order}) => client.setRoomTag(
        client.userID,
        id,
        tag,
        order: order,
      );
  /// Removes a tag from the room.
  Future removeTag(String tag) => client.deleteRoomTag(
        client.userID,
        id,
        tag,
      );
  // Tag is part of client-to-server-API, so it uses strict parsing.
  // For roomAccountData, permissive parsing is more suitable,
  // so it is implemented here.
  static Tag _tryTagFromJson(Object o) {
    if (o is Map) {
      return Tag(
          order: o.tryGet('order').toDouble(),
          additionalProperties: Map.from(o)..remove('order'));
    }
    return Tag();
  }
  /// Returns all tags for this room.
  Map get tags {
    if (roomAccountData['m.tag'] == null ||
        !(roomAccountData['m.tag'].content['tags'] is Map)) {
      return {};
    }
    final tags = (roomAccountData['m.tag'].content['tags'] as Map)
        .map((k, v) => MapEntry(k, _tryTagFromJson(v)));
    tags.removeWhere((k, v) => !TagType.isValid(k));
    return tags;
  }
  bool get markedUnread {
    return MarkedUnread.fromJson(
            roomAccountData[EventType.markedUnread]?.content ?? {})
        .unread;
  }
  /// Returns true if this room is unread
  bool get isUnread => notificationCount > 0 || markedUnread;
  /// Sets an unread flag manually for this room. Similar to the setUnreadMarker
  /// this changes the local account data model before syncing it to make sure
  /// this works if there is no connection to the homeserver.
  Future setUnread(bool unread) async {
    final content = MarkedUnread(unread).toJson();
    await _handleFakeSync(SyncUpdate(nextBatch: '')
      ..rooms = (RoomsUpdate()
        ..join = (({}..[id] = (JoinedRoomUpdate()
          ..accountData = [
            BasicRoomEvent(
              content: content,
              roomId: id,
              type: EventType.markedUnread,
            )
          ])))));
    await client.setAccountDataPerRoom(
      client.userID,
      id,
      EventType.markedUnread,
      content,
    );
    if (unread == false && lastEvent != null) {
      await setReadMarker(
        lastEvent.eventId,
        mRead: lastEvent.eventId,
      );
    }
  }
  /// Returns true if this room has a m.favourite tag.
  bool get isFavourite => tags[TagType.favourite] != null;
  /// Sets the m.favourite tag for this room.
  Future setFavourite(bool favourite) =>
      favourite ? addTag(TagType.favourite) : removeTag(TagType.favourite);
  /// Call the Matrix API to change the pinned events of this room.
  Future setPinnedEvents(List pinnedEventIds) =>
      client.setRoomStateWithKey(
        id,
        EventTypes.RoomPinnedEvents,
        '',
        {'pinned': pinnedEventIds},
      );
  /// return all current emote packs for this room
  @deprecated
  Map> get emotePacks =>
      getImagePacksFlat(ImagePackUsage.emoticon);
  /// Sends a normal text message to this room. Returns the event ID generated
  /// by the server for this message.
  Future sendTextEvent(String message,
      {String txid,
      Event inReplyTo,
      String editEventId,
      bool parseMarkdown = true,
      @deprecated Map> emotePacks,
      bool parseCommands = true,
      String msgtype = MessageTypes.Text}) {
    if (parseCommands) {
      return client.parseAndRunCommand(this, message,
          inReplyTo: inReplyTo, editEventId: editEventId, txid: txid);
    }
    final event = {
      'msgtype': msgtype,
      'body': message,
    };
    if (parseMarkdown) {
      final mentionMap = {
        for (final user in getParticipants()) user.mention: user.id
      };
      final html = markdown(event['body'],
          emotePacks: getImagePacksFlat(ImagePackUsage.emoticon),
          mentionMap: mentionMap);
      // if the decoded html is the same as the body, there is no need in sending a formatted message
      if (HtmlUnescape().convert(html.replaceAll(RegExp(r'
\n?'), '\n')) !=
          event['body']) {
        event['format'] = 'org.matrix.custom.html';
        event['formatted_body'] = html;
      }
    }
    return sendEvent(event,
        txid: txid, inReplyTo: inReplyTo, editEventId: editEventId);
  }
  /// Sends a reaction to an event with an [eventId] and the content [key] into a room.
  /// Returns the event ID generated by the server for this reaction.
  Future sendReaction(String eventId, String key, {String txid}) {
    return sendEvent({
      'm.relates_to': {
        'rel_type': RelationshipTypes.reaction,
        'event_id': eventId,
        'key': key,
      },
    }, type: EventTypes.Reaction, txid: txid);
  }
  /// Sends the location with description [body] and geo URI [geoUri] into a room.
  /// Returns the event ID generated by the server for this message.
  Future sendLocation(String body, String geoUri, {String txid}) {
    final event = {
      'msgtype': 'm.location',
      'body': body,
      'geo_uri': geoUri,
    };
    return sendEvent(event, txid: txid);
  }
  /// Sends a [file] to this room after uploading it. Returns the mxc uri of
  /// the uploaded file. If [waitUntilSent] is true, the future will wait until
  /// the message event has received the server. Otherwise the future will only
  /// wait until the file has been uploaded.
  Future sendFileEvent(
    MatrixFile file, {
    String txid,
    Event inReplyTo,
    String editEventId,
    bool waitUntilSent = false,
    MatrixImageFile thumbnail,
  }) async {
    MatrixFile uploadFile = file; // ignore: omit_local_variable_types
    MatrixFile uploadThumbnail = thumbnail; // ignore: omit_local_variable_types
    EncryptedFile encryptedFile;
    EncryptedFile encryptedThumbnail;
    if (encrypted && client.fileEncryptionEnabled) {
      encryptedFile = await file.encrypt();
      uploadFile = encryptedFile.toMatrixFile();
      if (thumbnail != null) {
        encryptedThumbnail = await thumbnail.encrypt();
        uploadThumbnail = encryptedThumbnail.toMatrixFile();
      }
    }
    final uploadResp = await client.uploadContent(
      uploadFile.bytes,
      filename: uploadFile.name,
      contentType: uploadFile.mimeType,
    );
    final thumbnailUploadResp = uploadThumbnail != null
        ? await client.uploadContent(
            uploadThumbnail.bytes,
            filename: uploadThumbnail.name,
            contentType: uploadThumbnail.mimeType,
          )
        : null;
    // Send event
    final content = {
      'msgtype': file.msgType,
      'body': file.name,
      'filename': file.name,
      if (encryptedFile == null) 'url': uploadResp,
      if (encryptedFile != null)
        'file': {
          'url': uploadResp,
          'mimetype': file.mimeType,
          'v': 'v2',
          'key': {
            'alg': 'A256CTR',
            'ext': true,
            'k': encryptedFile.k,
            'key_ops': ['encrypt', 'decrypt'],
            'kty': 'oct'
          },
          'iv': encryptedFile.iv,
          'hashes': {'sha256': encryptedFile.sha256}
        },
      'info': {
        ...file.info,
        if (thumbnail != null && encryptedThumbnail == null)
          'thumbnail_url': thumbnailUploadResp,
        if (thumbnail != null && encryptedThumbnail != null)
          'thumbnail_file': {
            'url': thumbnailUploadResp,
            'mimetype': thumbnail.mimeType,
            'v': 'v2',
            'key': {
              'alg': 'A256CTR',
              'ext': true,
              'k': encryptedThumbnail.k,
              'key_ops': ['encrypt', 'decrypt'],
              'kty': 'oct'
            },
            'iv': encryptedThumbnail.iv,
            'hashes': {'sha256': encryptedThumbnail.sha256}
          },
        if (thumbnail != null) 'thumbnail_info': thumbnail.info,
      }
    };
    final sendResponse = sendEvent(
      content,
      txid: txid,
      inReplyTo: inReplyTo,
      editEventId: editEventId,
    );
    if (waitUntilSent) {
      await sendResponse;
    }
    return uploadResp;
  }
  Future _sendContent(
    String type,
    Map content, {
    String txid,
  }) async {
    txid ??= client.generateUniqueTransactionId();
    final mustEncrypt = encrypted && client.encryptionEnabled;
    final sendMessageContent = mustEncrypt
        ? await client.encryption
            .encryptGroupMessagePayload(id, content, type: type)
        : content;
    return await client.sendMessage(
      id,
      mustEncrypt ? EventTypes.Encrypted : type,
      txid,
      sendMessageContent,
    );
  }
  /// Sends an event to this room with this json as a content. Returns the
  /// event ID generated from the server.
  Future sendEvent(
    Map content, {
    String type,
    String txid,
    Event inReplyTo,
    String editEventId,
  }) async {
    type = type ?? EventTypes.Message;
    // Create new transaction id
    String messageID;
    if (txid == null) {
      messageID = client.generateUniqueTransactionId();
    } else {
      messageID = txid;
    }
    if (inReplyTo != null) {
      var replyText = '<${inReplyTo.senderId}> ' + inReplyTo.body;
      replyText = replyText.split('\n').map((line) => '> $line').join('\n');
      content['format'] = 'org.matrix.custom.html';
      // be sure that we strip any previous reply fallbacks
      final replyHtml = (inReplyTo.formattedText.isNotEmpty
              ? inReplyTo.formattedText
              : htmlEscape.convert(inReplyTo.body).replaceAll('\n', '
'))
          .replaceAll(
              RegExp(r'.*<\/mx-reply>',
                  caseSensitive: false, multiLine: false, dotAll: true),
              '');
      final repliedHtml = content.tryGet('formatted_body') ??
          htmlEscape
              .convert(content.tryGet('body') ?? '')
              .replaceAll('\n', '
');
      content['formatted_body'] =
          'In reply to ${inReplyTo.senderId}
$replyHtml
$repliedHtml';
      // We escape all @room-mentions here to prevent accidental room pings when an admin
      // replies to a message containing that!
      content['body'] =
          '${replyText.replaceAll('@room', '@\u200broom')}\n\n${content.tryGet('body') ?? ''}';
      content['m.relates_to'] = {
        'm.in_reply_to': {
          'event_id': inReplyTo.eventId,
        },
      };
    }
    if (editEventId != null) {
      final newContent = content.copy();
      content['m.new_content'] = newContent;
      content['m.relates_to'] = {
        'event_id': editEventId,
        'rel_type': RelationshipTypes.edit,
      };
      if (content['body'] is String) {
        content['body'] = '* ' + content['body'];
      }
      if (content['formatted_body'] is String) {
        content['formatted_body'] = '* ' + content['formatted_body'];
      }
    }
    final sentDate = DateTime.now();
    final syncUpdate = SyncUpdate(nextBatch: '')
      ..rooms = (RoomsUpdate()
        ..join = ({}..[id] = (JoinedRoomUpdate()
          ..timeline = (TimelineUpdate()
            ..events = [
              MatrixEvent(
                content: content,
                type: type,
                eventId: messageID,
                senderId: client.userID,
                originServerTs: sentDate,
                unsigned: {
                  messageSendingStatusKey: 0,
                  'transaction_id': messageID,
                },
              )
            ]))));
    await _handleFakeSync(syncUpdate);
    // Send the text and on success, store and display a *sent* event.
    String res;
    while (res == null) {
      try {
        res = await _sendContent(
          type,
          content,
          txid: messageID,
        );
      } catch (e, s) {
        if ((DateTime.now().millisecondsSinceEpoch -
                sentDate.millisecondsSinceEpoch) <
            (1000 * client.sendMessageTimeoutSeconds)) {
          Logs().w('[Client] Problem while sending message because of "' +
              e.toString() +
              '". Try again in 1 seconds...');
          await Future.delayed(Duration(seconds: 1));
        } else {
          Logs().w('[Client] Problem while sending message', e, s);
          syncUpdate.rooms.join.values.first.timeline.events.first
              .unsigned[messageSendingStatusKey] = -1;
          await _handleFakeSync(syncUpdate);
          return null;
        }
      }
    }
    syncUpdate.rooms.join.values.first.timeline.events.first
        .unsigned[messageSendingStatusKey] = 1;
    syncUpdate.rooms.join.values.first.timeline.events.first.eventId = res;
    await _handleFakeSync(syncUpdate);
    return res;
  }
  /// Call the Matrix API to join this room if the user is not already a member.
  /// If this room is intended to be a direct chat, the direct chat flag will
  /// automatically be set.
  Future join({bool leaveIfNotFound = true}) async {
    try {
      await client.joinRoomById(id);
      final invitation = getState(EventTypes.RoomMember, client.userID);
      if (invitation != null &&
          invitation.content['is_direct'] is bool &&
          invitation.content['is_direct']) {
        await addToDirectChat(invitation.sender.id);
      }
    } on MatrixException catch (exception) {
      if (leaveIfNotFound &&
          [MatrixError.M_NOT_FOUND, MatrixError.M_UNKNOWN]
              .contains(exception.error)) {
        await leave();
      }
      rethrow;
    }
    return;
  }
  /// Call the Matrix API to leave this room. If this room is set as a direct
  /// chat, this will be removed too.
  Future leave() async {
    if (directChatMatrixID != '') await removeFromDirectChat();
    try {
      await client.leaveRoom(id);
    } on MatrixException catch (exception) {
      if ([MatrixError.M_NOT_FOUND, MatrixError.M_UNKNOWN]
          .contains(exception.error)) {
        await _handleFakeSync(
          SyncUpdate(nextBatch: '')
            ..rooms = (RoomsUpdate()
              ..leave = {
                '$id': (LeftRoomUpdate()),
              }),
        );
      }
      rethrow;
    }
    return;
  }
  /// Call the Matrix API to forget this room if you already left it.
  Future forget() async {
    await client.database?.forgetRoom(client.id, id);
    await client.forgetRoom(id);
    return;
  }
  /// Call the Matrix API to kick a user from this room.
  Future kick(String userID) => client.kick(id, userID);
  /// Call the Matrix API to ban a user from this room.
  Future ban(String userID) => client.ban(id, userID);
  /// Call the Matrix API to unban a banned user from this room.
  Future unban(String userID) => client.unban(id, userID);
  /// Set the power level of the user with the [userID] to the value [power].
  /// Returns the event ID of the new state event. If there is no known
  /// power level event, there might something broken and this returns null.
  Future setPower(String userID, int power) async {
    if (getState(EventTypes.RoomPowerLevels) == null) return null;
    final powerMap = {}
      ..addAll(getState(EventTypes.RoomPowerLevels).content);
    if (powerMap['users'] == null) powerMap['users'] = {};
    powerMap['users'][userID] = power;
    return await client.setRoomStateWithKey(
      id,
      EventTypes.RoomPowerLevels,
      '',
      powerMap,
    );
  }
  /// Call the Matrix API to invite a user to this room.
  Future invite(String userID) => client.inviteUser(id, userID);
  /// Request more previous events from the server. [historyCount] defines how much events should
  /// be received maximum. When the request is answered, [onHistoryReceived] will be triggered **before**
  /// the historical events will be published in the onEvent stream.
  Future requestHistory(
      {int historyCount = defaultHistoryCount, onHistoryReceived}) async {
    final resp = await client.getRoomEvents(
      id,
      prev_batch,
      Direction.b,
      limit: historyCount,
      filter: jsonEncode(StateFilter(lazyLoadMembers: true).toJson()),
    );
    if (onHistoryReceived != null) onHistoryReceived();
    prev_batch = resp.end;
    final loadFn = () async {
      if (!((resp.chunk?.isNotEmpty ?? false) && resp.end != null)) return;
      await client.handleSync(
          SyncUpdate(nextBatch: '')
            ..rooms = (RoomsUpdate()
              ..join = ({}..[id] = (JoinedRoomUpdate()
                ..state = resp.state
                ..timeline = (TimelineUpdate()
                  ..limited = false
                  ..events = resp.chunk
                  ..prevBatch = resp.end)))),
          sortAtTheEnd: true);
    };
    if (client.database != null) {
      await client.database.transaction(() async {
        await client.database.setRoomPrevBatch(resp.end, client.id, id);
        await loadFn();
        await updateSortOrder();
      });
    } else {
      await loadFn();
    }
  }
  /// Sets this room as a direct chat for this user if not already.
  Future addToDirectChat(String userID) async {
    final directChats = client.directChats;
    if (directChats[userID] is List) {
      if (!directChats[userID].contains(id)) {
        directChats[userID].add(id);
      } else {
        return;
      } // Is already in direct chats
    } else {
      directChats[userID] = [id];
    }
    await client.setAccountData(
      client.userID,
      'm.direct',
      directChats,
    );
    return;
  }
  /// Removes this room from all direct chat tags.
  Future removeFromDirectChat() async {
    final directChats = client.directChats;
    if (directChats[directChatMatrixID] is List &&
        directChats[directChatMatrixID].contains(id)) {
      directChats[directChatMatrixID].remove(id);
    } else {
      return;
    } // Nothing to do here
    await client.setAccountDataPerRoom(
      client.userID,
      id,
      'm.direct',
      directChats,
    );
    return;
  }
  /// Sets the position of the read marker for a given room, and optionally the
  /// read receipt's location.
  Future setReadMarker(String eventId, {String mRead}) async {
    if (mRead != null) {
      notificationCount = 0;
      await client.database?.resetNotificationCount(client.id, id);
    }
    await client.setReadMarker(
      id,
      eventId,
      mRead: mRead,
    );
    return;
  }
  /// This API updates the marker for the given receipt type to the event ID
  /// specified.
  Future postReceipt(String eventId) async {
    notificationCount = 0;
    await client.database?.resetNotificationCount(client.id, id);
    await client.postReceipt(
      id,
      ReceiptType.mRead,
      eventId,
      {},
    );
    return;
  }
  /// Sends *m.fully_read* and *m.read* for the given event ID.
  @Deprecated('Use sendReadMarker instead')
  Future sendReadReceipt(String eventID) async {
    notificationCount = 0;
    await client.database?.resetNotificationCount(client.id, id);
    await client.setReadMarker(
      id,
      eventID,
      mRead: eventID,
    );
    return;
  }
  /// Creates a timeline from the store. Returns a [Timeline] object.
  Future getTimeline(
      {void Function() onUpdate, void Function(int insertID) onInsert}) async {
    await postLoad();
    var events;
    if (client.database != null) {
      events = await client.database.getEventList(client.id, this);
    } else {
      events = [];
    }
    // Try again to decrypt encrypted events and update the database.
    if (encrypted && client.database != null && client.encryptionEnabled) {
      await client.database.transaction(() async {
        for (var i = 0; i < events.length; i++) {
          if (events[i].type == EventTypes.Encrypted &&
              events[i].content['can_request_session'] == true) {
            events[i] = await client.encryption
                .decryptRoomEvent(id, events[i], store: true);
          }
        }
      });
    }
    final timeline = Timeline(
      room: this,
      events: events,
      onUpdate: onUpdate,
      onInsert: onInsert,
    );
    if (client.database == null) {
      prev_batch = '';
      await requestHistory(historyCount: 10);
    }
    return timeline;
  }
  /// Returns all participants for this room. With lazy loading this
  /// list may not be complete. User [requestParticipants] in this
  /// case.
  List getParticipants() {
    final userList = [];
    if (states[EventTypes.RoomMember] is Map) {
      for (final entry in states[EventTypes.RoomMember].entries) {
        final state = entry.value;
        if (state.type == EventTypes.RoomMember) userList.add(state.asUser);
      }
    }
    return userList;
  }
  bool _requestedParticipants = false;
  /// Request the full list of participants from the server. The local list
  /// from the store is not complete if the client uses lazy loading.
  Future> requestParticipants() async {
    if (!participantListComplete && partial && client.database != null) {
      // we aren't fully loaded, maybe the users are in the database
      final users = await client.database.getUsers(client.id, this);
      for (final user in users) {
        setState(user);
      }
    }
    if (_requestedParticipants || participantListComplete) {
      return getParticipants();
    }
    final matrixEvents = await client.getMembersByRoom(id);
    final users =
        matrixEvents.map((e) => Event.fromMatrixEvent(e, this).asUser).toList();
    for (final user in users) {
      setState(user); // at *least* cache this in-memory
    }
    _requestedParticipants = true;
    users.removeWhere(
        (u) => [Membership.leave, Membership.ban].contains(u.membership));
    return users;
  }
  /// Checks if the local participant list of joined and invited users is complete.
  bool get participantListComplete {
    final knownParticipants = getParticipants();
    knownParticipants.removeWhere(
        (u) => ![Membership.join, Membership.invite].contains(u.membership));
    return knownParticipants.length ==
        (summary.mJoinedMemberCount ?? 0) + (summary.mInvitedMemberCount ?? 0);
  }
  /// Returns the [User] object for the given [mxID] or requests it from
  /// the homeserver and waits for a response.
  @Deprecated('Use [requestUser] instead')
  Future getUserByMXID(String mxID) async {
    if (getState(EventTypes.RoomMember, mxID) != null) {
      return getState(EventTypes.RoomMember, mxID).asUser;
    }
    return requestUser(mxID);
  }
  /// Returns the [User] object for the given [mxID] or requests it from
  /// the homeserver and returns a default [User] object while waiting.
  User getUserByMXIDSync(String mxID) {
    if (getState(EventTypes.RoomMember, mxID) != null) {
      return getState(EventTypes.RoomMember, mxID).asUser;
    } else {
      requestUser(mxID, ignoreErrors: true);
      return User(mxID, room: this);
    }
  }
  final Set _requestingMatrixIds = {};
  /// Requests a missing [User] for this room. Important for clients using
  /// lazy loading. If the user can't be found this method tries to fetch
  /// the displayname and avatar from the profile if [requestProfile] is true.
  Future requestUser(
    String mxID, {
    bool ignoreErrors = false,
    bool requestProfile = true,
  }) async {
    // TODO: Why is this bug happening at all?
    if (mxID == null) {
      // Show a warning but first generate a stacktrace.
      try {
        throw Exception();
      } catch (e, s) {
        Logs().w('requestUser has been called with a null mxID', e, s);
      }
      return null;
    }
    if (getState(EventTypes.RoomMember, mxID) != null) {
      return getState(EventTypes.RoomMember, mxID).asUser;
    }
    if (client.database != null) {
      // it may be in the database
      final user = await client.database.getUser(client.id, mxID, this);
      if (user != null) {
        setState(user);
        onUpdate.add(id);
        return user;
      }
    }
    if (!_requestingMatrixIds.add(mxID)) return null;
    Map resp;
    try {
      Logs().v(
          'Request missing user $mxID in room $displayname from the server...');
      resp = await client.requestStateContent(
        id,
        EventTypes.RoomMember,
        mxID,
      );
    } catch (e, s) {
      if (!ignoreErrors) {
        _requestingMatrixIds.remove(mxID);
        rethrow;
      } else {
        Logs().w('Unable to request the user $mxID from the server', e, s);
      }
    }
    if (resp == null && requestProfile) {
      try {
        final profile = await client.getUserProfile(mxID);
        resp = {
          'displayname': profile.displayname,
          'avatar_url': profile.avatarUrl.toString(),
        };
      } catch (e, s) {
        _requestingMatrixIds.remove(mxID);
        if (!ignoreErrors) {
          rethrow;
        } else {
          Logs().w('Unable to request the profile $mxID from the server', e, s);
        }
      }
    }
    if (resp == null) {
      return null;
    }
    final user = User(mxID,
        displayName: resp['displayname'],
        avatarUrl: resp['avatar_url'],
        room: this);
    setState(user);
    await client.database?.transaction(() async {
      final content = {
        'sender': mxID,
        'type': EventTypes.RoomMember,
        'content': resp,
        'state_key': mxID,
      };
      await client.database.storeEventUpdate(
        client.id,
        EventUpdate(
            content: content,
            roomID: id,
            type: EventUpdateType.state,
            sortOrder: 0.0),
      );
    });
    onUpdate.add(id);
    _requestingMatrixIds.remove(mxID);
    return user;
  }
  /// Searches for the event on the server. Returns null if not found.
  Future getEventById(String eventID) async {
    try {
      final matrixEvent = await client.getOneRoomEvent(id, eventID);
      final event = Event.fromMatrixEvent(matrixEvent, this);
      if (event.type == EventTypes.Encrypted && client.encryptionEnabled) {
        // attempt decryption
        return await client.encryption
            .decryptRoomEvent(id, event, store: false);
      }
      return event;
    } on MatrixException catch (err) {
      if (err.errcode == 'M_NOT_FOUND') {
        return null;
      }
      rethrow;
    }
  }
  /// Returns the power level of the given user ID.
  int getPowerLevelByUserId(String userId) {
    var powerLevel = 0;
    final powerLevelState = getState(EventTypes.RoomPowerLevels);
    if (powerLevelState == null) return powerLevel;
    if (powerLevelState.content['users_default'] is int) {
      powerLevel = powerLevelState.content['users_default'];
    }
    if (powerLevelState.content
            .tryGet