import { isEmpty } from 'lodash';
import { getConversationController } from '../session/conversations';
import { getSodiumRenderer } from '../session/crypto';
import { ed25519Str, fromArrayBufferToBase64, fromHex, toHex } from '../session/utils/String';
import { configurationMessageReceived, trigger } from '../shims/events';

import { SessionButtonColor } from '../components/basic/SessionButton';
import { Data } from '../data/data';
import { SettingsKey } from '../data/settings-key';
import { ConversationTypeEnum } from '../models/types';
import { deleteAllLogs } from '../node/logs';
import { SessionKeyPair } from '../receiver/keypairs';
import { clearInbox } from '../session/apis/open_group_api/sogsv3/sogsV3ClearInbox';
import { getAllValidOpenGroupV2ConversationRoomInfos } from '../session/apis/open_group_api/utils/OpenGroupUtils';
import { getSwarmPollingInstance } from '../session/apis/snode_api';
import { mnDecode, mnEncode } from '../session/crypto/mnemonic';
import { getOurPubKeyStrFromCache } from '../session/utils/User';
import { LibSessionUtil } from '../session/utils/libsession/libsession_utils';
import { forceSyncConfigurationNowIfNeeded } from '../session/utils/sync/syncUtils';
import { updateConfirmModal, updateDeleteAccountModal } from '../state/ducks/modalDialog';
import { actions as userActions } from '../state/ducks/user';
import { Registration } from './registration';
import { Storage, saveRecoveryPhrase, setLocalPubKey, setSignInByLinking } from './storage';
import { PromiseUtils } from '../session/utils';
import { SnodeAPI } from '../session/apis/snode_api/SNodeAPI';

/**
 * Might throw
 */
export async function sessionGenerateKeyPair(seed: ArrayBuffer): Promise<SessionKeyPair> {
  const sodium = await getSodiumRenderer();

  const ed25519KeyPair = sodium.crypto_sign_seed_keypair(new Uint8Array(seed));
  const x25519PublicKey = sodium.crypto_sign_ed25519_pk_to_curve25519(ed25519KeyPair.publicKey);
  // prepend version byte (coming from `processKeys(raw_keys)`)
  const origPub = new Uint8Array(x25519PublicKey);
  const prependedX25519PublicKey = new Uint8Array(33);
  prependedX25519PublicKey.set(origPub, 1);
  prependedX25519PublicKey[0] = 5;
  const x25519SecretKey = sodium.crypto_sign_ed25519_sk_to_curve25519(ed25519KeyPair.privateKey);

  // prepend with 05 the public key
  const x25519KeyPair = {
    pubKey: prependedX25519PublicKey.buffer,
    privKey: x25519SecretKey.buffer,
    ed25519KeyPair,
  };

  return x25519KeyPair;
}

const generateKeypair = async (
  mnemonic: string,
  mnemonicLanguage: string
): Promise<SessionKeyPair> => {
  let seedHex = mnDecode(mnemonic, mnemonicLanguage);
  // handle shorter than 32 bytes seeds
  const privKeyHexLength = 32 * 2;
  if (seedHex.length !== privKeyHexLength) {
    seedHex = seedHex.concat('0'.repeat(32));
    seedHex = seedHex.substring(0, privKeyHexLength);
  }
  const seed = fromHex(seedHex);
  return sessionGenerateKeyPair(seed);
};

/**
 * This registers a user account. It can also be used if an account restore fails and the user instead registers a new display name
 * @param mnemonic The mnemonic generated on first app loading and to use for this brand new user
 * @param mnemonicLanguage only 'english' is supported
 * @param displayName the display name to register
 * @param registerCallback when restoring an account, registration completion is handled elsewhere so we need to pass the pubkey back up to the caller
 */
export async function registerSingleDevice(
  generatedMnemonic: string,
  mnemonicLanguage: string,
  displayName: string,
  registerCallback?: (pubkey: string) => Promise<void>
) {
  if (isEmpty(generatedMnemonic)) {
    throw new Error('Session always needs a mnemonic. Either generated or given by the user');
  }
  if (isEmpty(mnemonicLanguage)) {
    throw new Error('We always need a mnemonicLanguage');
  }
  if (isEmpty(displayName)) {
    throw new Error('We always need a displayName');
  }

  const identityKeyPair = await generateKeypair(generatedMnemonic, mnemonicLanguage);

  await createAccount(identityKeyPair);
  await saveRecoveryPhrase(generatedMnemonic);

  const pubKeyString = toHex(identityKeyPair.pubKey);
  if (isEmpty(pubKeyString)) {
    throw new Error("We don't have a pubkey from the recovery password...");
  }

  if (registerCallback) {
    await registerCallback(pubKeyString);
  } else {
    await registrationDone(pubKeyString, displayName);
  }
}

/**
 * Restores a users account with their recovery password and try to recover display name and avatar from the first encountered configuration message.
 * @param mnemonic the mnemonic the user duly saved in a safe place. We will restore his sessionID based on this.
 * @param mnemonicLanguage 'english' only is supported
 * @param loadingAnimationCallback a callback to trigger a loading animation while fetching
 *
 * @returns the display name of the user if found on the network
 */
