/* 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/. */

const kLoginsKey =
  "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2";

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

import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs";
import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  OSCrypto: "resource://gre/modules/OSCrypto_win.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  ctypes: "resource://gre/modules/ctypes.sys.mjs",
});

// Resources

function History() {}

History.prototype = {
  type: MigrationUtils.resourceTypes.HISTORY,

  get exists() {
    return true;
  },

  migrate: function H_migrate(aCallback) {
    let pageInfos = [];
    let typedURLs = MSMigrationUtils.getTypedURLs(
      "Software\\Microsoft\\Internet Explorer"
    );
    let now = new Date();
    let maxDate = new Date(
      Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS
    );

    for (let entry of Cc[
      "@mozilla.org/profile/migrator/iehistoryenumerator;1"
    ].createInstance(Ci.nsISimpleEnumerator)) {
      let url = entry.get("uri").QueryInterface(Ci.nsIURI);
      // MSIE stores some types of URLs in its history that we don't handle,
      // like HTMLHelp and others.  Since we don't properly map handling for
      // all of them we just avoid importing them.
      if (!["http", "https", "ftp", "file"].includes(url.scheme)) {
        continue;
      }

      let title = entry.get("title");
      // Embed visits have no title and don't need to be imported.
      if (!title.length) {
        continue;
      }

      // The typed urls are already fixed-up, so we can use them for comparison.
      let transition = typedURLs.has(url.spec)
        ? lazy.PlacesUtils.history.TRANSITIONS.LINK
        : lazy.PlacesUtils.history.TRANSITIONS.TYPED;

      let time = entry.get("time");

      let visitDate = time ? lazy.PlacesUtils.toDate(time) : null;
      if (visitDate && visitDate < maxDate) {
        continue;
      }

      pageInfos.push({
        url,
        title,
        visits: [
          {
            transition,
            // use the current date if we have no visits for this entry.
            date: visitDate ?? now,
          },
        ],
      });
    }

    // Check whether there is any history to import.
    if (!pageInfos.length) {
      aCallback(true);
      return;
    }

    MigrationUtils.insertVisitsWrapper(pageInfos).then(
      () => aCallback(true),
      () => aCallback(false)
    );
  },
};

// IE form password migrator supporting windows from XP until 7 and IE from 7 until 11
function IE7FormPasswords() {
  // used to distinguish between this migrator and other passwords migrators in tests.
  this.name = "IE7FormPasswords";
}

