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

"use strict";

/**
 * Asynchronous API for managing history.
 *
 *
 * The API makes use of `PageInfo` and `VisitInfo` objects, defined as follows.
 *
 * A `PageInfo` object is any object that contains A SUBSET of the
 * following properties:
 * - guid: (string)
 *     The globally unique id of the page.
 * - url: (URL)
 *     or (nsIURI)
 *     or (string)
 *     The full URI of the page. Note that `PageInfo` values passed as
 *     argument may hold `nsIURI` or `string` values for property `url`,
 *     but `PageInfo` objects returned by this module always hold `URL`
 *     values.
 * - title: (string)
 *     The title associated with the page, if any.
 * - description: (string)
 *     The description of the page, if any.
 * - previewImageURL: (URL)
 *     or (nsIURI)
 *     or (string)
 *     The preview image URL of the page, if any.
 * - frecency: (number)
 *     The frecency of the page, if any.
 *     See https://developer.mozilla.org/en-US/docs/Mozilla/Tech/Places/Frecency_algorithm
 *     Note that this property may not be used to change the actualy frecency
 *     score of a page, only to retrieve it. In other words, any `frecency` field
 *     passed as argument to a function of this API will be ignored.
 *  - visits: (Array<VisitInfo>)
 *     All the visits for this page, if any.
 *  - annotations: (Map)
 *     A map containing key/value pairs of the annotations for this page, if any.
 *
 * See the documentation of individual methods to find out which properties
 * are required for `PageInfo` arguments or returned for `PageInfo` results.
 *
 * A `VisitInfo` object is any object that contains A SUBSET of the following
 * properties:
 * - date: (Date)
 *     The time the visit occurred.
 * - transition: (number)
 *     How the user reached the page. See constants `TRANSITIONS.*`
 *     for the possible transition types.
 * - referrer: (URL)
 *          or (nsIURI)
 *          or (string)
 *     The referring URI of this visit. Note that `VisitInfo` passed
 *     as argument may hold `nsIURI` or `string` values for property `referrer`,
 *     but `VisitInfo` objects returned by this module always hold `URL`
 *     values.
 * See the documentation of individual methods to find out which properties
 * are required for `VisitInfo` arguments or returned for `VisitInfo` results.
 *
 *
 *
 * Each successful operation notifies through the PlacesObservers. To listen to such
 * notifications you must register using
 * PlacesObservers `addListener` and `removeListener` methods.
 * @see PlacesObservers
 */

var EXPORTED_SYMBOLS = ["History"];

const { XPCOMUtils } = ChromeUtils.import(
  "resource://gre/modules/XPCOMUtils.jsm"
);

XPCOMUtils.defineLazyModuleGetters(this, {
  AppConstants: "resource://gre/modules/AppConstants.jsm",
  PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
  Services: "resource://gre/modules/Services.jsm",
});

XPCOMUtils.defineLazyServiceGetter(
  this,
  "asyncHistory",
  "@mozilla.org/browser/history;1",
  "mozIAsyncHistory"
);

XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);

/**
 * Whenever we update or remove numerous pages, it is preferable
 * to yield time to the main thread every so often to avoid janking.
 * These constants determine the maximal number of notifications we
 * may emit before we yield.
 */
const NOTIFICATION_CHUNK_SIZE = 300;
const ONRESULT_CHUNK_SIZE = 300;

// This constant determines the maximum number of remove pages before we cycle.
const REMOVE_PAGES_CHUNKLEN = 300;

/**
 * Sends a bookmarks notification through the given observers.
 *
 * @param observers
 *        array of nsINavBookmarkObserver objects.
 * @param notification
 *        the notification name.
 * @param args
 *        array of arguments to pass to the notification.
 */
function notify(observers, notification, args = []) {
  for (let observer of observers) {
    try {
      observer[notification](...args);
    } catch (ex) {
      if (
        ex.result != Cr.NS_ERROR_XPC_JSOBJECT_HAS_NO_FUNCTION_NAMED &&
        (AppConstants.DEBUG || Cu.isInAutomation)
      ) {
        Cu.reportError(ex);
      }
    }
  }
}

