/* 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 lazy = {};

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

/**
 * @import {OpenedConnection} from "resource://gre/modules/Sqlite.sys.mjs"
 */

/**
 * This module exports functions for Sync to use when applying remote
 * records. The calls are similar to those in `Bookmarks.sys.mjs` and
 * `nsINavBookmarksService`, with special handling for
 * tags, keywords, synced annotations, and missing parents.
 */
export var PlacesSyncUtils = {};

const { SOURCE_SYNC } = Ci.nsINavBookmarksService;

const MICROSECONDS_PER_SECOND = 1000000;

const MOBILE_BOOKMARKS_PREF = "browser.bookmarks.showMobileBookmarks";

// These are defined as lazy getters to defer initializing the bookmarks
// service until it's needed.
ChromeUtils.defineLazyGetter(lazy, "ROOT_RECORD_ID_TO_GUID", () => ({
  menu: lazy.PlacesUtils.bookmarks.menuGuid,
  places: lazy.PlacesUtils.bookmarks.rootGuid,
  tags: lazy.PlacesUtils.bookmarks.tagsGuid,
  toolbar: lazy.PlacesUtils.bookmarks.toolbarGuid,
  unfiled: lazy.PlacesUtils.bookmarks.unfiledGuid,
  mobile: lazy.PlacesUtils.bookmarks.mobileGuid,
}));

ChromeUtils.defineLazyGetter(lazy, "ROOT_GUID_TO_RECORD_ID", () => ({
  [lazy.PlacesUtils.bookmarks.menuGuid]: "menu",
  [lazy.PlacesUtils.bookmarks.rootGuid]: "places",
  [lazy.PlacesUtils.bookmarks.tagsGuid]: "tags",
  [lazy.PlacesUtils.bookmarks.toolbarGuid]: "toolbar",
  [lazy.PlacesUtils.bookmarks.unfiledGuid]: "unfiled",
  [lazy.PlacesUtils.bookmarks.mobileGuid]: "mobile",
}));

ChromeUtils.defineLazyGetter(lazy, "ROOTS", () =>
  Object.keys(lazy.ROOT_RECORD_ID_TO_GUID)
);

// Gets the history transition values we ignore and do not sync, as a
// string, which is a comma-separated set of values - ie, something which can
// be used with sqlite's IN operator. Does *not* includes the parens.
ChromeUtils.defineLazyGetter(lazy, "IGNORED_TRANSITIONS_AS_SQL_LIST", () =>
  // * We don't sync `TRANSITION_FRAMED_LINK` visits - these are excluded when
  //   rendering the history menu, so we use the same constraints for Sync.
  // * We don't sync `TRANSITION_DOWNLOAD` because it makes no sense to see
  //   these on other devices - the downloaded file can not exist.
  // * We don't want to sync TRANSITION_EMBED visits, but these aren't
  //   stored in the DB, so no need to specify them.
  // * 0 is invalid, and hopefully don't exist, but let's exclude it anyway.
  // Array.toString() semantics are well defined and exactly what we need, so..
  [
    0,
    lazy.PlacesUtils.history.TRANSITION_FRAMED_LINK,
    lazy.PlacesUtils.history.TRANSITION_DOWNLOAD,
  ].toString()
);