IE7FormPasswords.prototype = {
  type: MigrationUtils.resourceTypes.PASSWORDS,

  get exists() {
    // work only on windows until 7
    if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
      return false;
    }

    try {
      let nsIWindowsRegKey = Ci.nsIWindowsRegKey;
      let key =
        Cc["@mozilla.org/windows-registry-key;1"].createInstance(
          nsIWindowsRegKey
        );
      key.open(
        nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
        kLoginsKey,
        nsIWindowsRegKey.ACCESS_READ
      );
      let count = key.valueCount;
      key.close();
      return count > 0;
    } catch (e) {
      return false;
    }
  },

  async migrate(aCallback) {
    let uris = []; // the uris of the websites that are going to be migrated
    for (let entry of Cc[
      "@mozilla.org/profile/migrator/iehistoryenumerator;1"
    ].createInstance(Ci.nsISimpleEnumerator)) {
      let uri = entry.get("uri").QueryInterface(Ci.nsIURI);
      // MSIE stores some types of URLs in its history that we don't handle, like HTMLHelp
      // and others. Since we are not going to import the logins that are performed in these URLs
      // we can just skip them.
      if (!["http", "https", "ftp"].includes(uri.scheme)) {
        continue;
      }

      uris.push(uri);
    }
    await this._migrateURIs(uris);
    aCallback(true);
  },

  /**
   * Migrate the logins that were saved for the uris arguments.
   *
   * @param {nsIURI[]} uris - the uris that are going to be migrated.
   */
  async _migrateURIs(uris) {
    this.ctypesKernelHelpers = new MSMigrationUtils.CtypesKernelHelpers();
    this._crypto = new lazy.OSCrypto();
    let nsIWindowsRegKey = Ci.nsIWindowsRegKey;
    let key =
      Cc["@mozilla.org/windows-registry-key;1"].createInstance(
        nsIWindowsRegKey
      );
    key.open(
      nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
      kLoginsKey,
      nsIWindowsRegKey.ACCESS_READ
    );

    let urlsSet = new Set(); // set of the already processed urls.
    // number of the successfully decrypted registry values
    let successfullyDecryptedValues = 0;
    /* The logins are stored in the registry, where the key is a hashed URL and its
     * value contains the encrypted details for all logins for that URL.
     *
     * First iterate through IE history, hashing each URL and looking for a match. If
     * found, decrypt the value, using the URL as a salt. Finally add any found logins
     * to the Firefox password manager.
     */

    let logins = [];
    for (let uri of uris) {
      try {
        // remove the query and the ref parts of the URL
        let urlObject = new URL(uri.spec);
        let url = urlObject.origin + urlObject.pathname;
        // if the current url is already processed, it should be skipped
        if (urlsSet.has(url)) {
          continue;
        }
        urlsSet.add(url);
        // hash value of the current uri
        let hashStr = this._crypto.getIELoginHash(url);
        if (!key.hasValue(hashStr)) {
          continue;
        }
        let value = key.readBinaryValue(hashStr);
        // if no value was found, the uri is skipped
        if (value == null) {
          continue;
        }
        let data;
        try {
          // the url is used as salt to decrypt the registry value
          data = this._crypto.decryptData(value, url);
        } catch (e) {
          continue;
        }
        // extract the login details from the decrypted data
        let ieLogins = this._extractDetails(data, uri);
        // if at least a credential was found in the current data, successfullyDecryptedValues should
        // be incremented by one
        if (ieLogins.length) {
          successfullyDecryptedValues++;
        }
        for (let ieLogin of ieLogins) {
          logins.push({
            username: ieLogin.username,
            password: ieLogin.password,
            origin: ieLogin.url,
            timeCreated: ieLogin.creation,
          });
        }
      } catch (e) {
        console.error("Error while importing logins for ", uri.spec, ": ", e);
      }
    }

    if (logins.length) {
      await MigrationUtils.insertLoginsWrapper(logins);
    }

    // if the number of the imported values is less than the number of values in the key, it means
    // that not all the values were imported and an error should be reported
    if (successfullyDecryptedValues < key.valueCount) {
      console.error(
        "We failed to decrypt and import some logins. " +
          "This is likely because we didn't find the URLs where these " +
          "passwords were submitted in the IE history and which are needed to be used " +
          "as keys in the decryption."
      );
    }

    key.close();
    this._crypto.finalize();
    this.ctypesKernelHelpers.finalize();
  },

  _crypto: null,

  /**
   * Extract the details of one or more logins from the raw decrypted data.
   *
   * @param {string} data - the decrypted data containing raw information.
   * @param {nsURI} uri - the nsURI of page where the login has occur.
   * @returns {object[]} array of objects where each of them contains the username, password, URL,
   * and creation time representing all the logins found in the data arguments.
   */
  _extractDetails(data, uri) {
    // the structure of the header of the IE7 decrypted data for all the logins sharing the same URL
    let loginData = new lazy.ctypes.StructType("loginData", [
      // Bytes 0-3 are not needed and not documented
      { unknown1: lazy.ctypes.uint32_t },
      // Bytes 4-7 are the header size
      { headerSize: lazy.ctypes.uint32_t },
      // Bytes 8-11 are the data size
      { dataSize: lazy.ctypes.uint32_t },
      // Bytes 12-19 are not needed and not documented
      { unknown2: lazy.ctypes.uint32_t },
      { unknown3: lazy.ctypes.uint32_t },
      // Bytes 20-23 are the data count: each username and password is considered as a data
      { dataMax: lazy.ctypes.uint32_t },
      // Bytes 24-35 are not needed and not documented
      { unknown4: lazy.ctypes.uint32_t },
      { unknown5: lazy.ctypes.uint32_t },
      { unknown6: lazy.ctypes.uint32_t },
    ]);

    // the structure of a IE7 decrypted login item
    let loginItem = new lazy.ctypes.StructType("loginItem", [
      // Bytes 0-3 are the offset of the username
      { usernameOffset: lazy.ctypes.uint32_t },
      // Bytes 4-11 are the date
      { loDateTime: lazy.ctypes.uint32_t },
      { hiDateTime: lazy.ctypes.uint32_t },
      // Bytes 12-15 are not needed and not documented
      { foo: lazy.ctypes.uint32_t },
      // Bytes 16-19 are the offset of the password
      { passwordOffset: lazy.ctypes.uint32_t },
      // Bytes 20-31 are not needed and not documented
      { unknown1: lazy.ctypes.uint32_t },
      { unknown2: lazy.ctypes.uint32_t },
      { unknown3: lazy.ctypes.uint32_t },
    ]);

    let url = uri.prePath;
    let results = [];
    let arr = this._crypto.stringToArray(data);
    // convert data to ctypes.unsigned_char.array(arr.length)
    let cdata = lazy.ctypes.unsigned_char.array(arr.length)(arr);
    // Bytes 0-35 contain the loginData data structure for all the logins sharing the same URL
    let currentLoginData = lazy.ctypes.cast(cdata, loginData);
    let headerSize = currentLoginData.headerSize;
    let currentInfoIndex = loginData.size;
    // pointer to the current login item
    let currentLoginItemPointer = lazy.ctypes.cast(
      cdata.addressOfElement(currentInfoIndex),
      loginItem.ptr
    );
    // currentLoginData.dataMax is the data count: each username and password is considered as
    // a data. So, the number of logins is the number of data dived by 2
    let numLogins = currentLoginData.dataMax / 2;
    for (let n = 0; n < numLogins; n++) {
      // Bytes 0-31 starting from currentInfoIndex contain the loginItem data structure for the
      // current login
      let currentLoginItem = currentLoginItemPointer.contents;
      let creation =
        this.ctypesKernelHelpers.fileTimeToSecondsSinceEpoch(
          currentLoginItem.hiDateTime,
          currentLoginItem.loDateTime
        ) * 1000;
      let currentResult = {
        creation,
        url,
      };
      // The username is UTF-16 and null-terminated.
      currentResult.username = lazy.ctypes
        .cast(
          cdata.addressOfElement(
            headerSize + 12 + currentLoginItem.usernameOffset
          ),
          lazy.ctypes.char16_t.ptr
        )
        .readString();
      // The password is UTF-16 and null-terminated.
      currentResult.password = lazy.ctypes
        .cast(
          cdata.addressOfElement(
            headerSize + 12 + currentLoginItem.passwordOffset
          ),
          lazy.ctypes.char16_t.ptr
        )
        .readString();
      results.push(currentResult);
      // move to the next login item
      currentLoginItemPointer = currentLoginItemPointer.increment();
    }
    return results;
  },
};