var History = Object.freeze({
  ANNOTATION_EXPIRE_NEVER: 4,
  // Constants for the type of annotation.
  ANNOTATION_TYPE_STRING: 3,
  ANNOTATION_TYPE_INT64: 5,

  /**
   * Fetch the available information for one page.
   *
   * @param {URL|nsIURI|string} guidOrURI: (string) or (URL, nsIURI or href)
   *      Either the full URI of the page or the GUID of the page.
   * @param {object} [options]
   *      An optional object whose properties describe options:
   *        - `includeVisits` (boolean) set this to true if `visits` in the
   *           PageInfo needs to contain VisitInfo in a reverse chronological order.
   *           By default, `visits` is undefined inside the returned `PageInfo`.
   *        - `includeMeta` (boolean) set this to true to fetch page meta fields,
   *           i.e. `description`, `site_name` and `preview_image_url`.
   *        - `includeAnnotations` (boolean) set this to true to fetch any
   *           annotations that are associated with the page.
   *
   * @return (Promise)
   *      A promise resolved once the operation is complete.
   * @resolves (PageInfo | null) If the page could be found, the information
   *      on that page.
   * @note the VisitInfo objects returned while fetching visits do not
   *       contain the property `referrer`.
   *       TODO: Add `referrer` to VisitInfo. See Bug #1365913.
   * @note the visits returned will not contain `TRANSITION_EMBED` visits.
   *
   * @throws (Error)
   *      If `guidOrURI` does not have the expected type or if it is a string
   *      that may be parsed neither as a valid URL nor as a valid GUID.
   */
  fetch(guidOrURI, options = {}) {
    // First, normalize to guid or string, and throw if not possible
    guidOrURI = PlacesUtils.normalizeToURLOrGUID(guidOrURI);

    // See if options exists and make sense
    if (!options || typeof options !== "object") {
      throw new TypeError("options should be an object and not null");
    }

    let hasIncludeVisits = "includeVisits" in options;
    if (hasIncludeVisits && typeof options.includeVisits !== "boolean") {
      throw new TypeError("includeVisits should be a boolean if exists");
    }

    let hasIncludeMeta = "includeMeta" in options;
    if (hasIncludeMeta && typeof options.includeMeta !== "boolean") {
      throw new TypeError("includeMeta should be a boolean if exists");
    }

    let hasIncludeAnnotations = "includeAnnotations" in options;
    if (
      hasIncludeAnnotations &&
      typeof options.includeAnnotations !== "boolean"
    ) {
      throw new TypeError("includeAnnotations should be a boolean if exists");
    }

    return PlacesUtils.promiseDBConnection().then(db =>
      fetch(db, guidOrURI, options)
    );
  },

  /**
   * Fetches all pages which have one or more of the specified annotations.
   *
   * @param annotations: An array of strings containing the annotation names to
   *                     find.
   * @return (Promise)
   *      A promise resolved once the operation is complete.
   * @resolves (Map)
   *      A Map containing the annotations, pages and their contents, e.g.
   *      Map("anno1" => [{page, content}, {page, content}]), "anno2" => ....);
   * @rejects (Error) XXX
   *      Rejects if the insert was unsuccessful.
   */
  fetchAnnotatedPages(annotations) {
    // See if options exists and make sense
    if (!annotations || !Array.isArray(annotations)) {
      throw new TypeError("annotations should be an Array and not null");
    }
    if (annotations.some(name => typeof name !== "string")) {
      throw new TypeError("all annotation values should be strings");
    }

    return PlacesUtils.promiseDBConnection().then(db =>
      fetchAnnotatedPages(db, annotations)
    );
  },

  /**
   * Fetch multiple pages.
   *
   * @param {string[]|nsIURI[]|URL[]} guidOrURIs: Array of href or URLs to fetch.
   * @returns {Promise}
   *   A promise resolved once the operation is complete.
   * @resolves {Map<string, string>} Map of PageInfos, keyed by the input info,
   *   either guid or href. We don't use nsIURI or URL as keys to avoid
   *   complexity, in all the cases the caller knows which objects is handling,
   *   and can unwrap them. Unknown input pages will have no entry in the Map.
   * @throws (Error)
   *   If input is invalid, for example not a valid GUID or URL.
   */
  fetchMany(guidOrURIs) {
    if (!Array.isArray(guidOrURIs)) {
      throw new TypeError("Input is not an array");
    }
    // First, normalize to guid or URL, throw if not possible
    guidOrURIs = guidOrURIs.map(v => PlacesUtils.normalizeToURLOrGUID(v));
    return PlacesUtils.promiseDBConnection().then(db =>
      fetchMany(db, guidOrURIs)
    );
  },

  /**
   * Adds a number of visits for a single page.
   *
   * Any change may be observed through PlacesObservers.
   *
   * @param pageInfo: (PageInfo)
   *      Information on a page. This `PageInfo` MUST contain
   *        - a property `url`, as specified by the definition of `PageInfo`.
   *        - a property `visits`, as specified by the definition of
   *          `PageInfo`, which MUST contain at least one visit.
   *      If a property `title` is provided, the title of the page
   *      is updated.
   *      If the `date` of a visit is not provided, it defaults
   *      to now.
   *      If the `transition` of a visit is not provided, it defaults to
   *      TRANSITION_LINK.
   *
   * @return (Promise)
   *      A promise resolved once the operation is complete.
   * @resolves (PageInfo)
   *      A PageInfo object populated with data after the insert is complete.
   * @rejects (Error)
   *      Rejects if the insert was unsuccessful.
   *
   * @throws (Error)
   *      If the `url` specified was for a protocol that should not be
   *      stored (@see nsNavHistory::CanAddURI).
   * @throws (Error)
   *      If `pageInfo` has an unexpected type.
   * @throws (Error)
   *      If `pageInfo` does not have a `url`.
   * @throws (Error)
   *      If `pageInfo` does not have a `visits` property or if the
   *      value of `visits` is ill-typed or is an empty array.
   * @throws (Error)
   *      If an element of `visits` has an invalid `date`.
   * @throws (Error)
   *      If an element of `visits` has an invalid `transition`.
   */
  insert(pageInfo) {
    let info = PlacesUtils.validatePageInfo(pageInfo);

    return PlacesUtils.withConnectionWrapper("History.jsm: insert", db =>
      insert(db, info)
    );
  },

  /**
   * Adds a number of visits for a number of pages.
   *
   * Any change may be observed through PlacesObservers.
   *
   * @param pageInfos: (Array<PageInfo>)
   *      Information on a page. This `PageInfo` MUST contain
   *        - a property `url`, as specified by the definition of `PageInfo`.
   *        - a property `visits`, as specified by the definition of
   *          `PageInfo`, which MUST contain at least one visit.
   *      If a property `title` is provided, the title of the page
   *      is updated.
   *      If the `date` of a visit is not provided, it defaults
   *      to now.
   *      If the `transition` of a visit is not provided, it defaults to
   *      TRANSITION_LINK.
   * @param onResult: (function(PageInfo))
   *      A callback invoked for each page inserted.
   * @param onError: (function(PageInfo))
   *      A callback invoked for each page which generated an error
   *      when an insert was attempted.
   *
   * @return (Promise)
   *      A promise resolved once the operation is complete.
   * @resolves (null)
   * @rejects (Error)
   *      Rejects if all of the inserts were unsuccessful.
   *
   * @throws (Error)
   *      If the `url` specified was for a protocol that should not be
   *      stored (@see nsNavHistory::CanAddURI).
   * @throws (Error)
   *      If `pageInfos` has an unexpected type.
   * @throws (Error)
   *      If a `pageInfo` does not have a `url`.
   * @throws (Error)
   *      If a `PageInfo` does not have a `visits` property or if the
   *      value of `visits` is ill-typed or is an empty array.
   * @throws (Error)
   *      If an element of `visits` has an invalid `date`.
   * @throws (Error)
   *      If an element of `visits` has an invalid `transition`.
   */
  insertMany(pageInfos, onResult, onError) {
    let infos = [];

    if (!Array.isArray(pageInfos)) {
      throw new TypeError("pageInfos must be an array");
    }
    if (!pageInfos.length) {
      throw new TypeError("pageInfos may not be an empty array");
    }

    if (onResult && typeof onResult != "function") {
      throw new TypeError(`onResult: ${onResult} is not a valid function`);
    }
    if (onError && typeof onError != "function") {
      throw new TypeError(`onError: ${onError} is not a valid function`);
    }

    for (let pageInfo of pageInfos) {
      let info = PlacesUtils.validatePageInfo(pageInfo);
      infos.push(info);
    }

    return PlacesUtils.withConnectionWrapper("History.jsm: insertMany", db =>
      insertMany(db, infos, onResult, onError)
    );
  },

  /**
   * Remove pages from the database.
   *
   * Any change may be observed through PlacesObservers.
   *
   *
   * @param page: (URL or nsIURI)
   *      The full URI of the page.
   *             or (string)
   *      Either the full URI of the page or the GUID of the page.
   *             or (Array<URL|nsIURI|string>)
   *      An array of the above, to batch requests.
   * @param onResult: (function(PageInfo))
   *      A callback invoked for each page found.
   *
   * @return (Promise)
   *      A promise resolved once the operation is complete.
   * @resolve (bool)
   *      `true` if at least one page was removed, `false` otherwise.
   * @throws (TypeError)
   *       If `pages` has an unexpected type or if a string provided
   *       is neither a valid GUID nor a valid URI or if `pages`
   *       is an empty array.
   */
  remove(pages, onResult = null) {
    // Normalize and type-check arguments
    if (Array.isArray(pages)) {
      if (!pages.length) {
        throw new TypeError("Expected at least one page");
      }
    } else {
      pages = [pages];
    }

    let guids = [];
    let urls = [];
    for (let page of pages) {
      // Normalize to URL or GUID, or throw if `page` cannot
      // be normalized.
      let normalized = PlacesUtils.normalizeToURLOrGUID(page);
      if (typeof normalized === "string") {
        guids.push(normalized);
      } else {
        urls.push(normalized.href);
      }
    }

    // At this stage, we know that either `guids` is not-empty
    // or `urls` is not-empty.

    if (onResult && typeof onResult != "function") {
      throw new TypeError("Invalid function: " + onResult);
    }

    return (async function() {
      let removedPages = false;
      let count = 0;
      while (guids.length || urls.length) {
        if (count && count % 2 == 0) {
          // Every few cycles, yield time back to the main
          // thread to avoid jank.
          await Promise.resolve();
        }
        count++;
        let guidsSlice = guids.splice(0, REMOVE_PAGES_CHUNKLEN);
        let urlsSlice = [];
        if (guidsSlice.length < REMOVE_PAGES_CHUNKLEN) {
          urlsSlice = urls.splice(0, REMOVE_PAGES_CHUNKLEN - guidsSlice.length);
        }

        let pages = { guids: guidsSlice, urls: urlsSlice };

        let result = await PlacesUtils.withConnectionWrapper(
          "History.jsm: remove",
          db => remove(db, pages, onResult)
        );

        removedPages = removedPages || result;
      }
      return removedPages;
    })();
  },

  /**
   * Remove visits matching specific characteristics.
   *
   * Any change may be observed through PlacesObservers.
   *
   * @param filter: (object)
   *      The `object` may contain some of the following
   *      properties:
   *          - beginDate: (Date) Remove visits that have
   *                been added since this date (inclusive).
   *          - endDate: (Date) Remove visits that have
   *                been added before this date (inclusive).
   *          - limit: (Number) Limit the number of visits
   *                we remove to this number
   *          - url: (URL) Only remove visits to this URL
   *          - transition: (Integer)
   *                The type of the transition (see TRANSITIONS below)
   *      If both `beginDate` and `endDate` are specified,
   *      visits between `beginDate` (inclusive) and `end`
   *      (inclusive) are removed.
   *
   * @param onResult: (function(VisitInfo), [optional])
   *     A callback invoked for each visit found and removed.
   *     Note that the referrer property of `VisitInfo`
   *     is NOT populated.
   *
   * @return (Promise)
   * @resolve (bool)
   *      `true` if at least one visit was removed, `false`
   *      otherwise.
   * @throws (TypeError)
   *      If `filter` does not have the expected type, in
   *      particular if the `object` is empty.
   */
  removeVisitsByFilter(filter, onResult = null) {
    if (!filter || typeof filter != "object") {
      throw new TypeError("Expected a filter");
    }

    let hasBeginDate = "beginDate" in filter;
    let hasEndDate = "endDate" in filter;
    let hasURL = "url" in filter;
    let hasLimit = "limit" in filter;
    let hasTransition = "transition" in filter;
    if (hasBeginDate) {
      this.ensureDate(filter.beginDate);
    }
    if (hasEndDate) {
      this.ensureDate(filter.endDate);
    }
    if (hasBeginDate && hasEndDate && filter.beginDate > filter.endDate) {
      throw new TypeError("`beginDate` should be at least as old as `endDate`");
    }
    if (hasTransition && !this.isValidTransition(filter.transition)) {
      throw new TypeError("`transition` should be valid");
    }
    if (
      !hasBeginDate &&
      !hasEndDate &&
      !hasURL &&
      !hasLimit &&
      !hasTransition
    ) {
      throw new TypeError("Expected a non-empty filter");
    }

    if (
      hasURL &&
      !URL.isInstance(filter.url) &&
      typeof filter.url != "string" &&
      !(filter.url instanceof Ci.nsIURI)
    ) {
      throw new TypeError("Expected a valid URL for `url`");
    }

    if (
      hasLimit &&
      (typeof filter.limit != "number" ||
        filter.limit <= 0 ||
        !Number.isInteger(filter.limit))
    ) {
      throw new TypeError("Expected a non-zero positive integer as a limit");
    }

    if (onResult && typeof onResult != "function") {
      throw new TypeError("Invalid function: " + onResult);
    }

    return PlacesUtils.withConnectionWrapper(
      "History.jsm: removeVisitsByFilter",
      db => removeVisitsByFilter(db, filter, onResult)
    );
  },

  /**
   * Remove pages from the database based on a filter.
   *
   * Any change may be observed through PlacesObservers
   *
   *
   * @param filter: An object containing a non empty subset of the following
   * properties:
   * - host: (string)
   *     Hostname with or without subhost. Examples:
   *       "mozilla.org" removes pages from mozilla.org but not its subdomains
   *       ".mozilla.org" removes pages from mozilla.org and its subdomains
   *       "." removes local files
   * - beginDate: (Date)
   *     The first time the page was visited (inclusive)
   * - endDate: (Date)
   *     The last time the page was visited (inclusive)
   * @param [optional] onResult: (function(PageInfo))
   *      A callback invoked for each page found.
   *
   * @note This removes pages with at least one visit inside the timeframe.
   *       Any visits outside the timeframe will also be removed with the page.
   * @return (Promise)
   *      A promise resolved once the operation is complete.
   * @resolve (bool)
   *      `true` if at least one page was removed, `false` otherwise.
   * @throws (TypeError)
   *       if `filter` does not have the expected type, in particular
   *       if the `object` is empty, or its components do not satisfy the
   *       criteria given above
   */
  removeByFilter(filter, onResult) {
    if (!filter || typeof filter !== "object") {
      throw new TypeError("Expected a filter object");
    }

    let hasHost = filter.host;
    if (hasHost) {
      if (typeof filter.host !== "string") {
        throw new TypeError("`host` should be a string");
      }
      filter.host = filter.host.toLowerCase();
      if (filter.host.length > 1 && filter.host.lastIndexOf(".") == 0) {
        // The input contains only an initial period, thus it may be a
        // wildcarded local host, like ".localhost". Ideally the consumer should
        // pass just "localhost", because there is no concept of subhosts for
        // it, but we are being more lenient to allow for simpler input.
        // Anyway, in this case we remove the wildcard to avoid clearing too
        // much if the consumer wrongly passes in things like ".com".
        filter.host = filter.host.slice(1);
      }
    }

    let hasBeginDate = "beginDate" in filter;
    if (hasBeginDate) {
      this.ensureDate(filter.beginDate);
    }

    let hasEndDate = "endDate" in filter;
    if (hasEndDate) {
      this.ensureDate(filter.endDate);
    }

    if (hasBeginDate && hasEndDate && filter.beginDate > filter.endDate) {
      throw new TypeError("`beginDate` should be at least as old as `endDate`");
    }

    if (!hasBeginDate && !hasEndDate && !hasHost) {
      throw new TypeError("Expected a non-empty filter");
    }

    // Check the host format.
    // Either it has no dots, or has multiple dots, or it's a single dot char.
    if (
      hasHost &&
      (!/^(\.?([.a-z0-9-]+\.[a-z0-9-]+)?|[a-z0-9-]+)$/.test(filter.host) ||
        filter.host.includes(".."))
    ) {
      throw new TypeError(
        "Expected well formed hostname string for `host` with atmost 1 wildcard."
      );
    }

    if (onResult && typeof onResult != "function") {
      throw new TypeError("Invalid function: " + onResult);
    }

    return PlacesUtils.withConnectionWrapper(
      "History.jsm: removeByFilter",
      db => removeByFilter(db, filter, onResult)
    );
  },

  /**
   * Determine if a page has been visited.
   *
   * @param guidOrURI: (string) or (URL, nsIURI or href)
   *      Either the full URI of the page or the GUID of the page.
   * @return (Promise)
   *      A promise resolved once the operation is complete.
   * @resolve (bool)
   *      `true` if the page has been visited, `false` otherwise.
   * @throws (Error)
   *      If `guidOrURI` has an unexpected type or if a string provided
   *      is neither not a valid GUID nor a valid URI.
   */
  hasVisits(guidOrURI) {
    // Quick fallback to the cpp version.
    if (guidOrURI instanceof Ci.nsIURI) {
      return new Promise(resolve => {
        asyncHistory.isURIVisited(guidOrURI, (aURI, aIsVisited) => {
          resolve(aIsVisited);
        });
      });
    }

    guidOrURI = PlacesUtils.normalizeToURLOrGUID(guidOrURI);
    let isGuid = typeof guidOrURI == "string";
    let sqlFragment = isGuid
      ? "guid = :val"
      : "url_hash = hash(:val) AND url = :val ";

    return PlacesUtils.promiseDBConnection().then(async db => {
      let rows = await db.executeCached(
        `SELECT 1 FROM moz_places
                                         WHERE ${sqlFragment}
                                         AND last_visit_date NOTNULL`,
        { val: isGuid ? guidOrURI : guidOrURI.href }
      );
      return !!rows.length;
    });
  },

  /**
   * Clear all history.
   *
   * @return (Promise)
   *      A promise resolved once the operation is complete.
   */
  clear() {
    return PlacesUtils.withConnectionWrapper("History.jsm: clear", clear);
  },

  /**
   * Is a value a valid transition type?
   *
   * @param transition: (String)
   * @return (Boolean)
   */
  isValidTransition(transition) {
    return Object.values(History.TRANSITIONS).includes(transition);
  },

  /**
   * Throw if an object is not a Date object.
   */
  ensureDate(arg) {
    if (!arg || typeof arg != "object" || arg.constructor.name != "Date") {
      throw new TypeError("Expected a Date, got " + arg);
    }
  },

  /**
   * Update information for a page.
   *
   * Currently, it supports updating the description, preview image URL and annotations
   * for a page, any other fields will be ignored.
   *
   * Note that this function will ignore the update if the target page has not
   * yet been stored in the database. `History.fetch` could be used to check
   * whether the page and its meta information exist or not. Beware that
   * fetch&update might fail as they are not executed in a single transaction.
   *
   * @param pageInfo: (PageInfo)
   *      pageInfo must contain a URL of the target page. It will be ignored
   *      if a valid page `guid` is also provided.
   *
   *      If a property `description` is provided, the description of the
   *      page is updated. Note that:
   *      1). An empty string or null `description` will clear the existing
   *          value in the database.
   *      2). Descriptions longer than DB_DESCRIPTION_LENGTH_MAX will be
   *          truncated.
   *
   *      If a property `siteName` is provided, the site name of the
   *      page is updated. Note that:
   *      1). An empty string or null `siteName` will clear the existing
   *          value in the database.
   *      2). Descriptions longer than DB_SITENAME_LENGTH_MAX will be
   *          truncated.
   *
   *      If a property `previewImageURL` is provided, the preview image
   *      URL of the page is updated. Note that:
   *      1). A null `previewImageURL` will clear the existing value in the
   *          database.
   *      2). It throws if its length is greater than DB_URL_LENGTH_MAX
   *          defined in PlacesUtils.jsm.
   *
   *      If a property `annotations` is provided, the annotations will be
   *      updated. Note that:
   *      1). It should be a Map containing key/value pairs to be updated.
   *      2). If the value is falsy, the annotation will be removed.
   *      3). If the value is non-falsy, the annotation will be added or updated.
   *      For `annotations` the keys must all be strings, the values should be
   *      Boolean, Number or Strings. null and undefined are supported as falsy values.
   *
   * @return (Promise)
   *      A promise resolved once the update is complete.
   * @rejects (Error)
   *      Rejects if the update was unsuccessful.
   *
   * @throws (Error)
   *      If `pageInfo` has an unexpected type.
   * @throws (Error)
   *      If `pageInfo` has an invalid `url` or an invalid `guid`.
   * @throws (Error)
   *      If `pageInfo` has neither `description` nor `previewImageURL`.
   * @throws (Error)
   *      If the length of `pageInfo.previewImageURL` is greater than
   *      DB_URL_LENGTH_MAX defined in PlacesUtils.jsm.
   */
  update(pageInfo) {
    let info = PlacesUtils.validatePageInfo(pageInfo, false);

    if (
      info.description === undefined &&
      info.siteName === undefined &&
      info.previewImageURL === undefined &&
      info.annotations === undefined
    ) {
      throw new TypeError(
        "pageInfo object must at least have either a description, siteName, previewImageURL or annotations property."
      );
    }

    return PlacesUtils.withConnectionWrapper("History.jsm: update", db =>
      update(db, info)
    );
  },

  /**
   * Possible values for the `transition` property of `VisitInfo`
   * objects.
   */

  TRANSITIONS: {
    /**
     * The user followed a link and got a new toplevel window.
     */
    LINK: Ci.nsINavHistoryService.TRANSITION_LINK,

    /**
     * The user typed the page's URL in the URL bar or selected it from
     * URL bar autocomplete results, clicked on it from a history query
     * (from the History sidebar, History menu, or history query in the
     * personal toolbar or Places organizer.
     */
    TYPED: Ci.nsINavHistoryService.TRANSITION_TYPED,

    /**
     * The user followed a bookmark to get to the page.
     */
    BOOKMARK: Ci.nsINavHistoryService.TRANSITION_BOOKMARK,

    /**
     * Some inner content is loaded. This is true of all images on a
     * page, and the contents of the iframe. It is also true of any
     * content in a frame if the user did not explicitly follow a link
     * to get there.
     */
    EMBED: Ci.nsINavHistoryService.TRANSITION_EMBED,

    /**
     * Set when the transition was a permanent redirect.
     */
    REDIRECT_PERMANENT: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,

    /**
     * Set when the transition was a temporary redirect.
     */
    REDIRECT_TEMPORARY: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,

    /**
     * Set when the transition is a download.
     */
    DOWNLOAD: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,

    /**
     * The user followed a link and got a visit in a frame.
     */
    FRAMED_LINK: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,

    /**
     * The user reloaded a page.
     */
    RELOAD: Ci.nsINavHistoryService.TRANSITION_RELOAD,
  },
});