const HistorySyncUtils = (PlacesSyncUtils.history = Object.freeze({
  SYNC_ID_META_KEY: "sync/history/syncId",
  LAST_SYNC_META_KEY: "sync/history/lastSync",

  /**
   * Returns the current history sync ID, or `""` if one isn't set.
   */
  getSyncId() {
    return lazy.PlacesUtils.metadata.get(HistorySyncUtils.SYNC_ID_META_KEY, "");
  },

  /**
   * Assigns a new sync ID. This is called when we sync for the first time with
   * a new account, and when we're the first to sync after a node reassignment.
   *
   * @returns {Promise<string>}
   *   Resolved with the new sync ID once the ID has been updated.
   */
  resetSyncId() {
    return lazy.PlacesUtils.withConnectionWrapper(
      "HistorySyncUtils: resetSyncId",
      function (db) {
        let newSyncId = lazy.PlacesUtils.history.makeGuid();
        return db.executeTransaction(async function () {
          await setHistorySyncId(db, newSyncId);
          return newSyncId;
        });
      }
    );
  },

  /**
   * Ensures that the existing local sync ID, if any, is up-to-date with the
   * server. This is called when we sync with an existing account.
   *
   * @param {string} newSyncId
   *   The server's sync ID.
   * @returns {Promise<void>}
   *   Resolved once the ID has been updated.
   */
  async ensureCurrentSyncId(newSyncId) {
    if (!newSyncId || typeof newSyncId != "string") {
      throw new TypeError("Invalid new history sync ID");
    }
    await lazy.PlacesUtils.withConnectionWrapper(
      "HistorySyncUtils: ensureCurrentSyncId",
      async function (db) {
        let existingSyncId = await lazy.PlacesUtils.metadata.getWithConnection(
          db,
          HistorySyncUtils.SYNC_ID_META_KEY,
          ""
        );

        if (existingSyncId == newSyncId) {
          lazy.HistorySyncLog.trace("History sync ID up-to-date", {
            existingSyncId,
          });
          return;
        }

        lazy.HistorySyncLog.info(
          "History sync ID changed; resetting metadata",
          {
            existingSyncId,
            newSyncId,
          }
        );
        await db.executeTransaction(function () {
          return setHistorySyncId(db, newSyncId);
        });
      }
    );
  },

  /**
   * Returns the last sync time, in seconds, for the history collection, or 0
   * if history has never synced before.
   */
  async getLastSync() {
    let lastSync = await lazy.PlacesUtils.metadata.get(
      HistorySyncUtils.LAST_SYNC_META_KEY,
      0
    );
    return lastSync / 1000;
  },

  /**
   * Updates the history collection last sync time.
   *
   * @param {number} lastSyncSeconds
   *   The collection last sync time, in seconds.
   */
  async setLastSync(lastSyncSeconds) {
    let lastSync = Math.floor(lastSyncSeconds * 1000);
    if (!Number.isInteger(lastSync)) {
      throw new TypeError("Invalid history last sync timestamp");
    }
    await lazy.PlacesUtils.metadata.set(
      HistorySyncUtils.LAST_SYNC_META_KEY,
      lastSync
    );
  },

  /**
   * Removes all history visits and pages from the database. Sync calls this
   * method when it receives a command from a remote client to wipe all stored
   * data.
   *
   * @returns {Promise} resolved once all pages and visits have been removed.
   */
  async wipe() {
    await lazy.PlacesUtils.history.clear();
    await HistorySyncUtils.reset();
  },

  /**
   * Removes the sync ID and last sync time for the history collection. Unlike
   * `wipe`, this keeps all existing history pages and visits.
   *
   * @returns {Promise<void>}
   *   Resolved once the metadata has been removed.
   */
  reset() {
    return lazy.PlacesUtils.metadata.delete(
      HistorySyncUtils.SYNC_ID_META_KEY,
      HistorySyncUtils.LAST_SYNC_META_KEY
    );
  },

  /**
   * Clamps a history visit date between the current date and the earliest
   * sensible date.
   *
   * @param {Date} visitDate
   *   The visit date.
   * @returns {Date}
   *   The clamped visit date.
   */
  clampVisitDate(visitDate) {
    let currentDate = new Date();
    if (visitDate > currentDate) {
      return currentDate;
    }
    if (visitDate.getTime() < BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP) {
      return new Date(BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP);
    }
    return visitDate;
  },

  /**
   * Fetches the frecency for the URL provided
   *
   * @param {string|nsIURI|URL} url
   * @returns {Promise<number>}
   *   The frecency of the given url
   */
  async fetchURLFrecency(url) {
    let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);

    let db = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await db.executeCached(
      `
      SELECT frecency
      FROM moz_places
      WHERE url_hash = hash(:url) AND url = :url
      LIMIT 1`,
      { url: canonicalURL.href }
    );

    return rows.length ? rows[0].getResultByName("frecency") : -1;
  },

  /**
   * Filters syncable places from a collection of places guids.
   *
   * @param {string[]} guids
   *
   * @returns {Promise<string[]>}
   *   A new array with the guids that aren't syncable.
   */
  async determineNonSyncableGuids(guids) {
    // Filter out hidden pages and transitions that we don't sync.
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await db.executeCached(
      `
        SELECT DISTINCT p.guid FROM moz_places p
        JOIN moz_historyvisits v ON p.id = v.place_id
        WHERE p.guid IN carray(:guids) AND
            (p.hidden = 1 OR v.visit_type IN (${
              lazy.IGNORED_TRANSITIONS_AS_SQL_LIST
            }))
      `,
      { guids }
    );
    return rows.map(row => row.getResultByName("guid"));
  },

  /**
   * Changes the guid of the given uri.
   *
   * @param {string|nsIURI|URL} uri
   * @param {string} guid
   */
  changeGuid(uri, guid) {
    let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(uri);
    let validatedGuid = lazy.PlacesUtils.BOOKMARK_VALIDATORS.guid(guid);
    return lazy.PlacesUtils.withConnectionWrapper(
      "PlacesSyncUtils.history: changeGuid",
      async function (db) {
        await db.executeCached(
          `
            UPDATE moz_places
            SET guid = :guid
            WHERE url_hash = hash(:page_url) AND url = :page_url`,
          { guid: validatedGuid, page_url: canonicalURL.href }
        );
      }
    );
  },

  /**
   * Fetch the last 20 visits (date and type of it) corresponding to a given url
   *
   * @param {string|nsIURI|URL} url
   * @returns {Promise<{date: Date, type: number}[]>}
   *   Each element of the Array is an object with members: date and type
   */
  async fetchVisitsForURL(url) {
    let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await db.executeCached(
      `
      SELECT visit_type type, visit_date date,
      json_extract(e.sync_json, '$.unknown_sync_fields') as unknownSyncFields
      FROM moz_historyvisits v
      JOIN moz_places h ON h.id = v.place_id
      LEFT OUTER JOIN moz_historyvisits_extra e ON e.visit_id = v.id
      WHERE url_hash = hash(:url) AND url = :url
      ORDER BY date DESC LIMIT 20`,
      { url: canonicalURL.href }
    );
    return rows.map(row => {
      let visitDate = row.getResultByName("date");
      let visitType = row.getResultByName("type");
      let visit = { date: visitDate, type: visitType };

      // We should grab unknown fields to roundtrip them
      // back to the server
      let unknownFields = row.getResultByName("unknownSyncFields");
      if (unknownFields) {
        let unknownFieldsObj = JSON.parse(unknownFields);
        for (const key in unknownFieldsObj) {
          // We have to manually add it to the cleartext since that's
          // what gets processed during upload
          visit[key] = unknownFieldsObj[key];
        }
      }
      return visit;
    });
  },

  /**
   * Fetches the guid of a uri
   *
   * @param {string|nsIURI|URL} url
   * @returns {Promise<string>}
   *   The guid of the given uri.
   */
  async fetchGuidForURL(url) {
    let canonicalURL = lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.url(url);
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await db.executeCached(
      `
        SELECT guid
        FROM moz_places
        WHERE url_hash = hash(:page_url) AND url = :page_url`,
      { page_url: canonicalURL.href }
    );
    if (!rows.length) {
      return null;
    }
    return rows[0].getResultByName("guid");
  },

  /**
   * Fetch information about a guid (url, title and frecency)
   *
   * @param {string} guid
   * @returns {Promise<{url: string, title: string, frecency: number}>}
   *   An object with three members: url, title and frecency of the given guid.
   */
  async fetchURLInfoForGuid(guid) {
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let rows = await db.executeCached(
      `
      SELECT url, IFNULL(title, '') AS title, frecency,
      json_extract(e.sync_json, '$.unknown_sync_fields') as unknownSyncFields
      FROM moz_places h
      LEFT OUTER JOIN moz_places_extra e ON e.place_id = h.id
      WHERE guid = :guid`,
      { guid }
    );
    if (rows.length === 0) {
      return null;
    }

    let info = {
      url: rows[0].getResultByName("url"),
      title: rows[0].getResultByName("title"),
      frecency: rows[0].getResultByName("frecency"),
    };
    let unknownFields = rows[0].getResultByName("unknownSyncFields");
    if (unknownFields) {
      // This will be unfurled at the caller since the
      // cleartext process will drop this
      info.unknownFields = unknownFields;
    }
    return info;
  },

  /**
   * Get all URLs filtered by the specified limit and minimum visit date.
   *
   * @param {object} options
   * @param {number} options.limit
   *   Maximum number of URLs to return.
   * @param {Date} options.since
   *   Only include URLs visited after this date.
   * @returns {Promise<string[]>}
   *   A list of URLs, up to the given limit, that were visited after the date
   *   provided. Note that some visit types are explicitly excluded - downloads
   *   and framed links.
   */
  async getAllURLs(options) {
    // Check that the limit property is finite number.
    if (!Number.isFinite(options.limit)) {
      throw new Error("The number provided in options.limit is not finite.");
    }
    // Check that the since property is of type Date.
    if (
      !options.since ||
      Object.prototype.toString.call(options.since) != "[object Date]"
    ) {
      throw new Error(
        "The property since of the options object must be of type Date."
      );
    }
    let db = await lazy.PlacesUtils.promiseDBConnection();
    let sinceInMicroseconds = lazy.PlacesUtils.toPRTime(options.since);
    let rows = await db.executeCached(
      `
      SELECT DISTINCT p.url
      FROM moz_places p
      JOIN moz_historyvisits v ON p.id = v.place_id
      WHERE p.last_visit_date > :cutoff_date AND
            p.hidden = 0 AND
            v.visit_type NOT IN (${lazy.IGNORED_TRANSITIONS_AS_SQL_LIST})
      ORDER BY frecency DESC
      LIMIT :max_results`,
      { cutoff_date: sinceInMicroseconds, max_results: options.limit }
    );
    return rows.map(row => row.getResultByName("url"));
  },
  /**
   * Insert or update the unknownFields that this client doesn't understand (yet)
   * but stores & roundtrips them to prevent other clients from losing that data
   *
   * @param {object[]} updates array of objects
   *  an update object needs to have either a:
   *  placeId: if we're putting unknownFields for a moz_places item
   *  visitId: if we're putting unknownFields for a moz_historyvisits item
   *  Note: Supplying none or both will result in that record being ignored
   *  unknownFields: the stringified json to insert
   */
  async updateUnknownFieldsBatch(updates) {
    return lazy.PlacesUtils.withConnectionWrapper(
      "HistorySyncUtils: updateUnknownFieldsBatch",
      async function (db) {
        await db.executeTransaction(async () => {
          for await (const update of updates) {
            // Validate we only have one of these props
            if (
              (update.placeId && update.visitId) ||
              (!update.placeId && !update.visitId)
            ) {
              continue;
            }
            let tableName = update.placeId
              ? "moz_places_extra"
              : "moz_historyvisits_extra";
            let keyName = update.placeId ? "place_id" : "visit_id";
            await db.executeCached(
              `
            INSERT INTO ${tableName} (${keyName}, sync_json)
            VALUES (
              :keyValue,
              json_object('unknown_sync_fields', :unknownFields)
            )
            ON CONFLICT(${keyName}) DO UPDATE SET
            sync_json=json_patch(${tableName}.sync_json, json_object('unknown_sync_fields',:unknownFields))
            `,
              {
                keyValue: update.placeId ?? update.visitId,
                unknownFields: update.unknownFields,
              }
            );
          }
        });
      }
    );
  },
  // End of history freeze
}));

