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

Copyright (C) 2022 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 { CHECK_IDS, EV, EVENTS, checkIsUserPremium } from "../common";
import { tinyToTiptap } from "../migrations";

const ERASE_TIME = 1000 * 60 * 30;
var ERASER_TIMEOUT = null;
export default class Vault {
  get _password() {
    return this._vaultPassword;
  }

  set _password(value) {
    this._vaultPassword = value;
    if (value) {
      this._startEraser();
    }
  }

  _startEraser() {
    clearTimeout(ERASER_TIMEOUT);
    ERASER_TIMEOUT = setTimeout(() => {
      this._password = null;
      EV.publish(EVENTS.vaultLocked);
    }, ERASE_TIME);
  }

  /**
   *
   * @param {import('./index').default} db
   */
  constructor(db) {
    this._db = db;
    this._storage = db.storage;
    this._key = "svvaads1212#2123";
    this._vaultPassword = null;
    this.ERRORS = {
      noVault: "ERR_NO_VAULT",
      vaultLocked: "ERR_VAULT_LOCKED",
      wrongPassword: "ERR_WRONG_PASSWORD"
    };
    EV.subscribe(EVENTS.userLoggedOut, () => {
      this._password = null;
    });
  }

  /**
   * Creates a new vault
   * @param {string} password The password
   * @returns {Promise<Boolean>}
   */
  async create(password) {
    if (!(await checkIsUserPremium(CHECK_IDS.vaultAdd))) return;

    const vaultKey = await this._getKey();
    if (!vaultKey || !vaultKey.cipher || !vaultKey.iv) {
      const encryptedData = await this._storage.encrypt(
        { password },
        this._key
      );
      await this._setKey(encryptedData);
      this._password = password;
    }
    return true;
  }

  /**
   * Unlocks the vault with the given password
   * @param {string} password The password
   * @throws  ERR_NO_VAULT | ERR_WRONG_PASSWORD
   * @returns {Promise<Boolean>}
   */
  async unlock(password) {
    const vaultKey = await this._getKey();
    if (!(await this.exists(vaultKey))) throw new Error(this.ERRORS.noVault);
    try {
      await this._storage.decrypt({ password }, vaultKey);
    } catch (e) {
      throw new Error(this.ERRORS.wrongPassword);
    }
    this._password = password;
    return true;
  }

  async changePassword(oldPassword, newPassword) {
    if (await this.unlock(oldPassword)) {
      await this._db.notes.init();

      const contentItems = [];
      for (const note of this._db.notes.locked) {
        try {
          let encryptedContent = await this._db.content.raw(note.contentId);
          let content = await this.decryptContent(
            encryptedContent,
            oldPassword
          );
          contentItems.push({
            ...content,
            id: note.contentId,
            noteId: note.id
          });
        } catch (e) {
          throw new Error(
            `Could not decrypt content of note ${note.id}. Error: ${e.message}`
          );
        }
      }

      for (const content of contentItems) {
        await this._encryptContent(
          content.id,
          null,
          content.data,
          content.type,
          newPassword
        );
      }

      await this._storage.remove("vaultKey");
      await this.create(newPassword);
    }
  }

  async clear(password) {
    if (await this.unlock(password)) {
      await this._db.notes.init();
      for (var note of this._db.notes.locked) {
        await this._unlockNote(note, password, true);
      }
    }
  }

  async delete(deleteAllLockedNotes = false) {
    if (deleteAllLockedNotes) {
      await this._db.notes.init();
      await this._db.notes.remove(
        ...this._db.notes.locked.map((note) => note.id)
      );
    }
    await this._storage.remove("vaultKey");
    this._password = null;
  }

  /**
   * Locks (add to vault) a note
   * @param {string} noteId The id of the note to lock
   */
  async add(noteId) {
    if (!(await checkIsUserPremium(CHECK_IDS.vaultAdd))) return;

    await this._check();
    await this._lockNote({ id: noteId }, this._password);
    await this._db.noteHistory.clearSessions(noteId);
  }