/**
 * Convert a PageInfo object into the format expected by updatePlaces.
 *
 * Note: this assumes that the PageInfo object has already been validated
 * via PlacesUtils.validatePageInfo.
 *
 * @param pageInfo: (PageInfo)
 * @return (info)
 */
function convertForUpdatePlaces(pageInfo) {
  let info = {
    guid: pageInfo.guid,
    uri: PlacesUtils.toURI(pageInfo.url),
    title: pageInfo.title,
    visits: [],
  };

  for (let inVisit of pageInfo.visits) {
    let visit = {
      visitDate: PlacesUtils.toPRTime(inVisit.date),
      transitionType: inVisit.transition,
      referrerURI: inVisit.referrer
        ? PlacesUtils.toURI(inVisit.referrer)
        : undefined,
    };
    info.visits.push(visit);
  }
  return info;
}

/**
 * Invalidate and recompute the frecency of a list of pages,
 * informing frecency observers.
 *
 * @param {OpenConnection} db an Sqlite connection
 * @param {Array} idList The `moz_places` identifiers to invalidate.
 * @returns {Promise} resolved when done
 */
var invalidateFrecencies = async function(db, idList) {
  if (!idList.length) {
    return;
  }
  for (let chunk of PlacesUtils.chunkArray(idList, db.variableLimit)) {
    await db.execute(
      `UPDATE moz_places
       SET frecency = CALCULATE_FRECENCY(id)
       WHERE id in (${PlacesUtils.sqlBindPlaceholders(chunk)})`,
      chunk
    );
    await db.execute(
      `UPDATE moz_places
       SET hidden = 0
       WHERE id in (${PlacesUtils.sqlBindPlaceholders(chunk)})
       AND frecency <> 0`,
      chunk
    );
  }

  PlacesObservers.notifyListeners([new PlacesRanking()]);

  // Trigger frecency updates for all affected origins.
  await db.execute(`DELETE FROM moz_updateoriginsupdate_temp`);
};