export async function signInByLinkingDevice(
  mnemonic: string,
  mnemonicLanguage: string,
  abortSignal?: AbortSignal
) {
  if (isEmpty(mnemonic)) {
    throw new Error('Session always needs a mnemonic. Either generated or given by the user');
  }
  if (isEmpty(mnemonicLanguage)) {
    throw new Error('We always need a mnemonicLanguage');
  }

  const identityKeyPair = await generateKeypair(mnemonic, mnemonicLanguage);

  await setSignInByLinking(true);
  await createAccount(identityKeyPair);
  await saveRecoveryPhrase(mnemonic);

  const pubKeyString = toHex(identityKeyPair.pubKey);

  if (isEmpty(pubKeyString)) {
    throw new Error("We don't have a pubkey from the recovery password...");
  }

  const displayName = await getSwarmPollingInstance().pollOnceForOurDisplayName(abortSignal);

  // NOTE the registration is not yet finished until the configurationMessageReceived event has been processed
  trigger(configurationMessageReceived, pubKeyString, displayName);
  // for testing purposes
  return { displayName, pubKeyString };
}

export async function generateMnemonic() {
  // Note: 4 bytes are converted into 3 seed words, so length 12 seed words
  // (13 - 1 checksum) are generated using 12 * 4 / 3 = 16 bytes.
  const seedSize = 16;
  const seed = (await getSodiumRenderer()).randombytes_buf(seedSize);
  const hex = toHex(seed);
  return mnEncode(hex);
}

async function createAccount(identityKeyPair: SessionKeyPair) {
  const sodium = await getSodiumRenderer();

  let password = fromArrayBufferToBase64(sodium.randombytes_buf(16));
  password = password.substring(0, password.length - 2);

  await Promise.all([
    Storage.remove('identityKey'),
    Storage.remove('signaling_key'),
    Storage.remove('password'),
    Storage.remove('registrationId'),
    Storage.remove('number_id'),
    Storage.remove('device_name'),
    Storage.remove('userAgent'),
    Storage.remove('regionCode'),
    Storage.remove('local_attachment_encrypted_key'),
    Storage.remove(SettingsKey.settingsReadReceipt),
    Storage.remove(SettingsKey.settingsTypingIndicator),
    Storage.remove(SettingsKey.hideRecoveryPassword),
  ]);

  // update our own identity key, which may have changed
  // if we're relinking after a reinstall on the master device
  const pubKeyString = toHex(identityKeyPair.pubKey);

  await Storage.put('identityKey', identityKeyPair);
  await Storage.put('password', password);

  // disable read-receipt by default
  await Storage.put(SettingsKey.settingsReadReceipt, false);

  // Enable typing indicators by default
  await Storage.put(SettingsKey.settingsTypingIndicator, false);

  // opengroups pruning in ON by default on new accounts, but you can change that from the settings
  await Storage.put(SettingsKey.settingsOpengroupPruning, true);
  await window.setOpengroupPruning(true);

  // turn off hide recovery password by default
  await Storage.put(SettingsKey.hideRecoveryPassword, false);

  await setLocalPubKey(pubKeyString);
}

/**
 * When a user sucessfully registers, we need to initialise the libession wrappers and create a conversation for the user
 * @param ourPubkey the pubkey recovered from the seed
 * @param displayName the display name entered by the user. Can be what is fetched from the last config message or what is entered manually by the user
 */
export async function registrationDone(ourPubkey: string, displayName: string) {
  window?.log?.info(
    `[onboarding] registration done with user provided displayName "${displayName}" and pubkey "${ourPubkey}"`
  );

  // initializeLibSessionUtilWrappers needs our publicKey to be set
  await Storage.put('primaryDevicePubKey', ourPubkey);
  await Registration.markDone();

  try {
    await LibSessionUtil.initializeLibSessionUtilWrappers();
  } catch (e) {
    window.log.warn(
      '[onboarding] registration done but LibSessionUtil.initializeLibSessionUtilWrappers failed with',
      e.message || e
    );
    throw e;
  }

  // Ensure that we always have a conversation for ourself
  const conversation = await getConversationController().getOrCreateAndWait(
    ourPubkey,
    ConversationTypeEnum.PRIVATE
  );
  conversation.setSessionDisplayNameNoCommit(displayName);

  await conversation.setIsApproved(true, false);
  await conversation.setDidApproveMe(true, false);
  // when onboarding, hide the note to self by default.
  await conversation.setHidden(true);
  await conversation.commit();

  const user = {
    ourDisplayNameInProfile: displayName,
    ourNumber: getOurPubKeyStrFromCache(),
    ourPrimary: ourPubkey,
  };
  window.inboxStore?.dispatch(userActions.userChanged(user));

  window?.log?.info('[onboarding] dispatching registration event');
  // this will make the poller start fetching messages
  trigger('registration_done');
}