  /**
   * Permanently unlocks (remove from vault) a note
   * @param {string} noteId The note id
   * @param {string} password The password to unlock note with
   */
  async remove(noteId, password) {
    const note = this._db.notes.note(noteId).data;
    await this._unlockNote(note, password, true);
  }

  /**
   * Temporarily unlock (open) a note
   * @param {string} noteId The note id
   * @param {string} password The password to open note with
   */
  async open(noteId, password) {
    const note = this._db.notes.note(noteId).data;
    const unlockedNote = await this._unlockNote(note, password, false);
    this._password = password;
    return unlockedNote;
  }

  /**
   * Saves a note in the vault
   * @param {{Object}} note The note to save into the vault
   */
  async save(note) {
    if (!note) return;
    await this._check();
    // roll over erase timer
    this._startEraser();
    return await this._lockNote(note, this._password);
  }

  async exists(vaultKey) {
    if (!vaultKey) vaultKey = await this._getKey();
    return vaultKey && vaultKey.cipher && vaultKey.iv;
  }

  // Private & internal methods

  /** @private */
  _locked() {
    return !this._password || !this._password.length;
  }

  /** @private */
  async _check() {
    if (!(await this.exists())) {
      throw new Error(this.ERRORS.noVault);
    }

    if (this._locked()) {
      throw new Error(this.ERRORS.vaultLocked);
    }
  }

  /** @private */
  async _encryptContent(contentId, sessionId, content, type, password) {
    let encryptedContent = await this._storage.encrypt(
      { password },
      JSON.stringify(content)
    );

    await this._db.content.add({
      id: contentId,
      sessionId,
      data: encryptedContent,
      type
    });
  }

  async decryptContent(encryptedContent, password = null) {
    if (!password) {
      await this._check();
      password = this._password;
    }

    let decryptedContent = await this._storage.decrypt(
      { password },
      encryptedContent.data
    );

    const content = {
      type: encryptedContent.type,
      data: JSON.parse(decryptedContent)
    };

    // #MIGRATION: convert tiny to tiptap
    if (content.type === "tiny") {
      content.type = "tiptap";
      content.data = tinyToTiptap(content.data);
    }

    return content;
  }

  /** @private */
  async _lockNote(note, password) {
    let { id, content: { type, data } = {}, sessionId, title } = note;

    note = this._db.notes.note(id).data;
    const contentId = note.contentId;
    if (!contentId) throw new Error("Cannot lock note because it is empty.");

    // Case: when note is being newly locked
    if (!note.locked && (!data || !type)) {
      let content = await this._db.content.raw(contentId, false);
      // NOTE:
      // At this point, the note already has all the attachments extracted
      // so we should just encrypt it as normal.
      data = content.data;
      type = content.type;
    } else if (data && type) {
      const content = await this._db.content.extractAttachments({
        data,
        type,
        noteId: id
      });
      data = content.data;
      type = content.type;
    }

    if (data && type)
      await this._encryptContent(contentId, sessionId, data, type, password);

    return await this._db.notes.add({
      id,
      locked: true,
      headline: "",
      title: title || note.title,
      favorite: note.favorite,
      localOnly: note.localOnly,
      readonly: note.readonly,
      dateEdited: Date.now()
    });
  }

  /** @private */
  async _unlockNote(note, password, perm = false) {
    let encryptedContent = await this._db.content.raw(note.contentId);
    let content = await this.decryptContent(encryptedContent, password);

    if (perm) {
      await this._db.notes.add({
        id: note.id,
        locked: false,
        headline: note.headline,
        contentId: note.contentId,
        content
      });
      // await this._db.content.add({ id: note.contentId, data: content });
      return;
    }

    return {
      ...note,
      content
    };
  }

  /** @inner */
  async _getKey() {
    return await this._storage.read("vaultKey");
  }

  /** @inner */
  async _setKey(vaultKey) {
    if (!vaultKey) return;
    await this._storage.write("vaultKey", vaultKey);
  }
}