// Inner implementation of History.clear().
var clear = async function(db) {
  await db.executeTransaction(async function() {
    // Remove all non-bookmarked places entries first, this will speed up the
    // triggers work.
    await db.execute(`DELETE FROM moz_places WHERE foreign_count = 0`);
    await db.execute(`DELETE FROM moz_updateoriginsdelete_temp`);

    // Expire orphan icons.
    await db.executeCached(`DELETE FROM moz_pages_w_icons
                            WHERE page_url_hash NOT IN (SELECT url_hash FROM moz_places)`);
    await removeOrphanIcons(db);

    // Expire annotations.
    await db.execute(`DELETE FROM moz_annos WHERE NOT EXISTS (
                        SELECT 1 FROM moz_places WHERE id = place_id
                      )`);

    // Expire inputhistory.
    await db.execute(`DELETE FROM moz_inputhistory WHERE place_id IN (
                        SELECT i.place_id FROM moz_inputhistory i
                        LEFT JOIN moz_places h ON h.id = i.place_id
                        WHERE h.id IS NULL)`);

    // Remove all history.
    await db.execute("DELETE FROM moz_historyvisits");

    // Invalidate frecencies for the remaining places.
    await db.execute(`UPDATE moz_places SET frecency =
                        (CASE
                          WHEN url_hash BETWEEN hash("place", "prefix_lo") AND
                                                hash("place", "prefix_hi")
                          THEN 0
                          ELSE -1
                          END)
                        WHERE frecency > 0`);
  });

  PlacesObservers.notifyListeners([
    new PlacesHistoryCleared(),
    new PlacesRanking(),
  ]);

  // Trigger frecency updates for all affected origins.
  await db.execute(`DELETE FROM moz_updateoriginsupdate_temp`);
};