const BookmarkSyncUtils = (PlacesSyncUtils.bookmarks = Object.freeze({
  SYNC_ID_META_KEY: "sync/bookmarks/syncId",
  LAST_SYNC_META_KEY: "sync/bookmarks/lastSync",
  WIPE_REMOTE_META_KEY: "sync/bookmarks/wipeRemote",

  // Jan 23, 1993 in milliseconds since 1970. Corresponds roughly to the release
  // of the original NCSA Mosiac. We can safely assume that any dates before
  // this time are invalid.
  EARLIEST_BOOKMARK_TIMESTAMP: Date.UTC(1993, 0, 23),

  KINDS: {
    BOOKMARK: "bookmark",
    QUERY: "query",
    FOLDER: "folder",
    LIVEMARK: "livemark",
    SEPARATOR: "separator",
  },

  get ROOTS() {
    return lazy.ROOTS;
  },

  /**
   * Returns the current bookmarks sync ID, or `""` if one isn't set.
   */
  getSyncId() {
    return lazy.PlacesUtils.metadata.get(
      BookmarkSyncUtils.SYNC_ID_META_KEY,
      ""
    );
  },

  /**
   * Indicates if the bookmarks engine should erase all bookmarks on the server
   * and all other clients, because the user manually restored their bookmarks
   * from a backup on this client.
   */
  async shouldWipeRemote() {
    let shouldWipeRemote = await lazy.PlacesUtils.metadata.get(
      BookmarkSyncUtils.WIPE_REMOTE_META_KEY,
      false
    );
    return !!shouldWipeRemote;
  },

  /**
   * Assigns a new sync ID, bumps the change counter, and flags all items as
   * "NEW" for upload. This is called when we sync for the first time with a
   * new account, when we're the first to sync after a node reassignment, and
   * on the first sync after a manual restore.
   *
   * @returns {Promise<string>}
   *   Resolved to the new sync id once the ID and all items have been updated.
   */
  resetSyncId() {
    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: resetSyncId",
      function (db) {
        let newSyncId = lazy.PlacesUtils.history.makeGuid();
        return db.executeTransaction(async function () {
          await setBookmarksSyncId(db, newSyncId);
          await resetAllSyncStatuses(
            db,
            lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW
          );
          return newSyncId;
        });
      }
    );
  },

  /**
   * Ensures that the existing local sync ID, if any, is up-to-date with the
   * server. This is called when we sync with an existing account.
   *
   * We always take the server's sync ID. If we don't have an existing ID,
   * we're either syncing for the first time with an existing account, or Places
   * has automatically restored from a backup. If the sync IDs don't match,
   * we're likely syncing after a node reassignment, where another client
   * uploaded their bookmarks first.
   *
   * @param {string} newSyncId
   *   The server's sync ID.
   * @returns {Promise<void>}
   *   Resolved once the ID and all items have been updated.
   */
  async ensureCurrentSyncId(newSyncId) {
    if (!newSyncId || typeof newSyncId != "string") {
      throw new TypeError("Invalid new bookmarks sync ID");
    }
    await lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: ensureCurrentSyncId",
      async function (db) {
        let existingSyncId = await lazy.PlacesUtils.metadata.getWithConnection(
          db,
          BookmarkSyncUtils.SYNC_ID_META_KEY,
          ""
        );

        // If we don't have a sync ID, take the server's without resetting
        // sync statuses.
        if (!existingSyncId) {
          lazy.BookmarkSyncLog.info("Taking new bookmarks sync ID", {
            newSyncId,
          });
          await db.executeTransaction(() => setBookmarksSyncId(db, newSyncId));
          return;
        }

        // If the existing sync ID matches the server, great!
        if (existingSyncId == newSyncId) {
          lazy.BookmarkSyncLog.trace("Bookmarks sync ID up-to-date", {
            existingSyncId,
          });
          return;
        }

        // Otherwise, we have a sync ID, but it doesn't match, so we were likely
        // node reassigned. Take the server's sync ID and reset all items to
        // "UNKNOWN" so that we can merge.
        lazy.BookmarkSyncLog.info(
          "Bookmarks sync ID changed; resetting sync statuses",
          { existingSyncId, newSyncId }
        );
        await db.executeTransaction(async function () {
          await setBookmarksSyncId(db, newSyncId);
          await resetAllSyncStatuses(
            db,
            lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN
          );
        });
      }
    );
  },

  /**
   * Returns the last sync time, in seconds, for the bookmarks collection, or 0
   * if bookmarks have never synced before.
   */
  async getLastSync() {
    let lastSync = await lazy.PlacesUtils.metadata.get(
      BookmarkSyncUtils.LAST_SYNC_META_KEY,
      0
    );
    return lastSync / 1000;
  },

  /**
   * Updates the bookmarks collection last sync time.
   *
   * @param {number} lastSyncSeconds
   *   The collection last sync time, in seconds.
   */
  async setLastSync(lastSyncSeconds) {
    let lastSync = Math.floor(lastSyncSeconds * 1000);
    if (!Number.isInteger(lastSync)) {
      throw new TypeError("Invalid bookmarks last sync timestamp");
    }
    await lazy.PlacesUtils.metadata.set(
      BookmarkSyncUtils.LAST_SYNC_META_KEY,
      lastSync
    );
  },

  /**
   * Resets Sync metadata for bookmarks in Places. This function behaves
   * differently depending on the change source, and may be called from
   * `PlacesSyncUtils.bookmarks.reset` or
   * `PlacesUtils.bookmarks.eraseEverything`.
   *
   * - RESTORE: The user is restoring from a backup. Drop the sync ID, last
   *   sync time, and tombstones; reset sync statuses for remaining items to
   *   "NEW"; then set a flag to wipe the server and all other clients. On the
   *   next sync, we'll replace their bookmarks with ours.
   *
   * - RESTORE_ON_STARTUP: Places is automatically restoring from a backup to
   *   recover from a corrupt database. The sync ID, last sync time, and
   *   tombstones don't exist, since we don't back them up; reset sync statuses
   *   for the roots to "UNKNOWN"; but don't wipe the server. On the next sync,
   *   we'll merge the restored bookmarks with the ones on the server.
   *
   * - SYNC: Either another client told us to erase our bookmarks
   *   (`PlacesSyncUtils.bookmarks.wipe`), or the user disconnected Sync
   *   (`PlacesSyncUtils.bookmarks.reset`). In both cases, drop the existing
   *   sync ID, last sync time, and tombstones; reset sync statuses for
   *   remaining items to "NEW"; and don't wipe the server.
   *
   * @param {OpenedConnection} db
   *   The Sqlite.sys.mjs connection handle.
   * @param {nsINavBookmarksService.ChangeSource} source
   *   The change source constant.
   */
  async resetSyncMetadata(db, source) {
    if (
      ![
        lazy.PlacesUtils.bookmarks.SOURCES.RESTORE,
        lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
        lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
      ].includes(source)
    ) {
      return;
    }

    // Remove the sync ID and last sync time in all cases.
    await lazy.PlacesUtils.metadata.deleteWithConnection(
      db,
      BookmarkSyncUtils.SYNC_ID_META_KEY,
      BookmarkSyncUtils.LAST_SYNC_META_KEY
    );

    // If we're manually restoring from a backup, wipe the server and other
    // clients, so that we replace their bookmarks with the restored tree. If
    // we're automatically restoring to recover from a corrupt database, don't
    // wipe; we want to merge the restored tree with the one on the server.
    await lazy.PlacesUtils.metadata.setWithConnection(
      db,
      new Map([
        [
          BookmarkSyncUtils.WIPE_REMOTE_META_KEY,
          source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE,
        ],
      ])
    );

    // Reset change counters and sync statuses for roots and remaining
    // items, and drop tombstones.
    let syncStatus =
      source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP
        ? lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN
        : lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW;
    await resetAllSyncStatuses(db, syncStatus);
  },

  /**
   * Converts a Places GUID to a Sync record ID. Record IDs are identical to
   * Places GUIDs for all items except roots.
   *
   * @param {string} guid
   * @returns {string}
   */
  guidToRecordId(guid) {
    return lazy.ROOT_GUID_TO_RECORD_ID[guid] || guid;
  },

  /**
   * Converts a Sync record ID to a Places GUID.
   *
   * @param {string} recordId
   * @returns {string}
   */
  recordIdToGuid(recordId) {
    return lazy.ROOT_RECORD_ID_TO_GUID[recordId] || recordId;
  },

  /**
   * Fetches the record IDs for a folder's children, ordered by their position
   * within the folder.
   * Used only be tests - but that includes tps, so it lives here.
   *
   * @param {string} parentRecordId
   * @returns {Promise<string[]>}
   */
  fetchChildRecordIds(parentRecordId) {
    lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId);
    let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId);

    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: fetchChildRecordIds",
      async function (db) {
        let childGuids = await fetchChildGuids(db, parentGuid);
        return childGuids.map(guid => BookmarkSyncUtils.guidToRecordId(guid));
      }
    );
  },

  /**
   * Migrates an array of tuples from the old JSON-based tracker to the new sync
   * change counter.
   *
   * Sync calls this method before the first bookmark sync after the Places
   * schema migration.
   *
   * @param {{recordId: string, modified: number}[]} entries
   *   `recordId` is the record identifier. `modified` is when the change was
   *   added to the old tracker, in milliseconds.
   */
  migrateOldTrackerEntries(entries) {
    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: migrateOldTrackerEntries",
      function (db) {
        return db.executeTransaction(async function () {
          // Mark all existing bookmarks as synced, and clear their change
          // counters to avoid a full upload on the next sync. Note that
          // this means we'll miss changes made between startup and the first
          // post-migration sync, as well as changes made on a new release
          // channel that weren't synced before the user downgraded. This is
          // unfortunate, but no worse than the behavior of the old tracker.
          //
          // We also likely have bookmarks that don't exist on the server,
          // because the old tracker missed them. We'll eventually fix the
          // server once we decide on a repair strategy.
          await db.executeCached(
            `
            WITH RECURSIVE
            syncedItems(id) AS (
              SELECT b.id FROM moz_bookmarks b
              WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
                               'mobile______')
              UNION ALL
              SELECT b.id FROM moz_bookmarks b
              JOIN syncedItems s ON b.parent = s.id
            )
            UPDATE moz_bookmarks SET
              syncStatus = :syncStatus,
              syncChangeCounter = 0
            WHERE id IN syncedItems`,
            { syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
          );

          await db.executeCached(`DELETE FROM moz_bookmarks_deleted`);

          await db.executeCached(`CREATE TEMP TABLE moz_bookmarks_tracked (
            guid TEXT PRIMARY KEY,
            time INTEGER
          )`);

          try {
            for (let { recordId, modified } of entries) {
              let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
              if (!lazy.PlacesUtils.isValidGuid(guid)) {
                lazy.BookmarkSyncLog.warn(
                  `migrateOldTrackerEntries: Ignoring ` +
                    `change for invalid item ${guid}`
                );
                continue;
              }
              let time = lazy.PlacesUtils.toPRTime(
                Number.isFinite(modified) ? modified : Date.now()
              );
              await db.executeCached(
                `
                INSERT OR IGNORE INTO moz_bookmarks_tracked (guid, time)
                VALUES (:guid, :time)`,
                { guid, time }
              );
            }

            // Bump the change counter for existing tracked items.
            await db.executeCached(`
              INSERT OR REPLACE INTO moz_bookmarks (id, fk, type, parent,
                                                    position, title,
                                                    dateAdded, lastModified,
                                                    guid, syncChangeCounter,
                                                    syncStatus)
              SELECT b.id, b.fk, b.type, b.parent, b.position, b.title,
                     b.dateAdded, MAX(b.lastModified, t.time), b.guid,
                     b.syncChangeCounter + 1, b.syncStatus
              FROM moz_bookmarks b
              JOIN moz_bookmarks_tracked t ON b.guid = t.guid`);

            // Insert tombstones for nonexistent tracked items, using the most
            // recent deletion date for more accurate reconciliation. We assume
            // the tracked item belongs to a synced root.
            await db.executeCached(`
              INSERT OR REPLACE INTO moz_bookmarks_deleted (guid, dateRemoved)
              SELECT t.guid, MAX(IFNULL((SELECT dateRemoved FROM moz_bookmarks_deleted
                                         WHERE guid = t.guid), 0), t.time)
              FROM moz_bookmarks_tracked t
              LEFT JOIN moz_bookmarks b ON t.guid = b.guid
              WHERE b.guid IS NULL`);
          } finally {
            await db.executeCached(`DROP TABLE moz_bookmarks_tracked`);
          }
        });
      }
    );
  },

  /**
   * Reorders a folder's children, based on their order in the array of sync
   * IDs.
   *
   * Sync uses this method to reorder all synced children after applying all
   * incoming records.
   *
   * @param {string} parentRecordId
   *   The id of the parent folder.
   * @param {string[]} childRecordIds
   *   The children to reorder.
   * @returns {Promise<void>}
   *   Resolved when reordering is complete, rejects if an error happens while
   *   reordering.
   * @throws if the arguments are invalid.
   */
  order(parentRecordId, childRecordIds) {
    lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS.recordId(parentRecordId);
    if (!childRecordIds.length) {
      return undefined;
    }
    let parentGuid = BookmarkSyncUtils.recordIdToGuid(parentRecordId);
    if (parentGuid == lazy.PlacesUtils.bookmarks.rootGuid) {
      // Reordering roots doesn't make sense, but Sync will do this on the
      // first sync.
      return undefined;
    }
    let orderedChildrenGuids = childRecordIds.map(
      BookmarkSyncUtils.recordIdToGuid
    );
    return lazy.PlacesUtils.bookmarks.reorder(
      parentGuid,
      orderedChildrenGuids,
      {
        source: SOURCE_SYNC,
      }
    );
  },

  /**
   * Resolves to true if there are known sync changes.
   */
  havePendingChanges() {
    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: havePendingChanges",
      async function (db) {
        let rows = await db.executeCached(`
          WITH RECURSIVE
          syncedItems(id, guid, syncChangeCounter) AS (
            SELECT b.id, b.guid, b.syncChangeCounter
             FROM moz_bookmarks b
             WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
                              'mobile______')
            UNION ALL
            SELECT b.id, b.guid, b.syncChangeCounter
            FROM moz_bookmarks b
            JOIN syncedItems s ON b.parent = s.id
          ),
          changedItems(guid) AS (
            SELECT guid FROM syncedItems
            WHERE syncChangeCounter >= 1
            UNION ALL
            SELECT guid FROM moz_bookmarks_deleted
          )
          SELECT EXISTS(SELECT guid FROM changedItems) AS haveChanges`);
        return !!rows[0].getResultByName("haveChanges");
      }
    );
  },

  /**
   * Returns a changeset containing local bookmark changes since the last sync.
   *
   * @returns {Promise<object>}
   *   Resolves to an object containing records for changed bookmarks, keyed by
   *   the record ID.
   * @see pullSyncChanges for the implementation, and markChangesAsSyncing for
   *      an explanation of why we update the sync status.
   */
  pullChanges() {
    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: pullChanges",
      pullSyncChanges
    );
  },

  /**
   * Updates the sync status of all "NEW" bookmarks to "NORMAL", so that Sync
   * can recover correctly after an interrupted sync.
   *
   * @param {object} changeRecords
   *   A changeset containing sync change records, as returned by `pullChanges`.
   * @returns {Promise<void>}
   *   Resolved once all records have been updated.
   */
  markChangesAsSyncing(changeRecords) {
    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: markChangesAsSyncing",
      db => markChangesAsSyncing(db, changeRecords)
    );
  },

  /**
   * Decrements the sync change counter, updates the sync status, and cleans up
   * tombstones for successfully synced items. Sync calls this method at the
   * end of each bookmark sync.
   *
   * @param {object} changeRecords
   *   A changeset containing sync change records, as returned by `pullChanges`.
   * @returns {Promise<void>}
   *    Resolved once all records have been updated.
   */
  pushChanges(changeRecords) {
    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: pushChanges",
      async function (db) {
        let skippedCount = 0;
        let weakCount = 0;
        let updateParams = [];
        let tombstoneGuidsToRemove = [];

        for (let recordId in changeRecords) {
          // Validate change records to catch coding errors.
          let changeRecord = validateChangeRecord(
            "BookmarkSyncUtils: pushChanges",
            changeRecords[recordId],
            {
              tombstone: { required: true },
              counter: { required: true },
              synced: { required: true },
            }
          );

          // Skip weakly uploaded records.
          if (!changeRecord.counter) {
            weakCount++;
            continue;
          }

          // Sync sets the `synced` flag for reconciled or successfully
          // uploaded items. If upload failed, ignore the change; we'll
          // try again on the next sync.
          if (!changeRecord.synced) {
            skippedCount++;
            continue;
          }

          let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
          if (changeRecord.tombstone) {
            tombstoneGuidsToRemove.push(guid);
          } else {
            updateParams.push({
              guid,
              syncChangeDelta: changeRecord.counter,
              syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL,
            });
          }
        }

        // Reduce the change counter and update the sync status for
        // reconciled and uploaded items. If the bookmark was updated
        // during the sync, its change counter will still be > 0 for the
        // next sync.
        if (updateParams.length || tombstoneGuidsToRemove.length) {
          await db.executeTransaction(async function () {
            if (updateParams.length) {
              await db.executeCached(
                `
                UPDATE moz_bookmarks
                SET syncChangeCounter = MAX(syncChangeCounter - :syncChangeDelta, 0),
                    syncStatus = :syncStatus
                WHERE guid = :guid`,
                updateParams
              );
              // and if there are *both* bookmarks and tombstones for these
              // items, we nuke the tombstones.
              // This should be unlikely, but bad if it happens.
              let dupedGuids = updateParams.map(({ guid }) => guid);
              await removeUndeletedTombstones(db, dupedGuids);
            }
            await removeTombstones(db, tombstoneGuidsToRemove);
          });
        }

        lazy.BookmarkSyncLog.debug(`pushChanges: Processed change records`, {
          weak: weakCount,
          skipped: skippedCount,
          updated: updateParams.length,
        });
      }
    );
  },

  /**
   * Removes items from the database. Sync buffers incoming tombstones, and
   * calls this method to apply them at the end of each sync. Deletion
   * happens in three steps:
   *
   *  1. Remove all non-folder items. Deleting a folder on a remote client
   *     uploads tombstones for the folder and its children at the time of
   *     deletion. This preserves any new children we've added locally since
   *     the last sync.
   *  2. Reparent remaining children to the tombstoned folder's parent. This
   *     bumps the change counter for the children and their new parent.
   *  3. Remove the tombstoned folder. Because we don't do this in a
   *     transaction, the user might move new items into the folder before we
   *     can remove it. In that case, we keep the folder and upload the new
   *     subtree to the server.
   *
   * See the comment above `BookmarksStore::deletePending` for the details on
   * why delete works the way it does.
   *
   * @param {string[]} recordIds
   */
  remove(recordIds) {
    if (!recordIds.length) {
      return null;
    }

    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: remove",
      async function (db) {
        let folderGuids = [];
        for (let recordId of recordIds) {
          if (recordId in lazy.ROOT_RECORD_ID_TO_GUID) {
            lazy.BookmarkSyncLog.warn(
              `remove: Refusing to remove root ${recordId}`
            );
            continue;
          }
          let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
          let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid);
          if (!bookmarkItem) {
            lazy.BookmarkSyncLog.trace(`remove: Item ${guid} already removed`);
            continue;
          }
          let kind = await getKindForItem(db, bookmarkItem);
          if (kind == BookmarkSyncUtils.KINDS.FOLDER) {
            folderGuids.push(bookmarkItem.guid);
            continue;
          }
          let wasRemoved = await deleteSyncedAtom(bookmarkItem);
          if (wasRemoved) {
            lazy.BookmarkSyncLog.trace(
              `remove: Removed item ${guid} with kind ${kind}`
            );
          }
        }

        for (let guid of folderGuids) {
          let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid);
          if (!bookmarkItem) {
            lazy.BookmarkSyncLog.trace(
              `remove: Folder ${guid} already removed`
            );
            continue;
          }
          let wasRemoved = await deleteSyncedFolder(db, bookmarkItem);
          if (wasRemoved) {
            lazy.BookmarkSyncLog.trace(
              `remove: Removed folder ${bookmarkItem.guid}`
            );
          }
        }

        // TODO (Bug 1313890): Refactor the bookmarks engine to pull change records
        // before uploading, instead of returning records to merge into the engine's
        // initial changeset.
        return pullSyncChanges(db);
      }
    );
  },

  /**
   * Removes all bookmarks and tombstones from the database. Sync calls this
   * method when it receives a command from a remote client to wipe all stored
   * data.
   *
   * @returns {Promise} resolved once all items have been removed.
   */
  wipe() {
    return lazy.PlacesUtils.bookmarks.eraseEverything({
      source: SOURCE_SYNC,
    });
  },

  /**
   * Marks all bookmarks as "NEW" and removes all tombstones. Unlike `wipe`,
   * this keeps all existing bookmarks, and only clears their sync change
   * tracking info.
   *
   * @returns {Promise} resolved once all items have been updated.
   */
  reset() {
    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: reset",
      function (db) {
        return db.executeTransaction(async function () {
          await BookmarkSyncUtils.resetSyncMetadata(db, SOURCE_SYNC);
        });
      }
    );
  },

  /**
   * Fetches a Sync bookmark object for an item in the tree.
   *
   * Should only be used by SYNC TESTS.
   * We should remove this in bug XXXXXX, updating the tests to use
   * PlacesUtils.bookmarks.fetch.
   *
   * The object contains
   * the following properties, depending on the item's kind:
   *
   *  - kind (all): A string representing the item's kind.
   *  - recordId (all): The item's record ID.
   *  - parentRecordId (all): The record ID of the item's parent.
   *  - parentTitle (all): The title of the item's parent, used for de-duping.
   *    Omitted for the Places root and parents with empty titles.
   *  - dateAdded (all): Timestamp in milliseconds, when the bookmark was added
   *    or created on a remote device if known.
   *  - title ("bookmark", "folder", "query"): The item's title.
   *    Omitted if empty.
   *  - url ("bookmark", "query"): The item's URL.
   *  - tags ("bookmark", "query"): An array containing the item's tags.
   *  - keyword ("bookmark"): The bookmark's keyword, if one exists.
   *  - childRecordIds ("folder"): An array containing the record IDs of the item's
   *    children, used to determine child order.
   *  - folder ("query"): The tag folder name, if this is a tag query.
   *  - index ("separator"): The separator's position within its parent.
   *
   * @param {string} recordId
   */
  async fetch(recordId) {
    let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
    let bookmarkItem = await lazy.PlacesUtils.bookmarks.fetch(guid);
    if (!bookmarkItem) {
      return null;
    }
    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkSyncUtils: fetch",
      async function (db) {
        // Convert the Places bookmark object to a Sync bookmark and add
        // kind-specific properties. Titles are required for bookmarks,
        // and folders; optional for queries, and omitted for separators.
        let kind = await getKindForItem(db, bookmarkItem);
        let item;
        switch (kind) {
          case BookmarkSyncUtils.KINDS.BOOKMARK:
            item = await fetchBookmarkItem(db, bookmarkItem);
            break;

          case BookmarkSyncUtils.KINDS.QUERY:
            item = await fetchQueryItem(db, bookmarkItem);
            break;

          case BookmarkSyncUtils.KINDS.FOLDER:
            item = await fetchFolderItem(db, bookmarkItem);
            break;

          case BookmarkSyncUtils.KINDS.SEPARATOR:
            item = await placesBookmarkToSyncBookmark(db, bookmarkItem);
            item.index = bookmarkItem.index;
            break;

          default:
            throw new Error(`Unknown bookmark kind: ${kind}`);
        }

        // Sync uses the parent title for de-duping. All Sync bookmark objects
        // except the Places root should have this property.
        if (bookmarkItem.parentGuid) {
          let parent = await lazy.PlacesUtils.bookmarks.fetch(
            bookmarkItem.parentGuid
          );
          item.parentTitle = parent.title || "";
        }

        return item;
      }
    );
  },

  /**
   * Returns the sync change counter increment for a change source constant.
   *
   * @param {string} source
   */
  determineSyncChangeDelta(source) {
    // Don't bump the change counter when applying changes made by Sync, to
    // avoid sync loops.
    return source == lazy.PlacesUtils.bookmarks.SOURCES.SYNC ? 0 : 1;
  },

  /**
   * Returns the sync status for a new item inserted by a change source.
   *
   * @param {string} source
   */
  determineInitialSyncStatus(source) {
    if (source == lazy.PlacesUtils.bookmarks.SOURCES.SYNC) {
      // Incoming bookmarks are "NORMAL", since they already exist on the server.
      return lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL;
    }
    if (source == lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP) {
      // If the user restores from a backup, or Places automatically recovers
      // from a corrupt database, all prior sync tracking is lost. Setting the
      // status to "UNKNOWN" allows Sync to reconcile restored bookmarks with
      // those on the server.
      return lazy.PlacesUtils.bookmarks.SYNC_STATUS.UNKNOWN;
    }
    // For all other sources, mark items as "NEW". We'll update their statuses
    // to "NORMAL" after the first sync.
    return lazy.PlacesUtils.bookmarks.SYNC_STATUS.NEW;
  },

  /**
   * An internal helper that bumps the change counter for all bookmarks with
   * a given URL. This is used to update bookmarks when adding or changing a
   * tag or keyword entry.
   *
   * @param {OpenedConnection} db
   *   The Sqlite.sys.mjs connection handle.
   * @param {URL} url
   *   The bookmark URL object.
   * @param {number} syncChangeDelta
   *   The sync change counter increment.
   * @returns {Promise} resolved when the counters have been updated.
   */
  addSyncChangesForBookmarksWithURL(db, url, syncChangeDelta) {
    if (!url || !syncChangeDelta) {
      return Promise.resolve();
    }
    return db.executeCached(
      `
      UPDATE moz_bookmarks
        SET syncChangeCounter = syncChangeCounter + :syncChangeDelta
      WHERE type = :type AND
            fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND
                  url = :url)`,
      {
        syncChangeDelta,
        type: lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK,
        url: url.href,
      }
    );
  },

  /**
   * Returns `0` if no sensible timestamp could be found.
   * Otherwise, returns the earliest sensible timestamp between `existingMillis`
   * and `serverMillis`.
   *
   * @param {number} existingMillis
   * @param {number} serverMillis
   * @param {number} [lowerBound]
   */
  ratchetTimestampBackwards(
    existingMillis,
    serverMillis,
    lowerBound = BookmarkSyncUtils.EARLIEST_BOOKMARK_TIMESTAMP
  ) {
    const possible = [+existingMillis, +serverMillis].filter(
      n => !isNaN(n) && n > lowerBound
    );
    if (!possible.length) {
      return 0;
    }
    return Math.min(...possible);
  },

  /**
   * Rebuilds the left pane query for the mobile root under "All Bookmarks" if
   * necessary. Sync calls this method at the end of each bookmark sync. This
   * code should eventually move to `PlacesUIUtils#maybeRebuildLeftPane`; see
   * bug 647605.
   *
   * - If there are no mobile bookmarks, the query will not be created, or
   *   will be removed if it already exists.
   * - If there are mobile bookmarks, the query will be created if it doesn't
   *   exist, or will be updated with the correct title and URL otherwise.
   */
  async ensureMobileQuery() {
    let db = await lazy.PlacesUtils.promiseDBConnection();

    let mobileChildGuids = await fetchChildGuids(
      db,
      lazy.PlacesUtils.bookmarks.mobileGuid
    );
    let hasMobileBookmarks = !!mobileChildGuids.length;

    Services.prefs.setBoolPref(MOBILE_BOOKMARKS_PREF, hasMobileBookmarks);
  },
}));

