/*
This file is part of the Notesnook project (https://notesnook.com/)

Copyright (C) 2023 Streetwriters (Private) Limited

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/

import Sodium, { Cipher, Password } from "@ammarahmed/react-native-sodium";
import { SerializedKey } from "@notesnook/crypto";
import { Platform } from "react-native";
import "react-native-get-random-values";
import * as Keychain from "react-native-keychain";
import { MMKVLoader, ProcessingModes } from "react-native-mmkv-storage";
import { generateSecureRandom } from "react-native-securerandom";
import { DatabaseLogger } from ".";
import { MMKV } from "./mmkv";

// Database key cipher is persisted across different user sessions hence it has
// it's independent storage which we will never clear. This is only used when application has
// app lock with password enabled.
export const CipherStorage = new MMKVLoader()
  .withInstanceID("cipher_storage")
  .setProcessingMode(
    Platform.OS === "ios"
      ? ProcessingModes.MULTI_PROCESS
      : ProcessingModes.SINGLE_PROCESS
  )
  .disableIndexing()
  .initialize();

const IOS_KEYCHAIN_ACCESS_GROUP = "group.org.streetwriters.notesnook";
const IOS_KEYCHAIN_SERVICE_NAME = "org.streetwriters.notesnook";
const KEYCHAIN_SERVER_DBKEY = "notesnook:db";

const NOTESNOOK_APPLOCK_KEY_SALT = "kBwr1Kre86ebOZ8ThLu2OA";
const NOTESNOOK_DB_KEY_SALT = "SNuzOcEK3amoqL0WvPeKqw";

const DB_KEY_CIPHER = "databaseKeyCipher";
const USER_KEY_CIPHER = "userKeyCipher";
const APPLOCK_CIPHER = "applockCipher";

const KEYSTORE_CONFIG = Platform.select({
  ios: {
    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
    accessGroup: IOS_KEYCHAIN_ACCESS_GROUP,
    service: IOS_KEYCHAIN_SERVICE_NAME
  },
  android: {}
});

function generatePassword() {
  const length = 80;
  //@ts-ignore
  const crypto = window.crypto || window.msCrypto;
  if (typeof crypto === "undefined") {
    throw new Error(
      "Crypto API is not supported. Please upgrade your web browser"
    );
  }
  const charset =
    "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&+_{}[]():<>/?;";
  const indexes = crypto.getRandomValues(new Uint32Array(length));
  let secret = "";
  for (const index of indexes) {
    secret += charset[index % charset.length];
  }
  return secret;
}

export async function encryptDatabaseKeyWithPassword(appLockPassword: string) {
  const key = (await getDatabaseKey()) as string;
  const appLockCredentials = await Sodium.deriveKey(
    appLockPassword,
    NOTESNOOK_APPLOCK_KEY_SALT
  );
  const databaseKeyCipher = (await encrypt(appLockCredentials, key)) as Cipher;
  MMKV.setMap(DB_KEY_CIPHER, databaseKeyCipher);
  // We reset the database key from keychain once app lock password is set.
  await Keychain.resetInternetCredentials(KEYCHAIN_SERVER_DBKEY);
  return true;
}

export async function restoreDatabaseKeyToKeyChain(appLockPassword: string) {
  const databaseKeyCipher = CipherStorage.getMap(DB_KEY_CIPHER) as Cipher;
  const databaseKey = (await decrypt(
    {
      password: appLockPassword
    },
    databaseKeyCipher
  )) as string;

  await Keychain.setInternetCredentials(
    KEYCHAIN_SERVER_DBKEY,
    "notesnook",
    databaseKey,
    KEYSTORE_CONFIG
  );
  MMKV.removeItem(DB_KEY_CIPHER);
  return true;
}

export async function setAppLockVerificationCipher(appLockPassword: string) {
  try {
    const appLockCredentials = await Sodium.deriveKey(
      appLockPassword,
      NOTESNOOK_APPLOCK_KEY_SALT
    );
    const encrypted = (await encrypt(
      appLockCredentials,
      generatePassword()
    )) as Cipher;
    CipherStorage.setMap(APPLOCK_CIPHER, encrypted);
    DatabaseLogger.info("setAppLockVerificationCipher");
  } catch (e) {
    DatabaseLogger.error(e);
  }
}

export async function clearAppLockVerificationCipher() {
  CipherStorage.removeItem(APPLOCK_CIPHER);
}

export async function validateAppLockPassword(appLockPassword: string) {
  try {
    const appLockCipher: Cipher = CipherStorage.getMap(
      APPLOCK_CIPHER
    ) as Cipher;
    if (!appLockCipher) return true;
    const key = await Sodium.deriveKey(appLockPassword, appLockCipher.salt);
    const decrypted = await decrypt(key, appLockCipher);

    DatabaseLogger.info(
      `validateAppLockPassword: ${typeof decrypted === "string"}`
    );
    return typeof decrypted === "string";
  } catch (e) {
    DatabaseLogger.error(e);
    return false;
  }
}

let DB_KEY: string | undefined;
export function clearDatabaseKey() {
  DB_KEY = undefined;
  DatabaseLogger.info("Cleared database key");
}

export async function getDatabaseKey(appLockPassword?: string) {
  if (DB_KEY) return DB_KEY;
  if (appLockPassword) {
    const databaseKeyCipher: Cipher = CipherStorage.getMap(
      "databaseKeyCipher"
    ) as Cipher;
    const databaseKey = await decrypt(
      {
        password: appLockPassword
      },
      databaseKeyCipher
    );
    DatabaseLogger.info("Getting database key from cipher");
    DB_KEY = databaseKey;
  }

  if (!DB_KEY) {
    const hasKey = await Keychain.hasInternetCredentials(KEYCHAIN_SERVER_DBKEY);
    if (hasKey) {
      const credentials = await Keychain.getInternetCredentials(
        KEYCHAIN_SERVER_DBKEY
      );

      DatabaseLogger.info("Getting database key from Keychain");
      DB_KEY = (credentials as Keychain.UserCredentials).password;
    }
  }

  if (!DB_KEY) {
    DatabaseLogger.info("Generating new database key");
    const password = generatePassword();
    const derivedDatabaseKey = await Sodium.deriveKey(
      password,
      NOTESNOOK_DB_KEY_SALT
    );

    DB_KEY = derivedDatabaseKey.key as string;

    await Keychain.setInternetCredentials(
      KEYCHAIN_SERVER_DBKEY,
      "notesnook",
      DB_KEY,
      KEYSTORE_CONFIG
    );
  }

  if (await Keychain.hasInternetCredentials("notesnook")) {
    const userKeyCredentials =
      await Keychain.getInternetCredentials("notesnook");

    if (userKeyCredentials) {
      const userKeyCipher: Cipher = (await encrypt(
        {
          key: DB_KEY,
          salt: NOTESNOOK_DB_KEY_SALT
        },
        userKeyCredentials.password
      )) as Cipher;
      // Store encrypted user key in MMKV
      MMKV.setMap(USER_KEY_CIPHER, userKeyCipher);
      await Keychain.resetInternetCredentials("notesnook");
    }
    DatabaseLogger.info("Migrated user credentials to cipher storage");
  }

  if (!DB_KEY) {
    throw new Error(
      `Failed to get database key, ${await Keychain.hasInternetCredentials(
        KEYCHAIN_SERVER_DBKEY
      )}`
    );
  }

  return DB_KEY;
}

export async function deriveCryptoKeyFallback(data: SerializedKey) {
  if (Platform.OS !== "ios") return;
  try {
    if (!data.password || !data.salt)
      throw new Error(
        "Invalid password and salt provided to deriveCryptoKeyFallback"
      );

    const credentials = await Sodium.deriveKeyFallback?.(
      data.password,
      data.salt
    );

    if (!credentials) return;

    const userKeyCipher = (await encrypt(
      {
        key: (await getDatabaseKey()) as string,
        salt: NOTESNOOK_DB_KEY_SALT
      },
      credentials.key as string
    )) as Cipher<"base64">;
    DatabaseLogger.info("User key fallback stored: ", {
      userKeyCipher: !!userKeyCipher
    });

    // Store encrypted user key in MMKV
    MMKV.setMap(USER_KEY_CIPHER, userKeyCipher);
  } catch (e) {
    DatabaseLogger.error(e);
  }
}

export async function deriveCryptoKey(data: SerializedKey) {
  try {
    if (!data.password || !data.salt)
      throw new Error("Invalid password and salt provided to deriveCryptoKey");

    const credentials = (await Sodium.deriveKey(
      data.password,
      data.salt
    )) as Password;
    const userKeyCipher = (await encrypt(
      {
        key: (await getDatabaseKey()) as string,
        salt: NOTESNOOK_DB_KEY_SALT
      },
      credentials.key as string
    )) as Cipher<"base64">;
    DatabaseLogger.info("User key stored: ", {
      userKeyCipher: !!userKeyCipher
    });

    // Store encrypted user key in MMKV
    MMKV.setMap(USER_KEY_CIPHER, userKeyCipher);
  } catch (e) {
    DatabaseLogger.error(e);
  }
}

export async function getCryptoKey() {
  try {
    const keyCipher: Cipher = MMKV.getMap(USER_KEY_CIPHER) as Cipher;
    if (!keyCipher) {
      DatabaseLogger.info("User key cipher is null");
      return undefined;
    }

    const key = await decrypt(
      {
        key: (await getDatabaseKey()) as string,
        salt: keyCipher.salt
      },
      keyCipher
    );

    return key;
  } catch (e) {
    DatabaseLogger.error(e);
  }
}

export async function removeCryptoKey() {
  try {
    MMKV.removeItem(USER_KEY_CIPHER);
    await Keychain.resetInternetCredentials("notesnook");
    return true;
  } catch (e) {
    DatabaseLogger.error(e);
  }
}

export async function getRandomBytes(length: number) {
  return await generateSecureRandom(length);
}

export async function hash(
  password: string,
  email: string,
  options?: { usesFallback?: boolean }
) {
  DatabaseLogger.log(`Hashing password: fallback: ${options?.usesFallback}`);

  if (options?.usesFallback && Platform.OS !== "ios") {
    return "";
  }

  return (
    options?.usesFallback
      ? await Sodium.hashPasswordFallback?.(password, email)
      : await Sodium.hashPassword(password, email)
  ) as string;
}

export async function generateCryptoKey(password: string, salt?: string) {
  return Sodium.deriveKey(password, salt) as Promise<SerializedKey>;
}

export async function generateCryptoKeyFallback(
  password: string,
  salt?: string
): Promise<SerializedKey> {
  return Sodium.deriveKeyFallback?.(
    password,
    salt as string
  ) as Promise<SerializedKey>;
}

export function getAlgorithm(base64Variant: number) {
  return `xcha-argon2i13-${base64Variant}`;
}

export async function decrypt(password: SerializedKey, data: Cipher<"base64">) {
  const _data = { ...data };
  _data.output = "plain";

  if (!password.salt) password.salt = data.salt;

  if (Platform.OS === "ios" && !password.key && password.password) {
    const key = await Sodium.deriveKey(password.password, password.salt);
    try {
      return await Sodium.decrypt(key, _data);
    } catch (e) {
      const fallbackKey = await Sodium.deriveKeyFallback?.(
        password.password,
        password.salt
      );
      if (Platform.OS === "ios" && fallbackKey) {
        DatabaseLogger.info("Using fallback key for decryption");
      }
      if (fallbackKey) {
        return await Sodium.decrypt(fallbackKey, _data);
      } else {
        throw e;
      }
    }
  }

  return await Sodium.decrypt(password, _data);
}

export async function decryptMulti(
  password: Password,
  data: Cipher<"base64">[]
) {
  data = data.map((d) => {
    d.output = "plain";
    return d;
  });

  if (data.length && !password.salt) {
    password.salt = data[0].salt;
  }

  if (Platform.OS === "ios" && !password.key && password.password) {
    const key = await Sodium.deriveKey(password.password, password.salt);
    try {
      return await Sodium.decryptMulti(key, data);
    } catch (e) {
      const fallbackKey = await Sodium.deriveKeyFallback?.(
        password.password,
        password.salt as string
      );
      if (Platform.OS === "ios" && fallbackKey) {
        DatabaseLogger.info("Using fallback key for decryption");
      }
      if (fallbackKey) {
        return await Sodium.decryptMulti(fallbackKey, data);
      } else {
        throw e;
      }
    }
  }

  return await Sodium.decryptMulti(password, data);
}

export function parseAlgorithm(alg: string) {
  if (!alg) return {};
  const [enc, kdf, compressed, compressionAlg, base64variant] = alg.split("-");
  return {
    encryptionAlgorithm: enc,
    kdfAlgorithm: kdf,
    compressionAlgorithm: compressionAlg,
    isCompress: compressed === "1",
    base64_variant: base64variant
  };
}

export async function encrypt(password: SerializedKey, plainText: string) {
  const result = await Sodium.encrypt<"base64">(password, {
    type: "plain",
    data: plainText
  });

  return {
    ...result,
    alg: getAlgorithm(7)
  };
}

export async function encryptMulti(
  password: SerializedKey,
  plainText: string[]
) {
  const results = await Sodium.encryptMulti<"base64">(
    password,
    plainText.map((item) => ({
      type: "plain",
      data: item
    }))
  );

  return !results
    ? []
    : results.map((result) => ({
        ...result,
        alg: getAlgorithm(7)
      }));
}
