/*
 *   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:convert';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:html/parser.dart';
import 'package:http/http.dart' as http;
import '../matrix.dart';
import 'utils/event_localizations.dart';
import 'utils/html_to_text.dart';
import 'utils/markdown.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)
        ? Event.fromJson(redacted_because, room)
        : null;
  }
  bool get redacted => redactedBecause != null;
  User? get stateKeyUser => room.getUserByMXIDSync(stateKey!);
  Event({
    this.status = defaultStatus,
    required Map content,
    required String type,
    required String eventId,
    required String senderId,
    required DateTime originServerTs,
    Map? unsigned,
    Map? 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'] ??= {};
        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 getMapFromPayload(dynamic payload) {
    if (payload is String) {
      try {
        return json.decode(payload);
      } catch (e) {
        return {};
      }
    }
    if (payload is Map) 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 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 toJson() {
    final data = {};
    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 = [];
    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 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 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 sendAgain({String? txid}) async {
    if (!status.isError) return null;
    // If this is a failed file sending event, try to fetch the file from the
    // database first.
    final url = getAttachmentUrl();
    if (url?.scheme == 'local') {
      final file = await downloadAndDecryptAttachment();
      return await room.sendFileEvent(file, extraContent: content);
    }
    // we do not remove the event here. It will automatically be updated
    // in the `sendEvent` method to transition -1 -> 0 -> 1 -> 2
    return await room.sendEvent(
      content,
      txid: txid ?? unsigned?['transaction_id'] ?? eventId,
    );
  }
  /// 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 redactEvent({String? reason, String? txid}) async =>
      await room.redactEvent(eventId, reason: reason, txid: txid);
  /// Searches for the reply event in the given timeline.
  Future 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 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'] : {};
  /// 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']
      : {};
  /// 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 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 downloadAndDecryptAttachment(
      {bool getThumbnail = false,
      Future 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.");
    }
    if (status.isSending) {
      final localFile = room.sendingFilePlaceholders[eventId];
      if (localFile != null) return localFile;
    }
    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(
          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.
  /// [removeMarkdown] allow to remove the markdown formating from the event body.
  /// Usefull form message preview or notifications text.
  String getLocalizedBody(MatrixLocalizations i18n,
      {bool withSenderNamePrefix = false,
      bool hideReply = false,
      bool hideEdit = false,
      bool plaintextBody = false,
      bool removeMarkdown = 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