PlacesSyncUtils.test = {};
PlacesSyncUtils.test.bookmarks = Object.freeze({
  /**
   * Inserts a synced bookmark into the tree. Only SYNC TESTS should call this
   * method; other callers should use `PlacesUtils.bookmarks.insert`.
   *
   * It is in this file rather than a test-only file because it makes use of
   * other internal functions here, so moving is not trivial - see bug 1662602.
   *
   * The following properties are supported:
   *  - kind: Required.
   *  - guid: Required.
   *  - parentGuid: Required.
   *  - url: Required for bookmarks.
   *  - tags: An optional array of tag strings.
   *  - keyword: An optional keyword string.
   *
   * Sync doesn't set the index, since it appends and reorders children
   * after applying all incoming items.
   *
   * @param {object} info
   *   Object representing a synced bookmark.
   * @returns {Promise<object>}
   *   Resolves to an object representing the created bookmark. Rejects if it's
   *   not possible to create the requested bookmark.
   * @throws if the arguments are invalid.
   */
  insert(info) {
    let insertInfo = validateNewBookmark("BookmarkTestUtils: insert", info);

    return lazy.PlacesUtils.withConnectionWrapper(
      "BookmarkTestUtils: insert",
      async db => {
        // If we're inserting a tag query, make sure the tag exists and fix the
        // folder ID to refer to the local tag folder.
        insertInfo = await updateTagQueryFolder(db, insertInfo);

        let bookmarkInfo = syncBookmarkToPlacesBookmark(insertInfo);
        let bookmarkItem =
          await lazy.PlacesUtils.bookmarks.insert(bookmarkInfo);
        let newItem = await insertBookmarkMetadata(
          db,
          bookmarkItem,
          insertInfo
        );

        return newItem;
      }
    );
  },
});