export const deleteDbLocally = async () => {
  window?.log?.info('last message sent successfully. Deleting everything');
  await window.persistStore?.purge();
  window?.log?.info('store purged');

  await deleteAllLogs();
  window?.log?.info('deleteAllLogs: done');

  await Data.removeAll();
  window?.log?.info('Data.removeAll: done');

  await Data.close();
  window?.log?.info('Data.close: done');
  await Data.removeDB();
  window?.log?.info('Data.removeDB: done');

  await Data.removeOtherData();
  window?.log?.info('Data.removeOtherData: done');

  window.localStorage.setItem('restart-reason', 'delete-account');
};

export async function sendConfigMessageAndDeleteEverything() {
  try {
    // DELETE LOCAL DATA ONLY, NOTHING ON NETWORK
    window?.log?.info('DeleteAccount => Sending a last SyncConfiguration');
    if (window.isOnline) {
      // be sure to wait for the message being effectively sent. Otherwise we won't be able to encrypt it for our devices !
      await forceSyncConfigurationNowIfNeeded(true);
      window?.log?.info('Last configuration message sent!');
    } else {
      window?.log?.warn('sendConfigMessageAndDeleteEverything: we are offline, just deleting');
    }

    await deleteDbLocally();
  } catch (error) {
    // if an error happened, it's not related to the delete everything on network logic as this is handled above.
    // this could be a last sync configuration message not being sent.
    // in all case, we delete everything, and restart
    window?.log?.error(
      'Something went wrong deleting all data:',
      error && error.stack ? error.stack : error
    );
    try {
      await deleteDbLocally();
    } catch (e) {
      window?.log?.error(e);
    }
  } finally {
    window.restart();
  }
}

async function deleteEverythingOnNetwork() {
  const allRoomInfos = await getAllValidOpenGroupV2ConversationRoomInfos();
  const allRoomInfosArray = Array.from(allRoomInfos?.values() || []);
  // clear all sogs inboxes (includes message requests)

  if (allRoomInfosArray.length) {
    // clear each inbox per sogs

    const clearInboxPromises = allRoomInfosArray.map(async roomInfo => {
      const success = await clearInbox(roomInfo);
      if (!success) {
        throw Error(`Failed to clear inbox for ${roomInfo.conversationId}`);
      }
      return true;
    });

    const results = await Promise.allSettled(clearInboxPromises);
    results.forEach((result, index) => {
      if (result.status === 'rejected') {
        window.log.error(result.reason);
      } else {
        window.log.info('Inbox cleared for room', allRoomInfosArray[index]);
      }
    });
  }

  return SnodeAPI.forceNetworkDeletion();
}

export async function deleteEverythingAndNetworkData() {
  try {
    // DELETE EVERYTHING ON NETWORK, AND THEN STUFF LOCALLY STORED
    // a bit of duplicate code below, but it's easier to follow every case like that (helped with returns)
    let potentiallyMaliciousSnodes: Array<string> | null = null;
    try {
      potentiallyMaliciousSnodes = await PromiseUtils.timeout(deleteEverythingOnNetwork(), 15000);
    } catch (e) {
      potentiallyMaliciousSnodes = null; // mark as generic fail
    }

    // send deletion message to the network
    if (potentiallyMaliciousSnodes === null || potentiallyMaliciousSnodes.length) {
      window?.log?.warn('DeleteAccount => forceNetworkDeletion failed');

      if (potentiallyMaliciousSnodes?.length) {
        const snodeStr = potentiallyMaliciousSnodes.map(ed25519Str);
        window?.log?.warn(
          'DeleteAccount => forceNetworkDeletion Got some potentially malicious snodes',
          snodeStr
        );
      }

      // close this dialog
      window?.inboxStore?.dispatch(updateDeleteAccountModal(null));
      // open a new confirm dialog to ask user what to do
      window?.inboxStore?.dispatch(
        updateConfirmModal({
          title: window.i18n('clearDataAll'),
          i18nMessage: { token: 'clearDataErrorDescriptionGeneric' },
          okTheme: SessionButtonColor.Danger,
          okText: window.i18n('clearDevice'),
          cancelText: window.i18n('cancel'),
          onClickOk: async () => {
            await deleteDbLocally();
            window.restart();
          },
          onClickClose: () => {
            window.inboxStore?.dispatch(updateConfirmModal(null));
          },
        })
      );
      return;
    }

    // We removed everything on the network successfully (no malicious node!). Now delete the stuff we got locally
    // without sending a last configuration message (otherwise this one will still be on the network)
    await deleteDbLocally();
    window.restart();
  } catch (error) {
    // if an error happened, it's not related to the delete everything on network logic as this is handled above.
    // this could be a last sync configuration message not being sent.
    // in all case, we delete everything, and restart
    window?.log?.error(
      'Something went wrong deleting all data:',
      error && error.stack ? error.stack : error
    );
    try {
      await deleteDbLocally();
    } catch (e) {
      window?.log?.error(e);
    }
    window.restart();
  }
}
