/*
 *   Famedly Matrix SDK
 *   Copyright (C) 2019, 2020 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 'package:canonical_json/canonical_json.dart';
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/matrix_api.dart';
import 'package:olm/olm.dart' as olm;
import './encryption.dart';
class OlmManager {
  final Encryption encryption;
  Client get client => encryption.client;
  olm.Account _olmAccount;
  /// Returns the base64 encoded keys to store them in a store.
  /// This String should **never** leave the device!
  String get pickledOlmAccount =>
      enabled ? _olmAccount.pickle(client.userID) : null;
  String get fingerprintKey =>
      enabled ? json.decode(_olmAccount.identity_keys())['ed25519'] : null;
  String get identityKey =>
      enabled ? json.decode(_olmAccount.identity_keys())['curve25519'] : null;
  bool get enabled => _olmAccount != null;
  OlmManager(this.encryption);
  /// A map from Curve25519 identity keys to existing olm sessions.
  Map> get olmSessions => _olmSessions;
  final Map> _olmSessions = {};
  Future init(String olmAccount) async {
    if (olmAccount == null) {
      try {
        await olm.init();
        _olmAccount = olm.Account();
        _olmAccount.create();
        if (await uploadKeys(uploadDeviceKeys: true) == false) {
          throw ('Upload key failed');
        }
      } catch (_) {
        _olmAccount?.free();
        _olmAccount = null;
      }
    } else {
      try {
        await olm.init();
        _olmAccount = olm.Account();
        _olmAccount.unpickle(client.userID, olmAccount);
      } catch (_) {
        _olmAccount?.free();
        _olmAccount = null;
      }
    }
  }
  /// Adds a signature to this json from this olm account.
  Map signJson(Map payload) {
    if (!enabled) throw ('Encryption is disabled');
    final Map unsigned = payload['unsigned'];
    final Map signatures = payload['signatures'];
    payload.remove('unsigned');
    payload.remove('signatures');
    final canonical = canonicalJson.encode(payload);
    final signature = _olmAccount.sign(String.fromCharCodes(canonical));
    if (signatures != null) {
      payload['signatures'] = signatures;
    } else {
      payload['signatures'] = {};
    }
    if (!payload['signatures'].containsKey(client.userID)) {
      payload['signatures'][client.userID] = {};
    }
    payload['signatures'][client.userID]['ed25519:${client.deviceID}'] =
        signature;
    if (unsigned != null) {
      payload['unsigned'] = unsigned;
    }
    return payload;
  }
  /// Checks the signature of a signed json object.
  bool checkJsonSignature(String key, Map signedJson,
      String userId, String deviceId) {
    if (!enabled) throw ('Encryption is disabled');
    final Map signatures = signedJson['signatures'];
    if (signatures == null || !signatures.containsKey(userId)) return false;
    signedJson.remove('unsigned');
    signedJson.remove('signatures');
    if (!signatures[userId].containsKey('ed25519:$deviceId')) return false;
    final String signature = signatures[userId]['ed25519:$deviceId'];
    final canonical = canonicalJson.encode(signedJson);
    final message = String.fromCharCodes(canonical);
    var isValid = false;
    final olmutil = olm.Utility();
    try {
      olmutil.ed25519_verify(key, message, signature);
      isValid = true;
    } catch (e) {
      isValid = false;
      print('[LibOlm] Signature check failed: ' + e.toString());
    } finally {
      olmutil.free();
    }
    return isValid;
  }
  /// Generates new one time keys, signs everything and upload it to the server.
  Future uploadKeys({bool uploadDeviceKeys = false, int oldKeyCount = 0}) async {
    if (!enabled) {
      return true;
    }
    // generate one-time keys
    // we generate 2/3rds of max, so that other keys people may still have can
    // still be used
    final oneTimeKeysCount = (_olmAccount.max_number_of_one_time_keys() * 2 / 3).floor() - oldKeyCount;
    _olmAccount.generate_one_time_keys(oneTimeKeysCount);
    final Map oneTimeKeys =
        json.decode(_olmAccount.one_time_keys());
    // now sign all the one-time keys
    final signedOneTimeKeys = {};
    for (final entry in oneTimeKeys['curve25519'].entries) {
      final key = entry.key;
      final value = entry.value;
      signedOneTimeKeys['signed_curve25519:$key'] = {};
      signedOneTimeKeys['signed_curve25519:$key'] = signJson({
        'key': value,
      });
    }
    // and now generate the payload to upload
    final keysContent = {
      if (uploadDeviceKeys)
        'device_keys': {
          'user_id': client.userID,
          'device_id': client.deviceID,
          'algorithms': [
            'm.olm.v1.curve25519-aes-sha2',
            'm.megolm.v1.aes-sha2'
          ],
          'keys': {},
        },
    };
    if (uploadDeviceKeys) {
      final Map keys =
          json.decode(_olmAccount.identity_keys());
      for (final entry in keys.entries) {
        final algorithm = entry.key;
        final value = entry.value;
        keysContent['device_keys']['keys']['$algorithm:${client.deviceID}'] =
            value;
      }
      keysContent['device_keys'] =
          signJson(keysContent['device_keys'] as Map);
    }
    final response = await client.api.uploadDeviceKeys(
      deviceKeys: uploadDeviceKeys
          ? MatrixDeviceKeys.fromJson(keysContent['device_keys'])
          : null,
      oneTimeKeys: signedOneTimeKeys,
    );
    _olmAccount.mark_keys_as_published();
    await client.database?.updateClientKeys(pickledOlmAccount, client.id);
    return response['signed_curve25519'] == oneTimeKeysCount;
  }
  void handleDeviceOneTimeKeysCount(Map countJson) {
    if (!enabled) {
      return;
    }
    // Check if there are at least half of max_number_of_one_time_keys left on the server
    // and generate and upload more if not.
    if (countJson.containsKey('signed_curve25519') &&
        countJson['signed_curve25519'] <
            (_olmAccount.max_number_of_one_time_keys() / 2)) {
      uploadKeys(oldKeyCount: countJson['signed_curve25519']);
    }
  }
  void storeOlmSession(String curve25519IdentityKey, olm.Session session) {
    if (client.database == null) {
      return;
    }
    if (!_olmSessions.containsKey(curve25519IdentityKey)) {
      _olmSessions[curve25519IdentityKey] = [];
    }
    final ix = _olmSessions[curve25519IdentityKey]
        .indexWhere((s) => s.session_id() == session.session_id());
    if (ix == -1) {
      // add a new session
      _olmSessions[curve25519IdentityKey].add(session);
    } else {
      // update an existing session
      _olmSessions[curve25519IdentityKey][ix] = session;
    }
    final pickle = session.pickle(client.userID);
    client.database.storeOlmSession(
        client.id, curve25519IdentityKey, session.session_id(), pickle);
  }
  ToDeviceEvent _decryptToDeviceEvent(ToDeviceEvent event) {
    if (event.type != EventTypes.Encrypted) {
      return event;
    }
    if (event.content['algorithm'] != 'm.olm.v1.curve25519-aes-sha2') {
      throw ('Unknown algorithm: ${event.content}');
    }
    if (!event.content['ciphertext'].containsKey(identityKey)) {
      throw ("The message isn't sent for this device");
    }
    String plaintext;
    final String senderKey = event.content['sender_key'];
    final String body = event.content['ciphertext'][identityKey]['body'];
    final int type = event.content['ciphertext'][identityKey]['type'];
    if (type != 0 && type != 1) {
      throw ('Unknown message type');
    }
    var existingSessions = olmSessions[senderKey];
    if (existingSessions != null) {
      for (var session in existingSessions) {
        if (type == 0 && session.matches_inbound(body) == true) {
          plaintext = session.decrypt(type, body);
          storeOlmSession(senderKey, session);
          break;
        } else if (type == 1) {
          try {
            plaintext = session.decrypt(type, body);
            storeOlmSession(senderKey, session);
            break;
          } catch (_) {
            plaintext = null;
          }
        }
      }
    }
    if (plaintext == null && type != 0) {
      return event;
    }
    if (plaintext == null) {
      var newSession = olm.Session();
      try {
        newSession.create_inbound_from(_olmAccount, senderKey, body);
        _olmAccount.remove_one_time_keys(newSession);
        client.database?.updateClientKeys(pickledOlmAccount, client.id);
        plaintext = newSession.decrypt(type, body);
        storeOlmSession(senderKey, newSession);
      } catch (_) {
        newSession?.free();
        rethrow;
      }
    }
    final Map plainContent = json.decode(plaintext);
    if (plainContent.containsKey('sender') &&
        plainContent['sender'] != event.sender) {
      throw ("Message was decrypted but sender doesn't match");
    }
    if (plainContent.containsKey('recipient') &&
        plainContent['recipient'] != client.userID) {
      throw ("Message was decrypted but recipient doesn't match");
    }
    if (plainContent['recipient_keys'] is Map &&
        plainContent['recipient_keys']['ed25519'] is String &&
        plainContent['recipient_keys']['ed25519'] != fingerprintKey) {
      throw ("Message was decrypted but own fingerprint Key doesn't match");
    }
    return ToDeviceEvent(
      content: plainContent['content'],
      encryptedContent: event.content,
      type: plainContent['type'],
      sender: event.sender,
    );
  }
  Future decryptToDeviceEvent(ToDeviceEvent event) async {
    if (event.type != EventTypes.Encrypted) {
      return event;
    }
    final senderKey = event.content['sender_key'];
    final loadFromDb = () async {
      if (client.database == null) {
        return false;
      }
      final sessions = await client.database.getSingleOlmSessions(
          client.id, senderKey, client.userID);
      if (sessions.isEmpty) {
        return false; // okay, can't do anything
      }
      _olmSessions[senderKey] = sessions;
      return true;
    };
    if (!_olmSessions.containsKey(senderKey)) {
      await loadFromDb();
    }
    event = _decryptToDeviceEvent(event);
    if (event.type != EventTypes.Encrypted || !(await loadFromDb())) {
      return event;
    }
    // retry to decrypt!
    return _decryptToDeviceEvent(event);
  }
  Future startOutgoingOlmSessions(List deviceKeys) async {
    var requestingKeysFrom = >{};
    for (var device in deviceKeys) {
      if (requestingKeysFrom[device.userId] == null) {
        requestingKeysFrom[device.userId] = {};
      }
      requestingKeysFrom[device.userId][device.deviceId] = 'signed_curve25519';
    }
    final response =
        await client.api.requestOneTimeKeys(requestingKeysFrom, timeout: 10000);
    for (var userKeysEntry in response.oneTimeKeys.entries) {
      final userId = userKeysEntry.key;
      for (var deviceKeysEntry in userKeysEntry.value.entries) {
        final deviceId = deviceKeysEntry.key;
        final fingerprintKey =
            client.userDeviceKeys[userId].deviceKeys[deviceId].ed25519Key;
        final identityKey =
            client.userDeviceKeys[userId].deviceKeys[deviceId].curve25519Key;
        for (Map deviceKey in deviceKeysEntry.value.values) {
          if (!checkJsonSignature(fingerprintKey, deviceKey, userId, deviceId)) {
            continue;
          }
          try {
            var session = olm.Session();
            session.create_outbound(_olmAccount, identityKey, deviceKey['key']);
            await storeOlmSession(identityKey, session);
          } catch (e) {
            print('[LibOlm] Could not create new outbound olm session: ' +
                e.toString());
          }
        }
      }
    }
  }
  Future