ChromeUtils.defineLazyGetter(lazy, "HistorySyncLog", () => {
  return lazy.Log.repository.getLogger("Sync.Engine.History.HistorySyncUtils");
});

ChromeUtils.defineLazyGetter(lazy, "BookmarkSyncLog", () => {
  // Use a sub-log of the bookmarks engine, so setting the level for that
  // engine also adjust the level of this log.
  return lazy.Log.repository.getLogger(
    "Sync.Engine.Bookmarks.BookmarkSyncUtils"
  );
});

function validateSyncBookmarkObject(name, input, behavior) {
  return lazy.PlacesUtils.validateItemProperties(
    name,
    lazy.PlacesUtils.SYNC_BOOKMARK_VALIDATORS,
    input,
    behavior
  );
}

// Validates a sync change record as returned by `pullChanges` and passed to
// `pushChanges`.
function validateChangeRecord(name, changeRecord, behavior) {
  return lazy.PlacesUtils.validateItemProperties(
    name,
    lazy.PlacesUtils.SYNC_CHANGE_RECORD_VALIDATORS,
    changeRecord,
    behavior
  );
}

// Similar to the private `fetchBookmarksByParent` implementation in
// `Bookmarks.sys.mjs`.
var fetchChildGuids = async function (db, parentGuid) {
  let rows = await db.executeCached(
    `
    SELECT guid
    FROM moz_bookmarks
    WHERE parent = (
      SELECT id FROM moz_bookmarks WHERE guid = :parentGuid
    )
    ORDER BY position`,
    { parentGuid }
  );
  return rows.map(row => row.getResultByName("guid"));
};

