/*
 *   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:async';

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

import '../matrix.dart';
import '../src/utils/run_in_root.dart';
import 'cross_signing.dart';
import 'key_manager.dart';
import 'key_verification_manager.dart';
import 'olm_manager.dart';
import 'ssss.dart';
import 'utils/bootstrap.dart';

class Encryption {
  final Client client;
  final bool debug;

  bool get enabled => olmManager.enabled;

  /// Returns the base64 encoded keys to store them in a store.
  /// This String should **never** leave the device!
  String? get pickledOlmAccount => olmManager.pickledOlmAccount;

  String? get fingerprintKey => olmManager.fingerprintKey;
  String? get identityKey => olmManager.identityKey;

  late KeyManager keyManager;
  late OlmManager olmManager;
  late KeyVerificationManager keyVerificationManager;
  late CrossSigning crossSigning;
  late SSSS ssss;

  Encryption({
    required this.client,
    this.debug = false,
  }) {
    ssss = SSSS(this);
    keyManager = KeyManager(this);
    olmManager = OlmManager(this);
    keyVerificationManager = KeyVerificationManager(this);
    crossSigning = CrossSigning(this);
  }

  // initial login passes null to init a new olm account
  Future<void> init(String? olmAccount) async {
    await olmManager.init(olmAccount);
    _backgroundTasksRunning = true;
    _backgroundTasks(); // start the background tasks
  }

  bool isMinOlmVersion(int major, int minor, int patch) {
    try {
      final version = olm.get_library_version();
      return version[0] > major ||
          (version[0] == major &&
              (version[1] > minor ||
                  (version[1] == minor && version[2] >= patch)));
    } catch (_) {
      return false;
    }
  }

  Bootstrap bootstrap({void Function()? onUpdate}) => Bootstrap(
        encryption: this,
        onUpdate: onUpdate,
      );

  void handleDeviceOneTimeKeysCount(
      Map<String, int>? countJson, List<String>? unusedFallbackKeyTypes) {
    runInRoot(() => olmManager.handleDeviceOneTimeKeysCount(
        countJson, unusedFallbackKeyTypes));
  }

  void onSync() {
    keyVerificationManager.cleanup();
  }

  Future<void> handleToDeviceEvent(ToDeviceEvent event) async {
    if (event.type == EventTypes.RoomKey) {
      // a new room key. We need to handle this asap, before other
      // events in /sync are handled
      await keyManager.handleToDeviceEvent(event);
    }
    if ([EventTypes.RoomKeyRequest, EventTypes.ForwardedRoomKey]
        .contains(event.type)) {
      // "just" room key request things. We don't need these asap, so we handle
      // them in the background
      // ignore: unawaited_futures
      runInRoot(() => keyManager.handleToDeviceEvent(event));
    }
    if (event.type == EventTypes.Dummy) {
      // the previous device just had to create a new olm session, due to olm session
      // corruption. We want to try to send it the last message we just sent it, if possible
      // ignore: unawaited_futures
      runInRoot(() => olmManager.handleToDeviceEvent(event));
    }
    if (event.type.startsWith('m.key.verification.')) {
      // some key verification event. No need to handle it now, we can easily
      // do this in the background

      // ignore: unawaited_futures
      runInRoot(() => keyVerificationManager.handleToDeviceEvent(event));
    }
    if (event.type.startsWith('m.secret.')) {
      // some ssss thing. We can do this in the background
      // ignore: unawaited_futures
      runInRoot(() => ssss.handleToDeviceEvent(event));
    }
    if (event.sender == client.userID) {
      // maybe we need to re-try SSSS secrets
      // ignore: unawaited_futures
      runInRoot(() => ssss.periodicallyRequestMissingCache());
    }
  }

  Future<void> handleEventUpdate(EventUpdate update) async {
    if (update.type == EventUpdateType.ephemeral ||
        update.type == EventUpdateType.history) {
      return;
    }
    if (update.content['type'].startsWith('m.key.verification.') ||
        (update.content['type'] == EventTypes.Message &&
            (update.content['content']['msgtype'] is String) &&
            update.content['content']['msgtype']
                .startsWith('m.key.verification.'))) {
      // "just" key verification, no need to do this in sync

      // ignore: unawaited_futures
      runInRoot(() => keyVerificationManager.handleEventUpdate(update));
    }
    if (update.content['sender'] == client.userID &&
        update.content['unsigned']?['transaction_id'] == null) {
      // maybe we need to re-try SSSS secrets
      // ignore: unawaited_futures
      runInRoot(() => ssss.periodicallyRequestMissingCache());
    }
  }

  Future<ToDeviceEvent> decryptToDeviceEvent(ToDeviceEvent event) async {
    try {
      return await olmManager.decryptToDeviceEvent(event);
    } catch (e, s) {
      Logs().w(
          '[LibOlm] Could not decrypt to device event from ${event.sender} with content: ${event.content}',
          e,
          s);
      client.onEncryptionError.add(
        SdkError(
          exception: e is Exception ? e : Exception(e),
          stackTrace: s,
        ),
      );
      return event;
    }
  }

  Event decryptRoomEventSync(String roomId, Event event) {
    final content = event.parsedRoomEncryptedContent;
    if (event.type != EventTypes.Encrypted ||
        content.ciphertextMegolm == null) {
      return event;
    }
    Map<String, dynamic> decryptedPayload;
    var canRequestSession = false;
    try {
      if (content.algorithm != AlgorithmTypes.megolmV1AesSha2) {
        throw DecryptException(DecryptException.unknownAlgorithm);
      }
      final sessionId = content.sessionId;
      final senderKey = content.senderKey;
      if (sessionId == null) {
        throw DecryptException(DecryptException.unknownSession);
      }

      final inboundGroupSession =
          keyManager.getInboundGroupSession(roomId, sessionId, senderKey);
      if (!(inboundGroupSession?.isValid ?? false)) {
        canRequestSession = true;
        throw DecryptException(DecryptException.unknownSession);
      }

      // decrypt errors here may mean we have a bad session key - others might have a better one
      canRequestSession = true;

      final decryptResult = inboundGroupSession!.inboundGroupSession!
          .decrypt(content.ciphertextMegolm!);
      canRequestSession = false;

      // we can't have the key be an int, else json-serializing will fail, thus we need it to be a string
      final messageIndexKey = 'key-' + decryptResult.message_index.toString();
      final messageIndexValue = event.eventId +
          '|' +
          event.originServerTs.millisecondsSinceEpoch.toString();
      final haveIndex =
          inboundGroupSession.indexes.containsKey(messageIndexKey);
      if (haveIndex &&
          inboundGroupSession.indexes[messageIndexKey] != messageIndexValue) {
        Logs().e('[Decrypt] Could not decrypt due to a corrupted session.');
        throw DecryptException(DecryptException.channelCorrupted);
      }

      inboundGroupSession.indexes[messageIndexKey] = messageIndexValue;
      if (!haveIndex) {
        // now we persist the udpated indexes into the database.
        // the entry should always exist. In the case it doesn't, the following
        // line *could* throw an error. As that is a future, though, and we call
        // it un-awaited here, nothing happens, which is exactly the result we want
        client.database?.updateInboundGroupSessionIndexes(
            json.encode(inboundGroupSession.indexes), roomId, sessionId);
      }
      decryptedPayload = json.decode(decryptResult.plaintext);
    } catch (exception) {
      // alright, if this was actually by our own outbound group session, we might as well clear it
      if (exception.toString() != DecryptException.unknownSession &&
          (keyManager
                      .getOutboundGroupSession(roomId)
                      ?.outboundGroupSession
                      ?.session_id() ??
                  '') ==
              content.sessionId) {
        runInRoot(() =>
            keyManager.clearOrUseOutboundGroupSession(roomId, wipe: true));
      }
      if (canRequestSession) {
        decryptedPayload = {
          'content': event.content,
          'type': EventTypes.Encrypted,
        };
        decryptedPayload['content']['body'] = exception.toString();
        decryptedPayload['content']['msgtype'] = MessageTypes.BadEncrypted;
        decryptedPayload['content']['can_request_session'] = true;
      } else {
        decryptedPayload = {
          'content': <String, dynamic>{
            'msgtype': MessageTypes.BadEncrypted,
            'body': exception.toString(),
          },
          'type': EventTypes.Encrypted,
        };
      }
    }
    if (event.content['m.relates_to'] != null) {
      decryptedPayload['content']['m.relates_to'] =
          event.content['m.relates_to'];
    }
    return Event(
      content: decryptedPayload['content'],
      type: decryptedPayload['type'],
      senderId: event.senderId,
      eventId: event.eventId,
      room: event.room,
      originServerTs: event.originServerTs,
      unsigned: event.unsigned,
      stateKey: event.stateKey,
      prevContent: event.prevContent,
      status: event.status,
    );
  }

  Future<Event> decryptRoomEvent(String roomId, Event event,
      {bool store = false,
      EventUpdateType updateType = EventUpdateType.timeline}) async {
    if (event.type != EventTypes.Encrypted) {
      return event;
    }
    final content = event.parsedRoomEncryptedContent;
    final sessionId = content.sessionId;
    try {
      if (client.database != null &&
          sessionId != null &&
          !(keyManager
                  .getInboundGroupSession(
                    roomId,
                    sessionId,
                    content.senderKey,
                  )
                  ?.isValid ??
              false)) {
        await keyManager.loadInboundGroupSession(
          roomId,
          sessionId,
          content.senderKey,
        );
      }
      event = decryptRoomEventSync(roomId, event);
      if (event.type == EventTypes.Encrypted &&
          event.content['can_request_session'] == true &&
          sessionId != null) {
        keyManager.maybeAutoRequest(
          roomId,
          sessionId,
          content.senderKey,
        );
      }
      if (event.type != EventTypes.Encrypted && store) {
        if (updateType != EventUpdateType.history) {
          event.room.setState(event);
        }
        await client.database?.storeEventUpdate(
          EventUpdate(
            content: event.toJson(),
            roomID: roomId,
            type: updateType,
          ),
          client,
        );
      }
      return event;
    } catch (e, s) {
      Logs().e('[Decrypt] Could not decrpyt event', e, s);
      return event;
    }
  }

  /// Encrypts the given json payload and creates a send-ready m.room.encrypted
  /// payload. This will create a new outgoingGroupSession if necessary.
  Future<Map<String, dynamic>> encryptGroupMessagePayload(
      String roomId, Map<String, dynamic> payload,
      {String type = EventTypes.Message}) async {
    final Map<String, dynamic>? mRelatesTo = payload.remove('m.relates_to');
    // Events which only contain a m.relates_to like reactions don't need to
    // be encrypted.
    if (payload.isEmpty && mRelatesTo != null) {
      return {'m.relates_to': mRelatesTo};
    }
    final room = client.getRoomById(roomId);
    if (room == null || !room.encrypted || !enabled) {
      return payload;
    }
    if (room.encryptionAlgorithm != AlgorithmTypes.megolmV1AesSha2) {
      throw ('Unknown encryption algorithm');
    }
    if (keyManager.getOutboundGroupSession(roomId)?.isValid != true) {
      await keyManager.loadOutboundGroupSession(roomId);
    }
    await keyManager.clearOrUseOutboundGroupSession(roomId);
    if (keyManager.getOutboundGroupSession(roomId)?.isValid != true) {
      await keyManager.createOutboundGroupSession(roomId);
    }
    final sess = keyManager.getOutboundGroupSession(roomId);
    if (sess?.isValid != true) {
      throw ('Unable to create new outbound group session');
    }
    // we clone the payload as we do not want to remove 'm.relates_to' from the
    // original payload passed into this function
    payload = payload.copy();
    final payloadContent = {
      'content': payload,
      'type': type,
      'room_id': roomId,
    };
    final encryptedPayload = <String, dynamic>{
      'algorithm': AlgorithmTypes.megolmV1AesSha2,
      'ciphertext':
          sess!.outboundGroupSession!.encrypt(json.encode(payloadContent)),
      'device_id': client.deviceID,
      'sender_key': identityKey,
      'session_id': sess.outboundGroupSession!.session_id(),
      if (mRelatesTo != null) 'm.relates_to': mRelatesTo,
    };
    await keyManager.storeOutboundGroupSession(roomId, sess);
    return encryptedPayload;
  }

  Future<Map<String, dynamic>> encryptToDeviceMessage(
      List<DeviceKeys> deviceKeys,
      String type,
      Map<String, dynamic> payload) async {
    return await olmManager.encryptToDeviceMessage(deviceKeys, type, payload);
  }

  Future<void> autovalidateMasterOwnKey() async {
    // check if we can set our own master key as verified, if it isn't yet
    final userId = client.userID;
    final masterKey = client.userDeviceKeys[userId]?.masterKey;
    if (client.database != null &&
        masterKey != null &&
        userId != null &&
        !masterKey.directVerified &&
        masterKey.hasValidSignatureChain(onlyValidateUserIds: {userId})) {
      await masterKey.setVerified(true);
    }
  }

  // this method is responsible for all background tasks, such as uploading online key backups
  bool _backgroundTasksRunning = true;
  void _backgroundTasks() {
    if (!_backgroundTasksRunning || !client.isLogged()) {
      return;
    }

    keyManager.backgroundTasks();

//    autovalidateMasterOwnKey();

    if (_backgroundTasksRunning) {
      Timer(Duration(seconds: 10), _backgroundTasks);
    }
  }

  void dispose() {
    _backgroundTasksRunning = false;
    keyManager.dispose();
    olmManager.dispose();
    keyVerificationManager.dispose();
  }
}

class DecryptException implements Exception {
  String cause;
  String? libolmMessage;
  DecryptException(this.cause, [this.libolmMessage]);

  @override
  String toString() =>
      cause + (libolmMessage != null ? ': $libolmMessage' : '');

  static const String notEnabled = 'Encryption is not enabled in your client.';
  static const String unknownAlgorithm = 'Unknown encryption algorithm.';
  static const String unknownSession =
      'The sender has not sent us the session key.';
  static const String channelCorrupted =
      'The secure channel with the sender was corrupted.';
  static const String unableToDecryptWithAnyOlmSession =
      'Unable to decrypt with any existing OLM session';
  static const String senderDoesntMatch =
      "Message was decrypted but sender doesn't match";
  static const String recipientDoesntMatch =
      "Message was decrypted but recipient doesn't match";
  static const String ownFingerprintDoesntMatch =
      "Message was decrypted but own fingerprint Key doesn't match";
  static const String isntSentForThisDevice =
      "The message isn't sent for this device";
  static const String unknownMessageType = 'Unknown message type';
  static const String decryptionFailed = 'Decryption failed';
}