/**
 * Internet Explorer profile migrator
 */
export class IEProfileMigrator extends MigratorBase {
  static get key() {
    return "ie";
  }

  static get displayNameL10nID() {
    return "migration-wizard-migrator-display-name-ie";
  }

  static get brandImage() {
    return "chrome://browser/content/migration/brands/ie.png";
  }

  getResources() {
    let resources = [MSMigrationUtils.getBookmarksMigrator(), new History()];
    // Only support the form password migrator for Windows XP to 7.
    if (AppConstants.isPlatformAndVersionAtMost("win", "6.1")) {
      resources.push(new IE7FormPasswords());
    }
    let windowsVaultFormPasswordsMigrator =
      MSMigrationUtils.getWindowsVaultFormPasswordsMigrator();
    windowsVaultFormPasswordsMigrator.name = "IEVaultFormPasswords";
    resources.push(windowsVaultFormPasswordsMigrator);
    return resources.filter(r => r.exists);
  }

  async getLastUsedDate() {
    const datePromises = ["Favs", "CookD"].map(dirId => {
      const { path } = Services.dirsvc.get(dirId, Ci.nsIFile);
      return IOUtils.stat(path)
        .then(info => info.lastModified)
        .catch(() => 0);
    });

    const dates = await Promise.all(datePromises);

    try {
      const typedURLs = MSMigrationUtils.getTypedURLs(
        "Software\\Microsoft\\Internet Explorer"
      );
      // typedURLs.values() returns an array of PRTimes, which are in
      // microseconds - convert to milliseconds
      dates.push(Math.max(0, ...typedURLs.values()) / 1000);
    } catch (ex) {}

    return new Date(Math.max(...dates));
  }
}