// Legacy tag queries may use a `place:` URL that refers to the tag folder ID.
// When we apply a synced tag query from a remote client, we need to update the
// URL to point to the local tag.
function updateTagQueryFolder(db, info) {
  if (
    info.kind != BookmarkSyncUtils.KINDS.QUERY ||
    !info.folder ||
    !info.url ||
    info.url.protocol != "place:"
  ) {
    return info;
  }

  let params = new URLSearchParams(info.url.pathname);
  let type = +params.get("type");
  if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
    return info;
  }

  lazy.BookmarkSyncLog.debug(
    `updateTagQueryFolder: Tag query folder: ${info.folder}`
  );

  // Rewrite the query to directly reference the tag.
  params.delete("queryType");
  params.delete("type");
  params.delete("folder");
  params.set("tag", info.folder);
  info.url = new URL(info.url.protocol + params);
  return info;
}

// Keywords are a 1 to 1 mapping between strings and pairs of (URL, postData).
// (the postData is not synced, so we ignore it). Sync associates keywords with
// bookmarks, which is not really accurate. -- We might already have a keyword
// with that name, or we might already have another bookmark with that URL with
// a different keyword, etc.
//
// If we don't handle those cases by removing the conflicting keywords first,
// the insertion  will fail, and the keywords will either be wrong, or missing.
// This function handles those cases.
function removeConflictingKeywords(bookmarkURL, newKeyword) {
  return lazy.PlacesUtils.withConnectionWrapper(
    "BookmarkSyncUtils: removeConflictingKeywords",
    async function (db) {
      let entryForURL = await lazy.PlacesUtils.keywords.fetch({
        url: bookmarkURL.href,
      });
      if (entryForURL && entryForURL.keyword !== newKeyword) {
        await lazy.PlacesUtils.keywords.remove({
          keyword: entryForURL.keyword,
          source: SOURCE_SYNC,
        });
        // This will cause us to reupload this record for this sync, but
        // without it, we will risk data corruption.
        await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL(
          db,
          entryForURL.url,
          1
        );
      }
      if (!newKeyword) {
        return;
      }
      let entryForNewKeyword = await lazy.PlacesUtils.keywords.fetch({
        keyword: newKeyword,
      });
      if (entryForNewKeyword) {
        await lazy.PlacesUtils.keywords.remove({
          keyword: entryForNewKeyword.keyword,
          source: SOURCE_SYNC,
        });
        await BookmarkSyncUtils.addSyncChangesForBookmarksWithURL(
          db,
          entryForNewKeyword.url,
          1
        );
      }
    }
  );
}

// Sets annotations, keywords, and tags on a new bookmark. Returns a Sync
// bookmark object.
async function insertBookmarkMetadata(db, bookmarkItem, insertInfo) {
  let newItem = await placesBookmarkToSyncBookmark(db, bookmarkItem);

  try {
    newItem.tags = tagItem(bookmarkItem, insertInfo.tags);
  } catch (ex) {
    lazy.BookmarkSyncLog.warn(
      `insertBookmarkMetadata: Error tagging item ${insertInfo.recordId}`,
      ex
    );
  }

  if (insertInfo.keyword) {
    await removeConflictingKeywords(bookmarkItem.url, insertInfo.keyword);
    await lazy.PlacesUtils.keywords.insert({
      keyword: insertInfo.keyword,
      url: bookmarkItem.url.href,
      source: SOURCE_SYNC,
    });
    newItem.keyword = insertInfo.keyword;
  }

  return newItem;
}

// Determines the Sync record kind for an existing bookmark.
async function getKindForItem(db, item) {
  switch (item.type) {
    case lazy.PlacesUtils.bookmarks.TYPE_FOLDER: {
      return BookmarkSyncUtils.KINDS.FOLDER;
    }
    case lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK:
      return item.url.protocol == "place:"
        ? BookmarkSyncUtils.KINDS.QUERY
        : BookmarkSyncUtils.KINDS.BOOKMARK;

    case lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR:
      return BookmarkSyncUtils.KINDS.SEPARATOR;
  }
  return null;
}

// Returns the `nsINavBookmarksService` bookmark type constant for a Sync
// record kind.
function getTypeForKind(kind) {
  switch (kind) {
    case BookmarkSyncUtils.KINDS.BOOKMARK:
    case BookmarkSyncUtils.KINDS.QUERY:
      return lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK;

    case BookmarkSyncUtils.KINDS.FOLDER:
      return lazy.PlacesUtils.bookmarks.TYPE_FOLDER;

    case BookmarkSyncUtils.KINDS.SEPARATOR:
      return lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR;
  }
  throw new Error(`Unknown bookmark kind: ${kind}`);
}

