/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
  BackupError: "resource:///modules/backup/BackupError.mjs",
  ERRORS: "chrome://browser/content/backup/backup-constants.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "isBrowsingHistoryEnabled",
  "places.history.enabled",
  true
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "isSanitizeOnShutdownEnabled",
  "privacy.sanitize.sanitizeOnShutdown",
  false
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "isHistoryClearedOnShutdown",
  "privacy.clearOnShutdown_v2.browsingHistoryAndDownloads",
  false
);

// Convert from bytes to kilobytes (not kibibytes).
export const BYTES_IN_KB = 1000;

/**
 * Convert bytes to the nearest multiple of 10 kilobytes to make the measurements fuzzier.
 * Returns 1 if size is < 5 kB.
 *
 * @param {number} bytes - size in bytes.
 * @returns {number} - size in kilobytes, rounded to the nearest multiple of 10
 */
export function bytesToFuzzyKilobytes(bytes) {
  let sizeInKb = Math.ceil(bytes / BYTES_IN_KB);
  let nearestTenKb = Math.round(sizeInKb / 10) * 10;
  return Math.max(nearestTenKb, 1);
}

/**
 * An abstract class representing a set of data within a user profile
 * that can be persisted to a separate backup archive file, and restored
 * to a new user profile from that backup archive file.
 */
export class BackupResource {
  /**
   * This must be overridden to return a simple string identifier for the
   * resource, for example "places" or "extensions". This key is used as
   * a unique identifier for the resource.
   *
   * @type {string}
   */
  static get key() {
    throw new lazy.BackupError(
      "BackupResource::key needs to be overridden.",
      lazy.ERRORS.INTERNAL_ERROR
    );
  }

  /**
   * This must be overridden to return a boolean indicating whether the
   * resource requires encryption when being backed up. Encryption should be
   * required for particularly sensitive data, such as passwords / credentials,
   * cookies, or payment methods. If you're not sure, talk to someone from the
   * Privacy team.
   *
   * @type {boolean}
   */
  static get requiresEncryption() {
    throw new lazy.BackupError(
      "BackupResource::requiresEncryption needs to be overridden.",
      lazy.ERRORS.INTERNAL_ERROR
    );
  }

  /**
   * This can be overridden to return a number indicating the priority the
   * resource should have in the backup order.
   *
   * Resources with a higher priority will be backed up first.
   * The default priority of 0 indicates it can be processed in any order.
   *
   * @returns {number}
   */
  static get priority() {
    return 0;
  }

  /**
   * Get the size of a file.
   *
   * @param {string} filePath - path to a file.
   * @returns {Promise<number|null>} - the size of the file in kilobytes, or null if the
   * file does not exist, the path is a directory or the size is unknown.
   */
  static async getFileSize(filePath) {
    if (!(await IOUtils.exists(filePath))) {
      return null;
    }

    let { size } = await IOUtils.stat(filePath);

    if (size < 0) {
      return null;
    }

    let nearestTenKb = bytesToFuzzyKilobytes(size);

    return nearestTenKb;
  }

  /**
   * Get the total size of a directory.
   *
   * @param {string} directoryPath - path to a directory.
   * @param {object}   options - A set of additional optional parameters.
   * @param {Function} [options.shouldExclude] - an optional callback which based on file path and file type should return true
   * if the file should be excluded from the computed directory size.
   * @returns {Promise<number|null>} - the size of all descendants of the directory in kilobytes, or null if the
   * directory does not exist, the path is not a directory or the size is unknown.
   */
  static async getDirectorySize(
    directoryPath,
    { shouldExclude = () => false } = {}
  ) {
    if (!(await IOUtils.exists(directoryPath))) {
      return null;
    }

    let { type } = await IOUtils.stat(directoryPath);

    if (type != "directory") {
      return null;
    }

    let children = await IOUtils.getChildren(directoryPath, {
      ignoreAbsent: true,
    });

    let size = 0;
    for (const childFilePath of children) {
      let { size: childSize, type: childType } =
        await IOUtils.stat(childFilePath);

      if (shouldExclude(childFilePath, childType, directoryPath)) {
        continue;
      }

      if (childSize >= 0) {
        let nearestTenKb = bytesToFuzzyKilobytes(childSize);

        size += nearestTenKb;
      }

      if (childType == "directory") {
        let childDirectorySize = await this.getDirectorySize(childFilePath, {
          shouldExclude,
        });
        if (Number.isInteger(childDirectorySize)) {
          size += childDirectorySize;
        }
      }
    }

    return size;
  }