/**
 * Clean up pages whose history has been modified, by either
 * removing them entirely (if they are marked for removal,
 * typically because all visits have been removed and there
 * are no more foreign keys such as bookmarks) or updating
 * their frecency (otherwise).
 *
 * @param db: (Sqlite connection)
 *      The database.
 * @param pages: (Array of objects)
 *      Pages that have been touched and that need cleaning up.
 *      Each object should have the following properties:
 *          - id: (number) The `moz_places` identifier for the place.
 *          - hasVisits: (boolean) If `true`, there remains at least one
 *              visit to this page, so the page should be kept and its
 *              frecency updated.
 *          - hasForeign: (boolean) If `true`, the page has at least
 *              one foreign reference (i.e. a bookmark), so the page should
 *              be kept and its frecency updated.
 * @return (Promise)
 */
var cleanupPages = async function(db, pages) {
  await invalidateFrecencies(
    db,
    pages.filter(p => p.hasForeign || p.hasVisits).map(p => p.id)
  );

  let pagesToRemove = pages.filter(p => !p.hasForeign && !p.hasVisits);
  if (!pagesToRemove.length) {
    return;
  }

  // Note, we are already in a transaction, since callers create it.
  // Check relations regardless, to avoid creating orphans in case of
  // async race conditions.
  for (let chunk of PlacesUtils.chunkArray(pagesToRemove, db.variableLimit)) {
    let idsToRemove = chunk.map(p => p.id);
    await db.execute(
      `DELETE FROM moz_places
       WHERE id IN ( ${PlacesUtils.sqlBindPlaceholders(idsToRemove)} )
         AND foreign_count = 0 AND last_visit_date ISNULL`,
      idsToRemove
    );

    // Expire orphans.
    let hashesToRemove = chunk.map(p => p.hash);
    await db.executeCached(
      `DELETE FROM moz_pages_w_icons
       WHERE page_url_hash IN (${PlacesUtils.sqlBindPlaceholders(
         hashesToRemove
       )})`,
      hashesToRemove
    );

    await db.execute(
      `DELETE FROM moz_annos
       WHERE place_id IN ( ${PlacesUtils.sqlBindPlaceholders(idsToRemove)} )`,
      idsToRemove
    );
    await db.execute(
      `DELETE FROM moz_inputhistory
       WHERE place_id IN ( ${PlacesUtils.sqlBindPlaceholders(idsToRemove)} )`,
      idsToRemove
    );
  }
  // Hosts accumulated during the places delete are updated through a trigger
  // (see nsPlacesTriggers.h).
  await db.executeCached(`DELETE FROM moz_updateoriginsdelete_temp`);

  await removeOrphanIcons(db);
};

/**
 * Remove icons whose origin is not in moz_origins, unless referenced.
 * @param db: (Sqlite connection)
 *      The database.
 */
function removeOrphanIcons(db) {
  return db.executeCached(`
    DELETE FROM moz_icons WHERE id IN (
      SELECT id FROM moz_icons WHERE root = 0
      UNION ALL
      SELECT id FROM moz_icons
      WHERE root = 1
        AND get_host_and_port(icon_url) NOT IN (SELECT host FROM moz_origins)
        AND fixup_url(get_host_and_port(icon_url)) NOT IN (SELECT host FROM moz_origins)
      EXCEPT
      SELECT icon_id FROM moz_icons_to_pages
    )`);
}

/**
 * Notify observers that pages have been removed/updated.
 *
 * @param db: (Sqlite connection)
 *      The database.
 * @param pages: (Array of objects)
 *      Pages that have been touched and that need cleaning up.
 *      Each object should have the following properties:
 *          - id: (number) The `moz_places` identifier for the place.
 *          - hasVisits: (boolean) If `true`, there remains at least one
 *              visit to this page, so the page should be kept and its
 *              frecency updated.
 *          - hasForeign: (boolean) If `true`, the page has at least
 *              one foreign reference (i.e. a bookmark), so the page should
 *              be kept and its frecency updated.
 * @param transitionType: (Number)
 *      Set to a valid TRANSITIONS value to indicate all transitions of a
 *      certain type have been removed, otherwise defaults to 0 (unknown value).
 * @return (Promise)
 */
var notifyCleanup = async function(db, pages, transitionType = 0) {
  const notifications = [];
  let notifiedCount = 0;
  let bookmarkObservers = PlacesUtils.bookmarks.getObservers();

  for (let page of pages) {
    const isRemovedFromStore = !page.hasVisits && !page.hasForeign;
    notifications.push(
      new PlacesVisitRemoved({
        url: Services.io.newURI(page.url.href).spec,
        pageGuid: page.guid,
        reason: PlacesVisitRemoved.REASON_DELETED,
        transitionType,
        isRemovedFromStore,
        isPartialVisistsRemoval: !isRemovedFromStore && page.hasVisits > 0,
      })
    );

    if (page.hasForeign && !page.hasVisits) {
      PlacesUtils.bookmarks
        .fetch({ url: page.url }, async bookmark => {
          let itemId = await PlacesUtils.promiseItemId(bookmark.guid);
          let parentId = await PlacesUtils.promiseItemId(bookmark.parentGuid);
          notify(
            bookmarkObservers,
            "onItemChanged",
            [
              itemId,
              "cleartime",
              false,
              "",
              0,
              PlacesUtils.bookmarks.TYPE_BOOKMARK,
              parentId,
              bookmark.guid,
              bookmark.parentGuid,
              "",
              PlacesUtils.bookmarks.SOURCES.DEFAULT,
            ],
            { concurrent: true }
          );

          if (++notifiedCount % NOTIFICATION_CHUNK_SIZE == 0) {
            // Every few notifications, yield time back to the main
            // thread to avoid jank.
            await Promise.resolve();
          }
        })
        .catch(Cu.reportError);
    }
  }

  PlacesObservers.notifyListeners(notifications);
};

/**
 * Notify an `onResult` callback of a set of operations
 * that just took place.
 *
 * @param data: (Array)
 *      The data to send to the callback.
 * @param onResult: (function [optional])
 *      If provided, call `onResult` with `data[0]`, `data[1]`, etc.
 *      Otherwise, do nothing.
 */
