/* 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 http://mozilla.org/MPL/2.0/. */

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
  MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
});

const S100NS_FROM1601TO1970 = 0x19db1ded53e8000;
const S100NS_PER_MS = 10;

export var ChromeMigrationUtils = {
  // Supported browsers with importable logins.
  CONTEXTUAL_LOGIN_IMPORT_BROWSERS: ["chrome", "chromium-edge", "chromium"],

  _extensionVersionDirectoryNames: {},

  // The cache for the locale strings.
  // For example, the data could be:
  // {
  //   "profile-id-1": {
  //     "extension-id-1": {
  //       "name": {
  //         "message": "Fake App 1"
  //       }
  //   },
  // }
  _extensionLocaleStrings: {},

  get supportsLoginsForPlatform() {
    return ["macosx", "win"].includes(AppConstants.platform);
  },

  /**
   * Get all extensions installed in a specific profile.
   *
   * @param {string} profileId - A Chrome user profile ID. For example, "Profile 1".
   * @returns {Array} All installed Chrome extensions information.
   */
  async getExtensionList(profileId) {
    if (profileId === undefined) {
      profileId = await this.getLastUsedProfileId();
    }
    let path = await this.getExtensionPath(profileId);
    let extensionList = [];
    try {
      for (const child of await IOUtils.getChildren(path)) {
        const info = await IOUtils.stat(child);
        if (info.type === "directory") {
          const name = PathUtils.filename(child);
          let extensionInformation = await this.getExtensionInformation(
            name,
            profileId
          );
          if (extensionInformation) {
            extensionList.push(extensionInformation);
          }
        }
      }
    } catch (ex) {
      console.error(ex);
    }
    return extensionList;
  },

  /**
   * Get information of a specific Chrome extension.
   *
   * @param {string} extensionId - The extension ID.
   * @param {string} profileId - The user profile's ID.
   * @returns {object} The Chrome extension information.
   */
  async getExtensionInformation(extensionId, profileId) {
    if (profileId === undefined) {
      profileId = await this.getLastUsedProfileId();
    }
    let extensionInformation = null;
    try {
      let manifestPath = await this.getExtensionPath(profileId);
      manifestPath = PathUtils.join(manifestPath, extensionId);
      // If there are multiple sub-directories in the extension directory,
      // read the files in the latest directory.
      let directories =
        await this._getSortedByVersionSubDirectoryNames(manifestPath);
      if (!directories[0]) {
        return null;
      }

      manifestPath = PathUtils.join(
        manifestPath,
        directories[0],
        "manifest.json"
      );
      let manifest = await IOUtils.readJSON(manifestPath);
      // No app attribute means this is a Chrome extension not a Chrome app.
      if (!manifest.app) {
        const DEFAULT_LOCALE = manifest.default_locale;
        let name = await this._getLocaleString(
          manifest.name,
          DEFAULT_LOCALE,
          extensionId,
          profileId
        );
        let description = await this._getLocaleString(
          manifest.description,
          DEFAULT_LOCALE,
          extensionId,
          profileId
        );
        if (name) {
          extensionInformation = {
            id: extensionId,
            name,
            description,
          };
        } else {
          throw new Error("Cannot read the Chrome extension's name property.");
        }
      }
    } catch (ex) {
      console.error(ex);
    }
    return extensionInformation;
  },

  /**
   * Get the manifest's locale string.
   *
   * @param {string} key - The key of a locale string, for example __MSG_name__.
   * @param {string} locale - The specific language of locale string.
   * @param {string} extensionId - The extension ID.
   * @param {string} profileId - The user profile's ID.
   * @returns {string|null} The locale string.
   */
  async _getLocaleString(key, locale, extensionId, profileId) {
    if (typeof key !== "string") {
      console.debug("invalid manifest key");
      return null;
    }
    // Return the key string if it is not a locale key.
    // The key string starts with "__MSG_" and ends with "__".
    // For example, "__MSG_name__".
    // https://developer.chrome.com/apps/i18n
    if (!key.startsWith("__MSG_") || !key.endsWith("__")) {
      return key;
    }

    let localeString = null;
    try {
      let localeFile;
      if (
        this._extensionLocaleStrings[profileId] &&
        this._extensionLocaleStrings[profileId][extensionId]
      ) {
        localeFile = this._extensionLocaleStrings[profileId][extensionId];
      } else {
        if (!this._extensionLocaleStrings[profileId]) {
          this._extensionLocaleStrings[profileId] = {};
        }
        let localeFilePath = await this.getExtensionPath(profileId);
        localeFilePath = PathUtils.join(localeFilePath, extensionId);
        let directories =
          await this._getSortedByVersionSubDirectoryNames(localeFilePath);
        // If there are multiple sub-directories in the extension directory,
        // read the files in the latest directory.
        localeFilePath = PathUtils.join(
          localeFilePath,
          directories[0],
          "_locales",
          locale,
          "messages.json"
        );
        localeFile = await IOUtils.readJSON(localeFilePath);
        this._extensionLocaleStrings[profileId][extensionId] = localeFile;
      }
      const PREFIX_LENGTH = 6;
      const SUFFIX_LENGTH = 2;
      // Get the locale key from the string with locale prefix and suffix.
      // For example, it will get the "name" sub-string from the "__MSG_name__" string.
      key = key.substring(PREFIX_LENGTH, key.length - SUFFIX_LENGTH);
      if (localeFile[key] && localeFile[key].message) {
        localeString = localeFile[key].message;
      }
    } catch (ex) {
      console.error(ex);
    }
    return localeString;
  },

  /**
   * Check that a specific extension is installed or not.
   *
   * @param {string} extensionId - The extension ID.
   * @param {string} profileId - The user profile's ID.
   * @returns {boolean} Return true if the extension is installed otherwise return false.
   */
  async isExtensionInstalled(extensionId, profileId) {
    if (profileId === undefined) {
      profileId = await this.getLastUsedProfileId();
    }
    let extensionPath = await this.getExtensionPath(profileId);
    let isInstalled = await IOUtils.exists(
      PathUtils.join(extensionPath, extensionId)
    );
    return isInstalled;
  },

  /**
   * Get the last used user profile's ID.
   *
   * @returns {string} The last used user profile's ID.
   */
  async getLastUsedProfileId() {
    let localState = await this.getLocalState();
    return localState ? localState.profile.last_used : "Default";
  },

  /**
   * Get the local state file content.
   *
   * @param {string} chromeProjectName
   *   The type of Chrome data we're looking for (Chromium, Canary, etc.)
   * @param {string} [dataPath=undefined]
   *   The data path that should be used as the parent directory when getting
   *   the local state. If not supplied, the data path is calculated using
   *   getDataPath and the chromeProjectName.
   * @returns {object} The JSON-based content.
   */
  async getLocalState(chromeProjectName = "Chrome", dataPath) {
    let localState = null;
    try {
      if (!dataPath) {
        dataPath = await this.getDataPath(chromeProjectName);
      }
      let localStatePath = PathUtils.join(dataPath, "Local State");
      localState = JSON.parse(await IOUtils.readUTF8(localStatePath));
    } catch (ex) {
      // Don't report the error if it's just a file not existing.
      if (ex.name != "NotFoundError") {
        console.error(ex);
      }
      throw ex;
    }
    return localState;
  },

  /**
   * Get the path of Chrome extension directory.
   *
   * @param {string} profileId - The user profile's ID.
   * @returns {string} The path of Chrome extension directory.
   */
  async getExtensionPath(profileId) {
    return PathUtils.join(await this.getDataPath(), profileId, "Extensions");
  },

  /**
   * Get the path of an application data directory.
   *
   * @param {string} chromeProjectName - The Chrome project name, e.g. "Chrome", "Canary", etc.
   *                                     Defaults to "Chrome".
   * @returns {string} The path of application data directory.
   */
  async getDataPath(chromeProjectName = "Chrome") {
    const SNAP_REAL_HOME = "SNAP_REAL_HOME";

    const SUB_DIRECTORIES = {
      win: {
        Brave: [
          ["LocalAppData", "BraveSoftware", "Brave-Browser", "User Data"],
        ],
        Chrome: [["LocalAppData", "Google", "Chrome", "User Data"]],
        "Chrome Beta": [["LocalAppData", "Google", "Chrome Beta", "User Data"]],
        Chromium: [["LocalAppData", "Chromium", "User Data"]],
        Canary: [["LocalAppData", "Google", "Chrome SxS", "User Data"]],
        Edge: [["LocalAppData", "Microsoft", "Edge", "User Data"]],
        "Edge Beta": [["LocalAppData", "Microsoft", "Edge Beta", "User Data"]],
        "360 SE": [["AppData", "360se6", "User Data"]],
        Opera: [["AppData", "Opera Software", "Opera Stable"]],
        "Opera GX": [["AppData", "Opera Software", "Opera GX Stable"]],
        Vivaldi: [["LocalAppData", "Vivaldi", "User Data"]],
      },
      macosx: {
        Brave: [
          ["ULibDir", "Application Support", "BraveSoftware", "Brave-Browser"],
        ],
        Chrome: [["ULibDir", "Application Support", "Google", "Chrome"]],
        Chromium: [["ULibDir", "Application Support", "Chromium"]],
        Canary: [["ULibDir", "Application Support", "Google", "Chrome Canary"]],
        Edge: [["ULibDir", "Application Support", "Microsoft Edge"]],
        "Edge Beta": [
          ["ULibDir", "Application Support", "Microsoft Edge Beta"],
        ],
        "Opera GX": [
          ["ULibDir", "Application Support", "com.operasoftware.OperaGX"],
        ],
        Opera: [["ULibDir", "Application Support", "com.operasoftware.Opera"]],
        Vivaldi: [["ULibDir", "Application Support", "Vivaldi"]],
      },
      linux: {
        Brave: [["Home", ".config", "BraveSoftware", "Brave-Browser"]],
        Chrome: [["Home", ".config", "google-chrome"]],
        "Chrome Beta": [["Home", ".config", "google-chrome-beta"]],
        "Chrome Dev": [["Home", ".config", "google-chrome-unstable"]],
        Chromium: [
          ["Home", ".config", "chromium"],

          // If we're installed normally, we can look for Chromium installed
          // as a Snap on Ubuntu Linux by looking here.
          ["Home", "snap", "chromium", "common", "chromium"],

          // If we're installed as a Snap, "Home" is a special place that
          // the Snap environment has given us, and the Chromium data is
          // not within it. We want to, instead, start at the path set
          // on the environment variable "SNAP_REAL_HOME".
          // See: https://snapcraft.io/docs/environment-variables#heading--snap-real-home
          [SNAP_REAL_HOME, "snap", "chromium", "common", "chromium"],
        ],
        // Opera GX is not available on Linux.
        // Canary is not available on Linux.
        // Edge is not available on Linux.
        Opera: [["Home", ".config", "opera"]],
        Vivaldi: [["Home", ".config", "vivaldi"]],
      },
    };
    let options = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName];
    if (!options) {
      return null;
    }

    for (let subfolders of options) {
      let rootDir = subfolders[0];
      try {
        let targetPath;

        if (rootDir == SNAP_REAL_HOME) {
          targetPath = Services.env.get("SNAP_REAL_HOME");
        } else {
          targetPath = Services.dirsvc.get(rootDir, Ci.nsIFile).path;
        }

        targetPath = PathUtils.join(targetPath, ...subfolders.slice(1));
        if (await IOUtils.exists(targetPath)) {
          return targetPath;
        }
      } catch (ex) {
        // The path logic here shouldn't error, so log it:
        console.error(ex);
      }
    }
    return null;
  },

  /**
   * Get the directory objects sorted by version number.
   *
   * @param {string} path - The path to the extension directory.
   * otherwise return all file/directory object.
   * @returns {Array} The file/directory object array.
   */
  async _getSortedByVersionSubDirectoryNames(path) {
    if (this._extensionVersionDirectoryNames[path]) {
      return this._extensionVersionDirectoryNames[path];
    }

    let entries = [];
    try {
      for (const child of await IOUtils.getChildren(path)) {
        const info = await IOUtils.stat(child);
        if (info.type === "directory") {
          const name = PathUtils.filename(child);
          entries.push(name);
        }
      }
    } catch (ex) {
      console.error(ex);
      entries = [];
    }

    // The directory name is the version number string of the extension.
    // For example, it could be "1.0_0", "1.0_1", "1.0_2", 1.1_0, 1.1_1, or 1.1_2.
    // The "1.0_1" strings mean that the "1.0_0" directory is existed and you install the version 1.0 again.
    // https://chromium.googlesource.com/chromium/src/+/0b58a813992b539a6b555ad7959adfad744b095a/chrome/common/extensions/extension_file_util_unittest.cc
    entries.sort((a, b) => Services.vc.compare(b, a));

    this._extensionVersionDirectoryNames[path] = entries;
    return entries;
  },

  /**
   * Convert Chrome time format to Date object. Google Chrome uses FILETIME / 10 as time.
   * FILETIME is based on the same structure of Windows.
   *
   * @param {number} aTime Chrome time
   * @param {string|number|Date} aFallbackValue a date or timestamp (valid argument
   *   for the Date constructor) that will be used if the chrometime value passed is
   *   invalid.
   * @returns {Date} converted Date object
   */
  chromeTimeToDate(aTime, aFallbackValue) {
    // The date value may be 0 in some cases. Because of the subtraction below,
    // that'd generate a date before the unix epoch, which can upset consumers
    // due to the unix timestamp then being negative. Catch this case:
    if (!aTime) {
      return new Date(aFallbackValue);
    }
    return new Date((aTime * S100NS_PER_MS - S100NS_FROM1601TO1970) / 10000);
  },

  /**
   * Convert Date object to Chrome time format. For details on Chrome time, see
   * chromeTimeToDate.
   *
   * @param {Date|number} aDate Date object or integer equivalent
   * @returns {number} Chrome time
   */
  dateToChromeTime(aDate) {
    return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS;
  },

  /**
   * Returns an array of chromium browser ids that have importable logins.
   */
  _importableLoginsCache: null,
  async getImportableLogins(formOrigin) {
    // Only provide importable if we actually support importing.
    if (!this.supportsLoginsForPlatform) {
      return undefined;
    }

    // Lazily fill the cache with all importable login browsers.
    if (!this._importableLoginsCache) {
      this._importableLoginsCache = new Map();

      // Just handle these chromium-based browsers for now.
      for (const browserId of this.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) {
        // Skip if there's no profile data.
        const migrator = await lazy.MigrationUtils.getMigrator(browserId);
        if (!migrator) {
          continue;
        }

        // Check each profile for logins.
        const dataPath = await migrator._getChromeUserDataPathIfExists();
        for (const profile of await migrator.getSourceProfiles()) {
          const path = PathUtils.join(dataPath, profile.id, "Login Data");
          // Skip if login data is missing.
          if (!(await IOUtils.exists(path))) {
            console.error(`Missing file at ${path}`);
            continue;
          }

          try {
            for (const row of await lazy.MigrationUtils.getRowsFromDBWithoutLocks(
              path,
              `Importable ${browserId} logins`,
              `SELECT origin_url
               FROM logins
               WHERE blacklisted_by_user = 0`
            )) {
              const url = row.getString(0);
              try {
                // Initialize an array if it doesn't exist for the origin yet.
                const origin = lazy.LoginHelper.getLoginOrigin(url);
                const entries = this._importableLoginsCache.get(origin) || [];
                if (!entries.length) {
                  this._importableLoginsCache.set(origin, entries);
                }

                // Add the browser if it doesn't exist yet.
                if (!entries.includes(browserId)) {
                  entries.push(browserId);
                }
              } catch (ex) {
                console.error(
                  `Failed to process importable url ${url} from ${browserId}`,
                  ex
                );
              }
            }
          } catch (ex) {
            console.error(
              `Failed to get importable logins from ${browserId}`,
              ex
            );
          }
        }
      }
    }
    return this._importableLoginsCache.get(formOrigin);
  },
};