  /**
   * Copy a set of SQLite databases safely from a source directory to a
   * destination directory. A new read-only connection is opened for each
   * database, and then a backup is created. If the source database does not
   * exist, it is ignored.
   *
   * @param {string} sourcePath
   *   Path to the source directory of the SQLite databases.
   * @param {string} destPath
   *   Path to the destination directory where the SQLite databases should be
   *   copied to.
   * @param {Array<string>} sqliteDatabases
   *   An array of filenames of the SQLite databases to copy.
   * @returns {Promise<undefined>}
   */
  static async copySqliteDatabases(sourcePath, destPath, sqliteDatabases) {
    for (let fileName of sqliteDatabases) {
      let sourceFilePath = PathUtils.join(sourcePath, fileName);

      if (!(await IOUtils.exists(sourceFilePath))) {
        continue;
      }

      let destFilePath = PathUtils.join(destPath, fileName);
      let connection;

      try {
        connection = await lazy.Sqlite.openConnection({
          path: sourceFilePath,
          readOnly: true,
        });

        await connection.backup(
          destFilePath,
          BackupResource.SQLITE_PAGES_PER_STEP,
          BackupResource.SQLITE_STEP_DELAY_MS
        );
      } finally {
        await connection?.close();
      }
    }
  }

  /**
   * A helper function to copy a set of files from a source directory to a
   * destination directory. Callers should ensure that the source files can be
   * copied safely before invoking this function. Files that do not exist will
   * be ignored. Callers that wish to copy SQLite databases should use
   * copySqliteDatabases() instead.
   *
   * @param {string} sourcePath
   *   Path to the source directory of the files to be copied.
   * @param {string} destPath
   *   Path to the destination directory where the files should be
   *   copied to.
   * @param {string[]} fileNames
   *   An array of filenames of the files to copy.
   * @returns {Promise<undefined>}
   */
  static async copyFiles(sourcePath, destPath, fileNames) {
    for (let fileName of fileNames) {
      let sourceFilePath = PathUtils.join(sourcePath, fileName);
      let destFilePath = PathUtils.join(destPath, fileName);
      if (await IOUtils.exists(sourceFilePath)) {
        await IOUtils.copy(sourceFilePath, destFilePath, { recursive: true });
      }
    }
  }

  /**
   * Returns true if the browser is configured in such a way that backing up
   * things related to browsing history is allowed. Otherwise, returns false.
   *
   * @returns {boolean}
   */

  /**
   * Returns true if the resource is enabled for backup based on different
   * browser preferences and configurations. Otherwise, returns false.
   *
   * @returns {boolean}
   */
  static get canBackupResource() {
    // This is meant to be overridden if a resource requires checks; default is true.
    return true;
  }

  /**
   * Helper function to see if we are going to be backing up and restoring places.sqlite
   *
   * @returns {boolean}
   */
  static get backingUpPlaces() {
    if (
      lazy.PrivateBrowsingUtils.permanentPrivateBrowsing ||
      !lazy.isBrowsingHistoryEnabled
    ) {
      return false;
    }

    if (!lazy.isSanitizeOnShutdownEnabled) {
      return true;
    }

    if (lazy.isHistoryClearedOnShutdown) {
      return false;
    }

    return true;
  }

  constructor() {}

  /**
   * This must be overridden to record telemetry on the size of any
   * data associated with this BackupResource.
   *
   * @param {string} profilePath - path to a profile directory.
   * @returns {Promise<undefined>}
   */
  // eslint-disable-next-line no-unused-vars
  async measure(profilePath) {
    throw new lazy.BackupError(
      "BackupResource::measure needs to be overridden.",
      lazy.ERRORS.INTERNAL_ERROR
    );
  }