var notifyOnResult = async function(data, onResult) {
  if (!onResult) {
    return;
  }
  let notifiedCount = 0;
  for (let info of data) {
    try {
      onResult(info);
    } catch (ex) {
      // Errors should be reported but should not stop the operation.
      Promise.reject(ex);
    }
    if (++notifiedCount % ONRESULT_CHUNK_SIZE == 0) {
      // Every few notifications, yield time back to the main
      // thread to avoid jank.
      await Promise.resolve();
    }
  }
};

// Inner implementation of History.fetch.
var fetch = async function(db, guidOrURL, options) {
  let whereClauseFragment = "";
  let params = {};
  if (URL.isInstance(guidOrURL)) {
    whereClauseFragment = "WHERE h.url_hash = hash(:url) AND h.url = :url";
    params.url = guidOrURL.href;
  } else {
    whereClauseFragment = "WHERE h.guid = :guid";
    params.guid = guidOrURL;
  }

  let visitSelectionFragment = "";
  let joinFragment = "";
  let visitOrderFragment = "";
  if (options.includeVisits) {
    visitSelectionFragment = ", v.visit_date, v.visit_type";
    joinFragment = "JOIN moz_historyvisits v ON h.id = v.place_id";
    visitOrderFragment = "ORDER BY v.visit_date DESC";
  }

  let pageMetaSelectionFragment = "";
  if (options.includeMeta) {
    pageMetaSelectionFragment = ", description, site_name, preview_image_url";
  }

  let query = `SELECT h.id, guid, url, title, frecency
               ${pageMetaSelectionFragment} ${visitSelectionFragment}
               FROM moz_places h ${joinFragment}
               ${whereClauseFragment}
               ${visitOrderFragment}`;
  let pageInfo = null;
  let placeId = null;
  await db.executeCached(query, params, row => {
    if (pageInfo === null) {
      // This means we're on the first row, so we need to get all the page info.
      pageInfo = {
        guid: row.getResultByName("guid"),
        url: new URL(row.getResultByName("url")),
        frecency: row.getResultByName("frecency"),
        title: row.getResultByName("title") || "",
      };
      placeId = row.getResultByName("id");
    }
    if (options.includeMeta) {
      pageInfo.description = row.getResultByName("description") || "";
      pageInfo.siteName = row.getResultByName("site_name") || "";
      let previewImageURL = row.getResultByName("preview_image_url");
      pageInfo.previewImageURL = previewImageURL
        ? new URL(previewImageURL)
        : null;
    }
    if (options.includeVisits) {
      // On every row (not just the first), we need to collect visit data.
      if (!("visits" in pageInfo)) {
        pageInfo.visits = [];
      }
      let date = PlacesUtils.toDate(row.getResultByName("visit_date"));
      let transition = row.getResultByName("visit_type");

      // TODO: Bug #1365913 add referrer URL to the `VisitInfo` data as well.
      pageInfo.visits.push({ date, transition });
    }
  });

  // Only try to get annotations if requested, and if there's an actual page found.
  if (pageInfo && options.includeAnnotations) {
    let rows = await db.executeCached(
      `
      SELECT n.name, a.content FROM moz_anno_attributes n
      JOIN moz_annos a ON n.id = a.anno_attribute_id
      WHERE a.place_id = :placeId
    `,
      { placeId }
    );

    pageInfo.annotations = new Map(
      rows.map(row => [
        row.getResultByName("name"),
        row.getResultByName("content"),
      ])
    );
  }
  return pageInfo;
};

// Inner implementation of History.fetchAnnotatedPages.
var fetchAnnotatedPages = async function(db, annotations) {
  let result = new Map();
  let rows = await db.execute(
    `
    SELECT n.name, h.url, a.content FROM moz_anno_attributes n
    JOIN moz_annos a ON n.id = a.anno_attribute_id
    JOIN moz_places h ON h.id = a.place_id
    WHERE n.name IN (${new Array(annotations.length).fill("?").join(",")})
  `,
    annotations
  );

  for (let row of rows) {
    let uri;
    try {
      uri = new URL(row.getResultByName("url"));
    } catch (ex) {
      Cu.reportError("Invalid URL read from database in fetchAnnotatedPages");
      continue;
    }

    let anno = {
      uri,
      content: row.getResultByName("content"),
    };
    let annoName = row.getResultByName("name");
    let pageAnnos = result.get(annoName);
    if (!pageAnnos) {
      pageAnnos = [];
      result.set(annoName, pageAnnos);
    }
    pageAnnos.push(anno);
  }

  return result;
};

// Inner implementation of History.fetchMany.
var fetchMany = async function(db, guidOrURLs) {
  let resultsMap = new Map();
  for (let chunk of PlacesUtils.chunkArray(guidOrURLs, db.variableLimit)) {
    let urls = [];
    let guids = [];
    for (let v of chunk) {
      if (URL.isInstance(v)) {
        urls.push(v);
      } else {
        guids.push(v);
      }
    }
    let wheres = [];
    let params = [];
    if (urls.length) {
      wheres.push(`
        (
          url_hash IN(${PlacesUtils.sqlBindPlaceholders(
            urls,
            "hash(",
            ")"
          )}) AND
          url IN(${PlacesUtils.sqlBindPlaceholders(urls)})
        )`);
      let hrefs = urls.map(u => u.href);
      params = [...params, ...hrefs, ...hrefs];
    }
    if (guids.length) {
      wheres.push(`guid IN(${PlacesUtils.sqlBindPlaceholders(guids)})`);
      params = [...params, ...guids];
    }

    let rows = await db.executeCached(
      `
      SELECT h.id, guid, url, title, frecency
      FROM moz_places h
      WHERE ${wheres.join(" OR ")}
    `,
      params
    );
    for (let row of rows) {
      let pageInfo = {
        guid: row.getResultByName("guid"),
        url: new URL(row.getResultByName("url")),
        frecency: row.getResultByName("frecency"),
        title: row.getResultByName("title") || "",
      };
      if (guidOrURLs.includes(pageInfo.guid)) {
        resultsMap.set(pageInfo.guid, pageInfo);
      } else {
        resultsMap.set(pageInfo.url.href, pageInfo);
      }
    }
  }
  return resultsMap;
};