function validateNewBookmark(name, info) {
  let insertInfo = validateSyncBookmarkObject(name, info, {
    kind: { required: true },
    recordId: { required: true },
    url: {
      requiredIf: b =>
        [
          BookmarkSyncUtils.KINDS.BOOKMARK,
          BookmarkSyncUtils.KINDS.QUERY,
        ].includes(b.kind),
      validIf: b =>
        [
          BookmarkSyncUtils.KINDS.BOOKMARK,
          BookmarkSyncUtils.KINDS.QUERY,
        ].includes(b.kind),
    },
    parentRecordId: { required: true },
    title: {
      validIf: b =>
        [
          BookmarkSyncUtils.KINDS.BOOKMARK,
          BookmarkSyncUtils.KINDS.QUERY,
          BookmarkSyncUtils.KINDS.FOLDER,
        ].includes(b.kind) || b.title === "",
    },
    query: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY },
    folder: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY },
    tags: {
      validIf: b =>
        [
          BookmarkSyncUtils.KINDS.BOOKMARK,
          BookmarkSyncUtils.KINDS.QUERY,
        ].includes(b.kind),
    },
    keyword: {
      validIf: b =>
        [
          BookmarkSyncUtils.KINDS.BOOKMARK,
          BookmarkSyncUtils.KINDS.QUERY,
        ].includes(b.kind),
    },
    dateAdded: { required: false },
  });

  return insertInfo;
}

function tagItem(item, tags) {
  if (!item.url) {
    return [];
  }

  // Remove leading and trailing whitespace, then filter out empty tags.
  let newTags = tags ? tags.map(tag => tag.trim()).filter(Boolean) : [];

  // Removing the last tagged item will also remove the tag. To preserve
  // tag IDs, we temporarily tag a dummy URI, ensuring the tags exist.
  let dummyURI = lazy.PlacesUtils.toURI("about:weave#BStore_tagURI");
  let bookmarkURI = lazy.PlacesUtils.toURI(item.url);
  if (newTags && newTags.length) {
    lazy.PlacesUtils.tagging.tagURI(dummyURI, newTags, SOURCE_SYNC);
  }
  lazy.PlacesUtils.tagging.untagURI(bookmarkURI, null, SOURCE_SYNC);
  if (newTags && newTags.length) {
    lazy.PlacesUtils.tagging.tagURI(bookmarkURI, newTags, SOURCE_SYNC);
  }
  lazy.PlacesUtils.tagging.untagURI(dummyURI, null, SOURCE_SYNC);

  return newTags;
}

// Converts a Places bookmark to a Sync bookmark. This function maps Places
// GUIDs to record IDs and filters out extra Places properties like date added,
// last modified, and index.
async function placesBookmarkToSyncBookmark(db, bookmarkItem) {
  let item = {};

  for (let prop in bookmarkItem) {
    switch (prop) {
      // Record IDs are identical to Places GUIDs for all items except roots.
      case "guid":
        item.recordId = BookmarkSyncUtils.guidToRecordId(bookmarkItem.guid);
        break;

      case "parentGuid":
        item.parentRecordId = BookmarkSyncUtils.guidToRecordId(
          bookmarkItem.parentGuid
        );
        break;

      // Sync uses kinds instead of types, which distinguish between folders,
      // livemarks, bookmarks, and queries.
      case "type":
        item.kind = await getKindForItem(db, bookmarkItem);
        break;

      case "title":
      case "url":
        item[prop] = bookmarkItem[prop];
        break;

      case "dateAdded":
        item[prop] = new Date(bookmarkItem[prop]).getTime();
        break;
    }
  }

  return item;
}

// Converts a Sync bookmark object to a Places bookmark or livemark object.
// This function maps record IDs to Places GUIDs, and filters out extra Sync
// properties like keywords, tags. Returns an object that can be passed to
// `PlacesUtils.bookmarks.{insert, update}`.
function syncBookmarkToPlacesBookmark(info) {
  let bookmarkInfo = {
    source: SOURCE_SYNC,
  };

  for (let prop in info) {
    switch (prop) {
      case "kind":
        bookmarkInfo.type = getTypeForKind(info.kind);
        break;

      // Convert record IDs to Places GUIDs for roots.
      case "recordId":
        bookmarkInfo.guid = BookmarkSyncUtils.recordIdToGuid(info.recordId);
        break;

      case "dateAdded":
        bookmarkInfo.dateAdded = new Date(info.dateAdded);
        break;

      case "parentRecordId":
        bookmarkInfo.parentGuid = BookmarkSyncUtils.recordIdToGuid(
          info.parentRecordId
        );
        // Instead of providing an index, Sync reorders children at the end of
        // the sync using `BookmarkSyncUtils.order`. We explicitly specify the
        // default index here to prevent `PlacesUtils.bookmarks.update` from
        // throwing.
        bookmarkInfo.index = lazy.PlacesUtils.bookmarks.DEFAULT_INDEX;
        break;

      case "title":
      case "url":
        bookmarkInfo[prop] = info[prop];
        break;
    }
  }

  return bookmarkInfo;
}

// Creates and returns a Sync bookmark object containing the bookmark's
// tags, keyword.
var fetchBookmarkItem = async function (db, bookmarkItem) {
  let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);

  if (!item.title) {
    item.title = "";
  }

  item.tags = lazy.PlacesUtils.tagging.getTagsForURI(
    lazy.PlacesUtils.toURI(bookmarkItem.url)
  );

  let keywordEntry = await lazy.PlacesUtils.keywords.fetch({
    url: bookmarkItem.url,
  });
  if (keywordEntry) {
    item.keyword = keywordEntry.keyword;
  }

  return item;
};

// Creates and returns a Sync bookmark object containing the folder's children.
async function fetchFolderItem(db, bookmarkItem) {
  let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);

  if (!item.title) {
    item.title = "";
  }

  let childGuids = await fetchChildGuids(db, bookmarkItem.guid);
  item.childRecordIds = childGuids.map(guid =>
    BookmarkSyncUtils.guidToRecordId(guid)
  );

  return item;
}

// Creates and returns a Sync bookmark object containing the query's tag
// folder name.
async function fetchQueryItem(db, bookmarkItem) {
  let item = await placesBookmarkToSyncBookmark(db, bookmarkItem);

  let params = new URLSearchParams(bookmarkItem.url.pathname);
  let tags = params.getAll("tag");
  if (tags.length == 1) {
    item.folder = tags[0];
  }

  return item;
}

function addRowToChangeRecords(row, changeRecords) {
  let guid = row.getResultByName("guid");
  if (!guid) {
    throw new Error(`Changed item missing GUID`);
  }
  let isTombstone = !!row.getResultByName("tombstone");
  let recordId = BookmarkSyncUtils.guidToRecordId(guid);
  if (recordId in changeRecords) {
    let existingRecord = changeRecords[recordId];
    if (existingRecord.tombstone == isTombstone) {
      // Should never happen: `moz_bookmarks.guid` has a unique index, and
      // `moz_bookmarks_deleted.guid` is the primary key.
      throw new Error(`Duplicate item or tombstone ${recordId} in changeset`);
    }
    if (!existingRecord.tombstone && isTombstone) {
      // Don't replace undeleted items with tombstones...
      lazy.BookmarkSyncLog.warn(
        "addRowToChangeRecords: Ignoring tombstone for undeleted item",
        recordId
      );
      return;
    }
    // ...But replace undeleted tombstones with items.
    lazy.BookmarkSyncLog.warn(
      "addRowToChangeRecords: Replacing tombstone for undeleted item",
      recordId
    );
  }
  let modifiedAsPRTime = row.getResultByName("modified");
  let modified = modifiedAsPRTime / MICROSECONDS_PER_SECOND;
  if (Number.isNaN(modified) || modified <= 0) {
    lazy.BookmarkSyncLog.error(
      "addRowToChangeRecords: Invalid modified date for " + recordId,
      modifiedAsPRTime
    );
    modified = 0;
  }
  changeRecords[recordId] = {
    modified,
    counter: row.getResultByName("syncChangeCounter"),
    status: row.getResultByName("syncStatus"),
    tombstone: isTombstone,
    synced: false,
  };
}

/**
 * Queries the database for synced bookmarks and tombstones, and returns a
 * changeset for the Sync bookmarks engine.
 *
 * @param {OpenedConnection} db
 *   The Sqlite.sys.mjs connection handle.
 * @param {string[]} forGuids
 *   Fetch Sync tracking information for only the requested GUIDs.
 * @returns {Promise<object>}
 *   Resolves to an object containing records for changed bookmarks, keyed by
 *   the record ID.
 */