  /**
   * Perform a safe copy of the datastores that this resource manages and write
   * them into the backup database. The Promise should resolve with an object
   * that can be serialized to JSON, as it will be written to the manifest file.
   * This same object will be deserialized and passed to restore() when
   * restoring the backup. This object can be null if no additional information
   * is needed to restore the backup.
   *
   * @param {string} stagingPath
   *   The path to the staging folder where copies of the datastores for this
   *   BackupResource should be written to.
   * @param {string} [profilePath=null]
   *   This is null if the backup is being run on the currently running user
   *   profile. If, however, the backup is being run on a different user profile
   *   (for example, it's being run from a BackgroundTask on a user profile that
   *   just shut down, or during test), then this is a string set to that user
   *   profile path.
   * @param {boolean} [isEncrypting=false]
   *   True if the backup is being encrypted. A BackupResource may not require
   *   encryption, but might still choose to behave differently when encrypting,
   *   so this flag can be used to support that kind of behaviour.
   *
   * @returns {Promise<object|null>}
   */
  // eslint-disable-next-line no-unused-vars
  async backup(stagingPath, profilePath = null, isEncrypting = false) {
    throw new lazy.BackupError(
      "BackupResource::backup must be overridden",
      lazy.ERRORS.INTERNAL_ERROR
    );
  }

  /**
   * Recovers the datastores that this resource manages from a backup archive
   * that has been decompressed into the recoveryPath. A pre-existing unlocked
   * user profile should be available to restore into, and destProfilePath
   * should point at its location on the file system.
   *
   * This method is not expected to be running in an app connected to the
   * destProfilePath. If the BackupResource needs to run some operations
   * while attached to the recovery profile, it should do that work inside of
   * postRecovery(). If data needs to be transferred to postRecovery(), it
   * should be passed as a JSON serializable object in the return value of this
   * method.
   *
   * @see BackupResource.postRecovery()
   * @param {object|null} manifestEntry
   *   The object that was returned by the backup() method when the backup was
   *   created. This object can be null if no additional information was needed
   *   for recovery.
   * @param {string} recoveryPath
   *   The path to the resource directory where the backup archive has been
   *   decompressed.
   * @param {string} destProfilePath
   *   The path to the profile directory where the backup should be restored to.
   * @returns {Promise<object|null>}
   *   This should return a JSON serializable object that will be passed to
   *   postRecovery() if any data needs to be passed to it. This object can be
   *   null if no additional information is needed for postRecovery().
   */
  // eslint-disable-next-line no-unused-vars
  async recover(manifestEntry, recoveryPath, destProfilePath) {
    throw new lazy.BackupError(
      "BackupResource::recover must be overridden",
      lazy.ERRORS.INTERNAL_ERROR
    );
  }

  /**
   * Perform any post-recovery operations that need to be done after the
   * recovery has been completed and the recovered profile has been attached
   * to.
   *
   * This method is running in an app connected to the recovered profile. The
   * profile is locked, but this postRecovery method can be used to insert
   * data into connected datastores, or perform any other operations that can
   * only occur within the context of the recovered profile.
   *
   * @see BackupResource.recover()
   * @param {object|null} postRecoveryEntry
   *  The object that was returned by the recover() method when the recovery
   *  was originally done. This object can be null if no additional information
   *  is needed for post-recovery.
   */
  // eslint-disable-next-line no-unused-vars
  async postRecovery(postRecoveryEntry) {
    // no-op by default
  }
}

XPCOMUtils.defineLazyPreferenceGetter(
  BackupResource,
  "SQLITE_PAGES_PER_STEP",
  "browser.backup.sqlite.pages_per_step",
  5
);

XPCOMUtils.defineLazyPreferenceGetter(
  BackupResource,
  "SQLITE_STEP_DELAY_MS",
  "browser.backup.sqlite.step_delay_ms",
  250
);