// Inner implementation of History.removeVisitsByFilter.
var removeVisitsByFilter = async function(db, filter, onResult = null) {
  // 1. Determine visits that took place during the interval.  Note
  // that the database uses microseconds, while JS uses milliseconds,
  // so we need to *1000 one way and /1000 the other way.
  let conditions = [];
  let args = {};
  let transition = -1;
  if ("beginDate" in filter) {
    conditions.push("v.visit_date >= :begin * 1000");
    args.begin = Number(filter.beginDate);
  }
  if ("endDate" in filter) {
    conditions.push("v.visit_date <= :end * 1000");
    args.end = Number(filter.endDate);
  }
  if ("limit" in filter) {
    args.limit = Number(filter.limit);
  }
  if ("transition" in filter) {
    conditions.push("v.visit_type = :transition");
    args.transition = filter.transition;
    transition = filter.transition;
  }

  let optionalJoin = "";
  if ("url" in filter) {
    let url = filter.url;
    if (url instanceof Ci.nsIURI) {
      url = filter.url.spec;
    } else {
      url = new URL(url).href;
    }
    optionalJoin = `JOIN moz_places h ON h.id = v.place_id`;
    conditions.push("h.url_hash = hash(:url)", "h.url = :url");
    args.url = url;
  }

  let visitsToRemove = [];
  let pagesToInspect = new Set();
  let onResultData = onResult ? [] : null;

  await db.executeCached(
    `SELECT v.id, place_id, visit_date / 1000 AS date, visit_type FROM moz_historyvisits v
             ${optionalJoin}
             WHERE ${conditions.join(" AND ")}${
      args.limit ? " LIMIT :limit" : ""
    }`,
    args,
    row => {
      let id = row.getResultByName("id");
      let place_id = row.getResultByName("place_id");
      visitsToRemove.push(id);
      pagesToInspect.add(place_id);

      if (onResult) {
        onResultData.push({
          date: new Date(row.getResultByName("date")),
          transition: row.getResultByName("visit_type"),
        });
      }
    }
  );

  if (!visitsToRemove.length) {
    // Nothing to do
    return false;
  }

  let pages = [];
  await db.executeTransaction(async function() {
    // 2. Remove all offending visits.
    for (let chunk of PlacesUtils.chunkArray(
      visitsToRemove,
      db.variableLimit
    )) {
      await db.execute(
        `DELETE FROM moz_historyvisits
         WHERE id IN (${PlacesUtils.sqlBindPlaceholders(chunk)})`,
        chunk
      );
    }

    // 3. Find out which pages have been orphaned
    for (let chunk of PlacesUtils.chunkArray(
      [...pagesToInspect],
      db.variableLimit
    )) {
      await db.execute(
        `SELECT id, url, url_hash, guid,
          (foreign_count != 0) AS has_foreign,
          (last_visit_date NOTNULL) as has_visits
         FROM moz_places
         WHERE id IN (${PlacesUtils.sqlBindPlaceholders(chunk)})`,
        chunk,
        row => {
          let page = {
            id: row.getResultByName("id"),
            guid: row.getResultByName("guid"),
            hasForeign: row.getResultByName("has_foreign"),
            hasVisits: row.getResultByName("has_visits"),
            url: new URL(row.getResultByName("url")),
            hash: row.getResultByName("url_hash"),
          };
          pages.push(page);
        }
      );
    }

    // 4. Clean up and notify
    await cleanupPages(db, pages);
  });

  notifyCleanup(db, pages, transition);
  notifyOnResult(onResultData, onResult); // don't wait

  return !!visitsToRemove.length;
};

// Inner implementation of History.removeByFilter
var removeByFilter = async function(db, filter, onResult = null) {
  // 1. Create fragment for date filtration
  let dateFilterSQLFragment = "";
  let conditions = [];
  let params = {};
  if ("beginDate" in filter) {
    conditions.push("v.visit_date >= :begin");
    params.begin = PlacesUtils.toPRTime(filter.beginDate);
  }
  if ("endDate" in filter) {
    conditions.push("v.visit_date <= :end");
    params.end = PlacesUtils.toPRTime(filter.endDate);
  }

  if (conditions.length !== 0) {
    dateFilterSQLFragment = `EXISTS
         (SELECT id FROM moz_historyvisits v WHERE v.place_id = h.id AND
         ${conditions.join(" AND ")}
         LIMIT 1)`;
  }

  // 2. Create fragment for host and subhost filtering
  let hostFilterSQLFragment = "";
  if (filter.host) {
    // There are four cases that we need to consider:
    // mozilla.org, .mozilla.org, localhost, and local files
    let revHost = filter.host
      .split("")
      .reverse()
      .join("");
    if (filter.host == ".") {
      // Local files.
      hostFilterSQLFragment = `h.rev_host = :revHost`;
    } else if (filter.host.startsWith(".")) {
      // Remove the subhost wildcard.
      revHost = revHost.slice(0, -1);
      hostFilterSQLFragment = `h.rev_host between :revHost || "." and :revHost || "/"`;
    } else {
      // This covers non-wildcarded hosts (e.g.: mozilla.org, localhost)
      hostFilterSQLFragment = `h.rev_host = :revHost || "."`;
    }
    params.revHost = revHost;
  }

  // 3. Find out what needs to be removed
  let fragmentArray = [hostFilterSQLFragment, dateFilterSQLFragment];
  let query = `SELECT h.id, url, url_hash, rev_host, guid, title, frecency, foreign_count
       FROM moz_places h WHERE
       (${fragmentArray.filter(f => f !== "").join(") AND (")})`;
  let onResultData = onResult ? [] : null;
  let pages = [];
  let hasPagesToRemove = false;

  await db.executeCached(query, params, row => {
    let hasForeign = row.getResultByName("foreign_count") != 0;
    if (!hasForeign) {
      hasPagesToRemove = true;
    }
    let id = row.getResultByName("id");
    let guid = row.getResultByName("guid");
    let url = row.getResultByName("url");
    let page = {
      id,
      guid,
      hasForeign,
      hasVisits: false,
      url: new URL(url),
      hash: row.getResultByName("url_hash"),
    };
    pages.push(page);
    if (onResult) {
      onResultData.push({
        guid,
        title: row.getResultByName("title"),
        frecency: row.getResultByName("frecency"),
        url: new URL(url),
      });
    }
  });

  if (pages.length === 0) {
    // Nothing to do
    return false;
  }

  await db.executeTransaction(async function() {
    // 4. Actually remove visits
    let pageIds = pages.map(p => p.id);
    for (let chunk of PlacesUtils.chunkArray(pageIds, db.variableLimit)) {
      await db.execute(
        `DELETE FROM moz_historyvisits
         WHERE place_id IN(${PlacesUtils.sqlBindPlaceholders(chunk)})`,
        chunk
      );
    }
    // 5. Clean up and notify
    await cleanupPages(db, pages);
  });

  notifyCleanup(db, pages);
  notifyOnResult(onResultData, onResult);

  return hasPagesToRemove;
};

// Inner implementation of History.remove.
var remove = async function(db, { guids, urls }, onResult = null) {
  // 1. Find out what needs to be removed
  let onResultData = onResult ? [] : null;
  let pages = [];
  let hasPagesToRemove = false;
  function onRow(row) {
    let hasForeign = row.getResultByName("foreign_count") != 0;
    if (!hasForeign) {
      hasPagesToRemove = true;
    }
    let id = row.getResultByName("id");
    let guid = row.getResultByName("guid");
    let url = row.getResultByName("url");
    let page = {
      id,
      guid,
      hasForeign,
      hasVisits: false,
      url: new URL(url),
      hash: row.getResultByName("url_hash"),
    };
    pages.push(page);
    if (onResult) {
      onResultData.push({
        guid,
        title: row.getResultByName("title"),
        frecency: row.getResultByName("frecency"),
        url: new URL(url),
      });
    }
  }
  for (let chunk of PlacesUtils.chunkArray(guids, db.variableLimit)) {
    let query = `SELECT id, url, url_hash, guid, foreign_count, title, frecency
       FROM moz_places
       WHERE guid IN (${PlacesUtils.sqlBindPlaceholders(guids)})
      `;
    await db.execute(query, chunk, onRow);
  }
  for (let chunk of PlacesUtils.chunkArray(urls, db.variableLimit)) {
    // Make an array of variables like `["?1", "?2", ...]`, up to the length of
    // the chunk. This lets us bind each URL once, reusing the binding for the
    // `url_hash IN (...)` and `url IN (...)` clauses. We add 1 because indexed
    // parameters start at 1, not 0.
    let variables = Array.from(
      { length: chunk.length },
      (_, i) => "?" + (i + 1)
    );
    let query = `SELECT id, url, url_hash, guid, foreign_count, title, frecency
       FROM moz_places
       WHERE url_hash IN (${variables.map(v => `hash(${v})`).join(",")}) AND
             url IN (${variables.join(",")})
      `;
    await db.execute(query, chunk, onRow);
  }

  if (!pages.length) {
    // Nothing to do
    return false;
  }

  await db.executeTransaction(async function() {
    // 2. Remove all visits to these pages.
    let pageIds = pages.map(p => p.id);
    for (let chunk of PlacesUtils.chunkArray(pageIds, db.variableLimit)) {
      await db.execute(
        `DELETE FROM moz_historyvisits
         WHERE place_id IN (${PlacesUtils.sqlBindPlaceholders(chunk)})`,
        chunk
      );
    }

    // 3. Clean up and notify
    await cleanupPages(db, pages);
  });

  notifyCleanup(db, pages);
  notifyOnResult(onResultData, onResult); // don't wait

  return hasPagesToRemove;
};