var pullSyncChanges = async function (db, forGuids = []) {
  let changeRecords = {};

  let itemConditions = ["syncChangeCounter >= 1"];
  let tombstoneConditions = ["1 = 1"];
  if (forGuids.length) {
    let restrictToGuids = `guid IN (${forGuids
      .map(guid => JSON.stringify(guid))
      .join(",")})`;
    itemConditions.push(restrictToGuids);
    tombstoneConditions.push(restrictToGuids);
  }

  let rows = await db.executeCached(
    `
    WITH RECURSIVE
    syncedItems(id, guid, modified, syncChangeCounter, syncStatus) AS (
      SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus
       FROM moz_bookmarks b
       WHERE b.guid IN ('menu________', 'toolbar_____', 'unfiled_____',
                        'mobile______')
      UNION ALL
      SELECT b.id, b.guid, b.lastModified, b.syncChangeCounter, b.syncStatus
      FROM moz_bookmarks b
      JOIN syncedItems s ON b.parent = s.id
    )
    SELECT guid, modified, syncChangeCounter, syncStatus, 0 AS tombstone
    FROM syncedItems
    WHERE ${itemConditions.join(" AND ")}
    UNION ALL
    SELECT guid, dateRemoved AS modified, 1 AS syncChangeCounter,
           :deletedSyncStatus, 1 AS tombstone
    FROM moz_bookmarks_deleted
    WHERE ${tombstoneConditions.join(" AND ")}`,
    { deletedSyncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
  );
  for (let row of rows) {
    addRowToChangeRecords(row, changeRecords);
  }

  return changeRecords;
};

// Moves a synced folder's remaining children to its parent, and deletes the
// folder if it's empty.
async function deleteSyncedFolder(db, bookmarkItem) {
  // At this point, any member in the folder that remains is either a folder
  // pending deletion (which we'll get to in this function), or an item that
  // should not be deleted. To avoid deleting these items, we first move them
  // to the parent of the folder we're about to delete.
  let childGuids = await fetchChildGuids(db, bookmarkItem.guid);
  if (!childGuids.length) {
    // No children -- just delete the folder.
    return deleteSyncedAtom(bookmarkItem);
  }

  if (lazy.BookmarkSyncLog.level <= lazy.Log.Level.Trace) {
    lazy.BookmarkSyncLog.trace(
      `deleteSyncedFolder: Moving ${JSON.stringify(childGuids)} children of ` +
        `"${bookmarkItem.guid}" to grandparent
      "${BookmarkSyncUtils.guidToRecordId(bookmarkItem.parentGuid)}" before ` +
        `deletion`
    );
  }

  // Move children out of the parent and into the grandparent
  for (let guid of childGuids) {
    await lazy.PlacesUtils.bookmarks.update({
      guid,
      parentGuid: bookmarkItem.parentGuid,
      index: lazy.PlacesUtils.bookmarks.DEFAULT_INDEX,
      // `SYNC_REPARENT_REMOVED_FOLDER_CHILDREN` bumps the change counter for
      // the child and its new parent, without incrementing the bookmark
      // tracker's score.
      //
      // We intentionally don't check if the child is one we'll remove later,
      // so it's possible we'll bump the change counter of the closest living
      // ancestor when it's not needed. This avoids inconsistency if removal
      // is interrupted, since we don't run this operation in a transaction.
      source:
        lazy.PlacesUtils.bookmarks.SOURCES
          .SYNC_REPARENT_REMOVED_FOLDER_CHILDREN,
    });
  }

  // Delete the (now empty) parent
  try {
    await lazy.PlacesUtils.bookmarks.remove(bookmarkItem.guid, {
      preventRemovalOfNonEmptyFolders: true,
      // We don't want to bump the change counter for this deletion, because
      // a tombstone for the folder is already on the server.
      source: SOURCE_SYNC,
    });
  } catch (e) {
    // We failed, probably because someone added something to this folder
    // between when we got the children and now (or the database is corrupt,
    // or something else happened...) This is unlikely, but possible. To
    // avoid corruption in this case, we need to reupload the record to the
    // server.
    //
    // (Ideally this whole operation would be done in a transaction, and this
    // wouldn't be possible).
    lazy.BookmarkSyncLog.trace(
      `deleteSyncedFolder: Error removing parent ` +
        `${bookmarkItem.guid} after reparenting children`,
      e
    );
    return false;
  }

  return true;
}

// Removes a synced bookmark or empty folder from the database.
var deleteSyncedAtom = async function (bookmarkItem) {
  try {
    await lazy.PlacesUtils.bookmarks.remove(bookmarkItem.guid, {
      preventRemovalOfNonEmptyFolders: true,
      source: SOURCE_SYNC,
    });
  } catch (ex) {
    // Likely already removed.
    lazy.BookmarkSyncLog.trace(
      `deleteSyncedAtom: Error removing ` + bookmarkItem.guid,
      ex
    );
    return false;
  }

  return true;
};

/**
 * Updates the sync status on all "NEW" and "UNKNOWN" bookmarks to "NORMAL".
 *
 * We do this when pulling changes instead of in `pushChanges` to make sure
 * we write tombstones if a new item is deleted after an interrupted sync. (For
 * example, if a "NEW" record is uploaded or reconciled, then the app is closed
 * before Sync calls `pushChanges`).
 *
 * @param {OpenedConnection} db
 * @param {object} changeRecords
 */
function markChangesAsSyncing(db, changeRecords) {
  let unsyncedGuids = [];
  for (let recordId in changeRecords) {
    if (changeRecords[recordId].tombstone) {
      continue;
    }
    if (
      changeRecords[recordId].status ==
      lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL
    ) {
      continue;
    }
    let guid = BookmarkSyncUtils.recordIdToGuid(recordId);
    unsyncedGuids.push(JSON.stringify(guid));
  }
  if (!unsyncedGuids.length) {
    return Promise.resolve();
  }
  return db.execute(
    `
    UPDATE moz_bookmarks
    SET syncStatus = :syncStatus
    WHERE guid IN (${unsyncedGuids.join(",")})`,
    { syncStatus: lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL }
  );
}

/**
 * Removes tombstones for successfully synced items.
 *
 * @param {OpenedConnection} db
 * @param {string[]} guids
 * @returns {Promise}
 */
var removeTombstones = function (db, guids) {
  if (!guids.length) {
    return Promise.resolve();
  }
  return db.execute(`
    DELETE FROM moz_bookmarks_deleted
    WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")})`);
};

/**
 * Removes tombstones for successfully synced items where the specified GUID
 * exists in *both* the bookmarks and tombstones tables.
 *
 * @param {OpenedConnection} db
 * @param {string[]} guids
 * @returns {Promise}
 */
var removeUndeletedTombstones = function (db, guids) {
  if (!guids.length) {
    return Promise.resolve();
  }
  // sqlite can't join in a DELETE, so we use a subquery.
  return db.execute(`
    DELETE FROM moz_bookmarks_deleted
    WHERE guid IN (${guids.map(guid => JSON.stringify(guid)).join(",")})
    AND guid IN (SELECT guid from moz_bookmarks)`);
};

// Sets the history sync ID and clears the last sync time.
async function setHistorySyncId(db, newSyncId) {
  await lazy.PlacesUtils.metadata.setWithConnection(
    db,
    new Map([[HistorySyncUtils.SYNC_ID_META_KEY, newSyncId]])
  );

  await lazy.PlacesUtils.metadata.deleteWithConnection(
    db,
    HistorySyncUtils.LAST_SYNC_META_KEY
  );
}

// Sets the bookmarks sync ID and clears the last sync time.
async function setBookmarksSyncId(db, newSyncId) {
  await lazy.PlacesUtils.metadata.setWithConnection(
    db,
    new Map([[BookmarkSyncUtils.SYNC_ID_META_KEY, newSyncId]])
  );

  await lazy.PlacesUtils.metadata.deleteWithConnection(
    db,
    BookmarkSyncUtils.LAST_SYNC_META_KEY,
    BookmarkSyncUtils.WIPE_REMOTE_META_KEY
  );
}

// Bumps the change counter and sets the given sync status for all bookmarks,
// and drops stale tombstones.
async function resetAllSyncStatuses(db, syncStatus) {
  await db.execute(
    `
    UPDATE moz_bookmarks
    SET syncChangeCounter = 1,
        syncStatus = :syncStatus`,
    { syncStatus }
  );

  // Drop stale tombstones.
  await db.execute("DELETE FROM moz_bookmarks_deleted");
}

/**
 * Other clients might have new fields we don't quite understand yet,
 * so we add it to a "unknownFields" field to roundtrip back to the server
 * so other clients don't experience data loss
 *
 * @param {object} record
 *   An object, usually from the server, and will iterate through the keys
 *   and extract any fields that are unknown to this client.
 * @param {string[]} validFields
 *   An array of keys we know are valid and should ignore.
 * @returns {string} json object containing unknownfields, null if none found
 */
PlacesSyncUtils.extractUnknownFields = (record, validFields) => {
  let result = Object.keys(record).reduce(
    ({ unknownFields, hasUnknownFields }, key) => {
      if (validFields.includes(key)) {
        return { unknownFields, hasUnknownFields };
      }
      unknownFields[key] = record[key];
      return { unknownFields, hasUnknownFields: true };
    },
    { unknownFields: {}, hasUnknownFields: false }
  );
  if (result.hasUnknownFields) {
    // For simplicity, we store the unknown fields as a string
    // since we never operate on it and just need it for roundtripping
    return JSON.stringify(result.unknownFields);
  }
  return null;
};
