/*
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 { User } from "../types.js";
import http from "../utils/http.js";
import constants from "../utils/constants.js";
import TokenManager from "./token-manager.js";
import { EV, EVENTS } from "../common.js";
import { HealthCheck } from "./healthcheck.js";
import Database from "./index.js";
import { SerializedKeyPair, SerializedKey } from "@notesnook/crypto";
import { logger } from "../logger.js";
import { KEY_VERSION, KeyVersion } from "./sync/types.js";
import {
  KeyId,
  KeyManager,
  KeyTypeFromId,
  UnwrapKeyReturnType
} from "./key-manager.js";

const ENDPOINTS = {
  signup: "/users",
  token: "/connect/token",
  user: "/users",
  deleteUser: "/users/delete",
  patchUser: "/account",
  verifyUser: "/account/verify",
  revoke: "/connect/revocation",
  recoverAccount: "/account/recover",
  resetUser: "/users/reset",
  activateTrial: "/subscriptions/trial"
};

class UserManager {
  private tokenManager: TokenManager;
  private keyManager: KeyManager;
  constructor(private readonly db: Database) {
    this.keyManager = new KeyManager(db);
    this.tokenManager = new TokenManager(db.kv, db.eventManager);

    EV.subscribe(EVENTS.userUnauthorized, async (url: string) => {
      if (url.includes("/connect/token") || !(await HealthCheck.auth())) return;
      try {
        await this.tokenManager._refreshToken(true);
      } catch (e) {
        if (
          e instanceof Error &&
          (e.message === "invalid_grant" || e.message === "invalid_client")
        ) {
          await this.logout(
            false,
            `Your token has been revoked. Error: ${e.message}.`
          );
        }
      }
    });
  }

  async init() {
    const user = await this.getUser();
    if (!user) return;
  }

  async signup(email: string, password: string) {
    email = email.toLowerCase();

    const hashedPassword = await this.db.storage().hash(password, email);
    await this.tokenManager.saveToken(
      await http.post(`${constants.API_HOST}${ENDPOINTS.signup}`, {
        email,
        password: hashedPassword,
        client_id: "notesnook"
      })
    );

    const user = await this.fetchUser();
    if (!user) throw new Error("Failed to fetch user after signup.");

    await this.db.setLastSynced(0);
    await this.db.syncer.devices.register();

    await this.db.storage().deriveCryptoKey({
      password,
      salt: user.salt
    });

    const masterKey = await this.getMasterKey();
    if (!masterKey) throw new Error("User encryption key not generated.");
    await this.updateUser({
      dataEncryptionKey: await this.keyManager.wrapKey(
        await this.db.crypto().generateRandomKey(),
        masterKey
      ),
      attachmentsKey: await this.keyManager.wrapKey(
        await this.db.crypto().generateRandomKey(),
        masterKey
      ),
      monographPasswordsKey: await this.keyManager.wrapKey(
        await this.db.crypto().generateRandomKey(),
        masterKey
      )
    });

    this.db.eventManager.publish(EVENTS.userLoggedIn, user);
  }

  async authenticateEmail(email: string) {
    if (!email) throw new Error("Email is required.");

    email = email.toLowerCase();

    const result = await http.post(`${constants.AUTH_HOST}${ENDPOINTS.token}`, {
      email,
      grant_type: "email",
      client_id: "notesnook"
    });

    await this.tokenManager.saveToken(result);
    return result.additional_data;
  }

  async authenticateMultiFactorCode(code: string, method: string) {
    if (!code || !method) throw new Error("code & method are required.");

    const token = await this.tokenManager.getToken();
    if (!token || token.scope !== "auth:grant_types:mfa")
      throw new Error("No token found.");

    await this.tokenManager.saveToken(
      await http.post(
        `${constants.AUTH_HOST}${ENDPOINTS.token}`,
        {
          grant_type: "mfa",
          client_id: "notesnook",
          "mfa:code": code,
          "mfa:method": method
        },
        token.access_token
      )
    );
    return true;
  }

  async authenticatePassword(
    email: string,
    password: string,
    hashedPassword?: string,
    sessionExpired?: boolean
  ) {
    if (!email || !password) throw new Error("email & password are required.");

    const token = await this.tokenManager.getToken();
    if (!token || token.scope !== "auth:grant_types:mfa_password")
      throw new Error("No token found.");

    email = email.toLowerCase();
    if (!hashedPassword) {
      hashedPassword = await this.db.storage().hash(password, email);
    }
    try {
      let usesFallback = false;
      await this.tokenManager.saveToken(
        await http
          .post(
            `${constants.AUTH_HOST}${ENDPOINTS.token}`,
            {
              grant_type: "mfa_password",
              client_id: "notesnook",
              scope: "notesnook.sync offline_access IdentityServerApi",
              password: hashedPassword
            },
            token.access_token
          )
          .catch(async (e) => {
            if (e instanceof Error && e.message === "Password is incorrect.") {
              hashedPassword = await this.db
                .storage()
                .hash(password, email, { usesFallback: true });
              if (hashedPassword === null) return Promise.reject(e);
              usesFallback = true;
              return await http.post(
                `${constants.AUTH_HOST}${ENDPOINTS.token}`,
                {
                  grant_type: "mfa_password",
                  client_id: "notesnook",
                  scope: "notesnook.sync offline_access IdentityServerApi",
                  password: hashedPassword
                },
                token.access_token
              );
            }
            return Promise.reject(e);
          })
      );

      const user = await this.fetchUser();
      if (!user) throw new Error("Failed to fetch user.");

      if (!sessionExpired) {
        await this.db.setLastSynced(0);
        await this.db.syncer.devices.register();
      }

      if (usesFallback) {
        await this.db.storage().deriveCryptoKeyFallback({
          password,
          salt: user.salt
        });
      } else {
        await this.db.storage().deriveCryptoKey({
          password,
          salt: user.salt
        });
      }
      this.db.eventManager.publish(EVENTS.userLoggedIn, user);
    } catch (e) {
      await this.tokenManager.saveToken(token);
      throw e;
    }
  }

  async getSessions() {
    const token = await this.tokenManager.getAccessToken();
    if (!token) return;
    await http.get(`${constants.AUTH_HOST}/account/sessions`, token);
  }

  async clearSessions(all = false) {
    const token = await this.tokenManager.getToken();
    if (!token) return;
    const { access_token, refresh_token } = token;
    await http.post(
      `${constants.AUTH_HOST}/account/sessions/clear?all=${all}`,
      { refresh_token },
      access_token
    );
  }

  async activateTrial() {
    const token = await this.tokenManager.getAccessToken();
    if (!token) return false;
    await http.post(
      `${constants.SUBSCRIPTIONS_HOST}${ENDPOINTS.activateTrial}`,
      null,
      token
    );
    return true;
  }

  async logout(revoke = true, reason?: string) {
    try {
      await this.db.syncer.devices.unregister();
      if (revoke) await this.tokenManager.revokeToken();
    } catch (e) {
      logger.error(e, "Error logging out user.", { revoke, reason });
    } finally {
      this.keyManager.clearCache();
      await this.db.reset();
      this.db.eventManager.publish(EVENTS.userLoggedOut, reason);
      this.db.eventManager.publish(EVENTS.appRefreshRequested);
    }
  }

  setUser(user: User) {
    return this.db.kv().write("user", user);
  }

  getUser() {
    return this.db.kv().read("user");
  }

  /**
   * @deprecated
   */
  getLegacyUser() {
    return this.db.storage().read<User>("user");
  }

  async resetUser(removeAttachments = true) {
    const token = await this.tokenManager.getAccessToken();
    if (!token) return;
    await http.post(
      `${constants.API_HOST}${ENDPOINTS.resetUser}`,
      { removeAttachments },
      token
    );
    return true;
  }

  private async updateUser(partial: Partial<User>) {
    const user = await this.getUser();
    if (!user) return;

    const token = await this.tokenManager.getAccessToken();
    await http.patch.json(
      `${constants.API_HOST}${ENDPOINTS.user}`,
      partial,
      token
    );

    await this.setUser({ ...user, ...partial });
  }

  async deleteUser(password: string) {
    const token = await this.tokenManager.getAccessToken();
    const user = await this.getUser();
    if (!token || !user) return;

    await http.post(
      `${constants.API_HOST}${ENDPOINTS.deleteUser}`,
      {
        password: await this.db.storage().hash(password, user.email, {
          usesFallback: await this.usesFallbackPWHash(password)
        })
      },
      token
    );
    await this.logout(false, "Account deleted.");
    return true;
  }

  async fetchUser(): Promise<User | undefined> {
    this.keyManager.clearCache();

    const oldUser = await this.getUser();
    try {
      const token = await this.tokenManager.getAccessToken();
      if (!token) return;
      const user = await http.get(
        `${constants.API_HOST}${ENDPOINTS.user}`,
        token
      );
      if (user) {
        await this.setUser(user);
        if (
          oldUser &&
          (oldUser.subscription.plan !== user.subscription.plan ||
            oldUser.subscription.status !== user.subscription.status ||
            oldUser.subscription.provider !== user.subscription.provider)
        ) {
          await this.tokenManager._refreshToken(true);
          this.db.eventManager.publish(
            EVENTS.userSubscriptionUpdated,
            user.subscription
          );
        }
        if (oldUser && !oldUser.isEmailConfirmed && user.isEmailConfirmed)
          this.db.eventManager.publish(EVENTS.userEmailConfirmed);
        this.db.eventManager.publish(EVENTS.userFetched, user);
        return user;
      } else {
        return oldUser;
      }
    } catch (e) {
      logger.error(e, "Error fetching user");
      return oldUser;
    }
  }

  changePassword(oldPassword: string, newPassword: string) {
    return this._updatePassword("change", {
      old_password: oldPassword,
      new_password: newPassword
    });
  }

  async changeMarketingConsent(enabled: boolean) {
    const token = await this.tokenManager.getAccessToken();
    if (!token) return;

    await http.patch(
      `${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
      {
        type: "change_marketing_consent",
        enabled: enabled
      },
      token
    );
  }

  resetPassword(newPassword: string) {
    return this._updatePassword("reset", {
      new_password: newPassword
    });
  }

  async getDataEncryptionKeys(): Promise<
    { version: KeyVersion; key: SerializedKey }[] | undefined
  > {
    const masterKey = await this.getMasterKey();
    if (!masterKey) return;

    const dataEncryptionKey = await this.keyManager.get("dataEncryptionKey", {
      refetchUser: false
    });
    if (!dataEncryptionKey)
      return [
        {
          key: masterKey,
          version: KEY_VERSION.LEGACY
        }
      ];
    const keys: { version: KeyVersion; key: SerializedKey }[] = [];

    const legacyDataEncryptionKey = await this.keyManager.get(
      "legacyDataEncryptionKey",
      {
        refetchUser: false
      }
    );
    if (legacyDataEncryptionKey)
      keys.push({
        key: await this.keyManager.unwrapKey(
          legacyDataEncryptionKey,
          masterKey
        ),
        version: KEY_VERSION.LEGACY
      });
    keys.push({
      key: await this.keyManager.unwrapKey(dataEncryptionKey, masterKey),
      version: KEY_VERSION.DEK
    });
    return keys;
  }

  async getMasterKey(): Promise<SerializedKey | undefined> {
    const user = await this.getUser();
    if (!user) return;
    const key = await this.db.storage().getCryptoKey();
    if (!key) return;
    return { key, salt: user.salt };
  }

  /**
   * @deprecated
   */
  async getLegacyEncryptionKey(): Promise<SerializedKey | undefined> {
    const user = await this.getLegacyUser();
    if (!user) return;
    const key = await this.db.storage().getCryptoKey();
    if (!key) return;
    return { key, salt: user.salt };
  }

  private async getUserKey<TId extends KeyId>(
    id: TId,
    config: {
      generateKey: () => Promise<SerializedKey | SerializedKeyPair>;
      errorContext: string;
    }
  ): Promise<UnwrapKeyReturnType<KeyTypeFromId<TId>> | undefined> {
    try {
      const masterKey = await this.getMasterKey();
      if (!masterKey) return;

      const wrappedKey = await this.keyManager.get(id);

      if (!wrappedKey) {
        const key = await config.generateKey();
        await this.updateUser({
          [id]: await this.keyManager.wrapKey(key, masterKey)
        });
        return key as UnwrapKeyReturnType<KeyTypeFromId<TId>>;
      }

      return (await this.keyManager.unwrapKey(
        wrappedKey,
        masterKey
      )) as UnwrapKeyReturnType<KeyTypeFromId<TId>>;
    } catch (e) {
      logger.error(e, `Could not get ${config.errorContext}.`);
      if (e instanceof Error)
        throw new Error(
          `Could not get ${config.errorContext}. Error: ${e.message}`
        );
    }
  }

  async getAttachmentsKey() {
    return this.getUserKey("attachmentsKey", {
      generateKey: () => this.db.crypto().generateRandomKey(),
      errorContext: "attachments encryption key"
    });
  }

  async getMonographPasswordsKey() {
    return this.getUserKey("monographPasswordsKey", {
      generateKey: () => this.db.crypto().generateRandomKey(),
      errorContext: "monographs encryption key"
    });
  }

  async getInboxKeys() {
    return this.getUserKey("inboxKeys", {
      generateKey: () => this.db.crypto().generatePGPKeyPair(),
      errorContext: "inbox encryption keys"
    });
  }

  async hasInboxKeys() {
    const user = await this.getUser();
    if (!user) return false;

    return !!user.inboxKeys;
  }

  async discardInboxKeys() {
    this.keyManager.clearCache();

    const user = await this.getUser();
    if (!user) return;

    const token = await this.tokenManager.getAccessToken();
    await http.patch.json(
      `${constants.API_HOST}${ENDPOINTS.user}`,
      { inboxKeys: { public: null, private: null } },
      token
    );

    await this.setUser({ ...user, inboxKeys: undefined });
  }

  async saveInboxKeys(keys: SerializedKeyPair) {
    const userEncryptionKey = await this.getMasterKey();
    if (!userEncryptionKey) return;

    const updatePayload = {
      inboxKeys: {
        public: keys.publicKey,
        private: await this.db
          .storage()
          .encrypt(userEncryptionKey, keys.privateKey)
      }
    };
    await this.updateUser(updatePayload);
    this.keyManager.clearCache();
  }

  async sendVerificationEmail(newEmail?: string) {
    const token = await this.tokenManager.getAccessToken();
    if (!token) return;
    await http.post(
      `${constants.AUTH_HOST}${ENDPOINTS.verifyUser}`,
      { newEmail },
      token
    );
  }

  async changeEmail(newEmail: string, password: string, code: string) {
    const token = await this.tokenManager.getAccessToken();
    if (!token) return;

    const user = await this.getUser();
    if (!user) return;

    const email = newEmail.toLowerCase();

    try {
      await http.patch(
        `${constants.AUTH_HOST}${ENDPOINTS.patchUser}`,
        {
          type: "change_email",
          new_email: newEmail,
          password: await this.db.storage().hash(password, email, {
            usesFallback: await this.usesFallbackPWHash(password)
          }),
          verification_code: code
        },
        token
      );
    } catch (e) {
      const error = e as Error;
      if (error.message === "Invalid token.") throw new Error("Invalid code.");
      throw error;
    }
  }

  recoverAccount(email: string) {
    return http.post(`${constants.AUTH_HOST}${ENDPOINTS.recoverAccount}`, {
      email,
      client_id: "notesnook"
    });
  }

  async verifyPassword(password: string) {
    try {
      const user = await this.getUser();
      const key = await this.getMasterKey();
      if (!user || !key) return false;

      const cipher = await this.db.storage().encrypt(key, "notesnook");
      const plainText = await this.db.storage().decrypt({ password }, cipher);
      return plainText === "notesnook";
    } catch (e) {
      logger.error(e);
      return false;
    }
  }

  async _updatePassword(
    type: "change" | "reset",
    data: {
      new_password: string;
      old_password?: string;
      encryptionKey?: SerializedKey;
    }
  ) {
    const token = await this.tokenManager.getAccessToken();
    const user = await this.getUser();
    if (!token || !user) throw new Error("You are not logged in.");

    const { email, salt } = user;

    const { new_password, old_password } = data;
    if (old_password && !(await this.verifyPassword(old_password)))
      throw new Error("Incorrect old password.");

    const oldPassword = old_password
      ? // we don't lowercase email here to allow user accounts with
        // mixed cased emails to change their passwords. Once that is done,
        // we will lowercase the email in the backend.
        await this.db.storage().hash(old_password, email, {
          usesFallback: await this.usesFallbackPWHash(old_password)
        })
      : null;

    if (!new_password) throw new Error("New password is required.");

    data.encryptionKey = data.encryptionKey || (await this.getMasterKey());

    const updateUserPayload: Partial<User> = {};
    if (data.encryptionKey) {
      const newMasterKey = await this.db
        .storage()
        .generateCryptoKey(new_password, salt);
      if (user.attachmentsKey) {
        updateUserPayload.attachmentsKey = await this.keyManager.rewrapKey(
          user.attachmentsKey,
          data.encryptionKey,
          newMasterKey
        );
      }
      if (user.monographPasswordsKey) {
        updateUserPayload.monographPasswordsKey =
          await this.keyManager.rewrapKey(
            user.monographPasswordsKey,
            data.encryptionKey,
            newMasterKey
          );
      }
      if (user.inboxKeys) {
        updateUserPayload.inboxKeys = await this.keyManager.rewrapKey(
          user.inboxKeys,
          data.encryptionKey,
          newMasterKey
        );
      }

      if (user.legacyDataEncryptionKey)
        updateUserPayload.legacyDataEncryptionKey =
          await this.keyManager.rewrapKey(
            user.legacyDataEncryptionKey,
            data.encryptionKey,
            newMasterKey
          );
      if (user.dataEncryptionKey)
        updateUserPayload.dataEncryptionKey = await this.keyManager.rewrapKey(
          user.dataEncryptionKey,
          data.encryptionKey,
          newMasterKey
        );
      else {
        updateUserPayload.dataEncryptionKey = await this.keyManager.wrapKey(
          await this.db.crypto().generateRandomKey(),
          newMasterKey
        );
        updateUserPayload.legacyDataEncryptionKey =
          await this.keyManager.wrapKey(data.encryptionKey, newMasterKey);
      }
    }

    await http.patch.json(
      `${constants.API_HOST}/users/password/${type}`,
      {
        oldPassword: oldPassword,
        newPassword: await this.db
          .storage()
          .hash(new_password, email.toLowerCase()),
        userKeys: updateUserPayload
      },
      token
    );

    await this.db.storage().deriveCryptoKey({
      password: new_password,
      salt
    });

    this.keyManager.clearCache();
    await this.setUser({ ...user, ...updateUserPayload });

    return true;
  }

  private async usesFallbackPWHash(password: string) {
    const user = await this.getUser();
    const encryptionKey = await this.getMasterKey();
    if (!user || !encryptionKey) return false;
    const fallbackCryptoKey = await this.db
      .storage()
      .generateCryptoKeyFallback(password, user.salt);
    if (!fallbackCryptoKey) return false;
    const cryptoKey = await this.db
      .storage()
      .generateCryptoKey(password, user.salt);

    if (!encryptionKey.key || !fallbackCryptoKey.key || !cryptoKey.key)
      throw new Error("Failed to generate crypto keys.");

    if (
      fallbackCryptoKey.key !== encryptionKey.key &&
      cryptoKey.key !== encryptionKey.key
    )
      throw new Error("Wrong password.");

    return fallbackCryptoKey.key === encryptionKey.key;
  }
}

export default UserManager;