/**
 * Merges an updateInfo object, as returned by asyncHistory.updatePlaces
 * into a PageInfo object as defined in this file.
 *
 * @param updateInfo: (Object)
 *      An object that represents a page that is generated by
 *      asyncHistory.updatePlaces.
 * @param pageInfo: (PageInfo)
 *      An PageInfo object into which to merge the data from updateInfo.
 *      Defaults to an empty object so that this method can be used
 *      to simply convert an updateInfo object into a PageInfo object.
 *
 * @return (PageInfo)
 *      A PageInfo object populated with data from updateInfo.
 */
function mergeUpdateInfoIntoPageInfo(updateInfo, pageInfo = {}) {
  pageInfo.guid = updateInfo.guid;
  pageInfo.title = updateInfo.title;
  if (!pageInfo.url) {
    pageInfo.url = new URL(updateInfo.uri.spec);
    pageInfo.title = updateInfo.title;
    pageInfo.visits = updateInfo.visits.map(visit => {
      return {
        date: PlacesUtils.toDate(visit.visitDate),
        transition: visit.transitionType,
        referrer: visit.referrerURI ? new URL(visit.referrerURI.spec) : null,
      };
    });
  }
  return pageInfo;
}

// Inner implementation of History.insert.
var insert = function(db, pageInfo) {
  let info = convertForUpdatePlaces(pageInfo);

  return new Promise((resolve, reject) => {
    asyncHistory.updatePlaces(info, {
      handleError: error => {
        reject(error);
      },
      handleResult: result => {
        pageInfo = mergeUpdateInfoIntoPageInfo(result, pageInfo);
      },
      handleCompletion: () => {
        resolve(pageInfo);
      },
    });
  });
};

// Inner implementation of History.insertMany.
var insertMany = function(db, pageInfos, onResult, onError) {
  let infos = [];
  let onResultData = [];
  let onErrorData = [];

  for (let pageInfo of pageInfos) {
    let info = convertForUpdatePlaces(pageInfo);
    infos.push(info);
  }

  return new Promise((resolve, reject) => {
    asyncHistory.updatePlaces(infos, {
      handleError: (resultCode, result) => {
        let pageInfo = mergeUpdateInfoIntoPageInfo(result);
        onErrorData.push(pageInfo);
      },
      handleResult: result => {
        let pageInfo = mergeUpdateInfoIntoPageInfo(result);
        onResultData.push(pageInfo);
      },
      ignoreErrors: !onError,
      ignoreResults: !onResult,
      handleCompletion: updatedCount => {
        notifyOnResult(onResultData, onResult);
        notifyOnResult(onErrorData, onError);
        if (updatedCount > 0) {
          resolve();
        } else {
          reject({ message: "No items were added to history." });
        }
      },
    });
  });
};

// Inner implementation of History.update.
var update = async function(db, pageInfo) {
  // Check for page existence first; we can skip most of the work if it doesn't
  // exist and anyway we'll need the place id multiple times later.
  // Prefer GUID over url if it's present.
  let id;
  if (typeof pageInfo.guid === "string") {
    let rows = await db.executeCached(
      "SELECT id FROM moz_places WHERE guid = :guid",
      { guid: pageInfo.guid }
    );
    id = rows.length ? rows[0].getResultByName("id") : null;
  } else {
    let rows = await db.executeCached(
      "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url",
      { url: pageInfo.url.href }
    );
    id = rows.length ? rows[0].getResultByName("id") : null;
  }
  if (!id) {
    return;
  }

  let updateFragments = [];
  let params = {};
  if ("description" in pageInfo) {
    updateFragments.push("description");
    params.description = pageInfo.description;
  }
  if ("siteName" in pageInfo) {
    updateFragments.push("site_name");
    params.site_name = pageInfo.siteName;
  }
  if ("previewImageURL" in pageInfo) {
    updateFragments.push("preview_image_url");
    params.preview_image_url = pageInfo.previewImageURL
      ? pageInfo.previewImageURL.href
      : null;
  }
  if (updateFragments.length) {
    // Since this data may be written at every visit and is textual, avoid
    // overwriting the existing record if it didn't change.
    await db.execute(
      `
      UPDATE moz_places
      SET ${updateFragments.map(v => `${v} = :${v}`).join(", ")}
      WHERE id = :id
        AND (${updateFragments
          .map(v => `IFNULL(${v}, '') <> IFNULL(:${v}, '')`)
          .join(" OR ")})
    `,
      { id, ...params }
    );
  }

  if (pageInfo.annotations) {
    let annosToRemove = [];
    let annosToUpdate = [];

    for (let anno of pageInfo.annotations) {
      anno[1] ? annosToUpdate.push(anno[0]) : annosToRemove.push(anno[0]);
    }

    await db.executeTransaction(async function() {
      if (annosToUpdate.length) {
        await db.execute(
          `
          INSERT OR IGNORE INTO moz_anno_attributes (name)
          VALUES ${Array.from(annosToUpdate.keys())
            .map(k => `(:${k})`)
            .join(", ")}
        `,
          Object.assign({}, annosToUpdate)
        );

        for (let anno of annosToUpdate) {
          let content = pageInfo.annotations.get(anno);
          // TODO: We only really need to save the type whilst we still support
          // accessing page annotations via the annotation service.
          let type =
            typeof content == "string"
              ? History.ANNOTATION_TYPE_STRING
              : History.ANNOTATION_TYPE_INT64;
          let date = PlacesUtils.toPRTime(new Date());

          // This will replace the id every time an annotation is updated. This is
          // not currently an issue as we're not joining on the id field.
          await db.execute(
            `
            INSERT OR REPLACE INTO moz_annos
              (place_id, anno_attribute_id, content, flags,
               expiration, type, dateAdded, lastModified)
            VALUES (:id,
                    (SELECT id FROM moz_anno_attributes WHERE name = :anno_name),
                    :content, 0, :expiration, :type, :date_added,
                    :last_modified)
          `,
            {
              id,
              anno_name: anno,
              content,
              expiration: History.ANNOTATION_EXPIRE_NEVER,
              type,
              // The date fields are unused, so we just set them both to the latest.
              date_added: date,
              last_modified: date,
            }
          );
        }
      }

      for (let anno of annosToRemove) {
        // We don't remove anything from the moz_anno_attributes table. If we
        // delete the last item of a given name, that item really should go away.
        // It will be cleaned up by expiration.
        await db.execute(
          `
          DELETE FROM moz_annos
          WHERE place_id = :id
          AND anno_attribute_id =
            (SELECT id FROM moz_anno_attributes WHERE name = :anno_name)
        `,
          { id, anno_name: anno }
        );
      }
    });
  }
};
