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

import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
import {
  Changeset,
  Store,
  SyncEngine,
  Tracker,
} from "resource://services-sync/engines.sys.mjs";
import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
import { Svc, Utils } from "resource://services-sync/util.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Async: "resource://services-common/async.sys.mjs",
  Observers: "resource://services-common/observers.sys.mjs",
  PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs",
  PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs",
  PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  Resource: "resource://services-sync/resource.sys.mjs",
  SyncedBookmarksMirror: "resource://gre/modules/SyncedBookmarksMirror.sys.mjs",
});

const PLACES_MAINTENANCE_INTERVAL_SECONDS = 4 * 60 * 60; // 4 hours.

const FOLDER_SORTINDEX = 1000000;

// Roots that should be deleted from the server, instead of applied locally.
// This matches `AndroidBrowserBookmarksRepositorySession::forbiddenGUID`,
// but allows tags because we don't want to reparent tag folders or tag items
// to "unfiled".
const FORBIDDEN_INCOMING_IDS = ["pinned", "places", "readinglist"];

// Items with these parents should be deleted from the server. We allow
// children of the Places root, to avoid orphaning left pane queries and other
// descendants of custom roots.
const FORBIDDEN_INCOMING_PARENT_IDS = ["pinned", "readinglist"];

// The tracker ignores changes made by import and restore, to avoid bumping the
// score and triggering syncs during the process, as well as changes made by
// Sync.
ChromeUtils.defineLazyGetter(lazy, "IGNORED_SOURCES", () => [
  lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
  lazy.PlacesUtils.bookmarks.SOURCES.IMPORT,
  lazy.PlacesUtils.bookmarks.SOURCES.RESTORE,
  lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
  lazy.PlacesUtils.bookmarks.SOURCES.SYNC_REPARENT_REMOVED_FOLDER_CHILDREN,
]);

// The validation telemetry version for the engine. Version 1 is collected
// by `bookmark_validator.js`, and checks value as well as structure
// differences. Version 2 is collected by the engine as part of building the
// remote tree, and checks structure differences only.
const BOOKMARK_VALIDATOR_VERSION = 2;

// The maximum time that the engine should wait before aborting a bookmark
// merge.
const BOOKMARK_APPLY_TIMEOUT_MS = 5 * 60 * 60 * 1000; // 5 minutes

// The default frecency value to use when not known.
const FRECENCY_UNKNOWN = -1;

// Returns the constructor for a bookmark record type.
function getTypeObject(type) {
  switch (type) {
    case "bookmark":
      return Bookmark;
    case "query":
      return BookmarkQuery;
    case "folder":
      return BookmarkFolder;
    case "livemark":
      return Livemark;
    case "separator":
      return BookmarkSeparator;
    case "item":
      return PlacesItem;
  }
  return null;
}

export function PlacesItem(collection, id, type) {
  CryptoWrapper.call(this, collection, id);
  this.type = type || "item";
}

PlacesItem.prototype = {
  async decrypt(keyBundle) {
    // Do the normal CryptoWrapper decrypt, but change types before returning
    let clear = await CryptoWrapper.prototype.decrypt.call(this, keyBundle);

    // Convert the abstract places item to the actual object type
    if (!this.deleted) {
      Object.setPrototypeOf(this, this.getTypeObject(this.type).prototype);
    }

    return clear;
  },

  getTypeObject: function PlacesItem_getTypeObject(type) {
    let recordObj = getTypeObject(type);
    if (!recordObj) {
      throw new Error("Unknown places item object type: " + type);
    }
    return recordObj;
  },

  _logName: "Sync.Record.PlacesItem",

  // Converts the record to a Sync bookmark object that can be passed to
  // `PlacesSyncUtils.bookmarks.{insert, update}`.
  toSyncBookmark() {
    let result = {
      kind: this.type,
      recordId: this.id,
      parentRecordId: this.parentid,
    };
    let dateAdded = lazy.PlacesSyncUtils.bookmarks.ratchetTimestampBackwards(
      this.dateAdded,
      +this.modified * 1000
    );
    if (dateAdded > 0) {
      result.dateAdded = dateAdded;
    }
    return result;
  },

  // Populates the record from a Sync bookmark object returned from
  // `PlacesSyncUtils.bookmarks.fetch`.
  fromSyncBookmark(item) {
    this.parentid = item.parentRecordId;
    this.parentName = item.parentTitle;
    if (item.dateAdded) {
      this.dateAdded = item.dateAdded;
    }
  },
};

Object.setPrototypeOf(PlacesItem.prototype, CryptoWrapper.prototype);

Utils.deferGetSet(PlacesItem, "cleartext", [
  "hasDupe",
  "parentid",
  "parentName",
  "type",
  "dateAdded",
]);

export function Bookmark(collection, id, type) {
  PlacesItem.call(this, collection, id, type || "bookmark");
}

Bookmark.prototype = {
  _logName: "Sync.Record.Bookmark",

  toSyncBookmark() {
    let info = PlacesItem.prototype.toSyncBookmark.call(this);
    info.title = this.title;
    info.url = this.bmkUri;
    info.description = this.description;
    info.tags = this.tags;
    info.keyword = this.keyword;
    return info;
  },

  fromSyncBookmark(item) {
    PlacesItem.prototype.fromSyncBookmark.call(this, item);
    this.title = item.title;
    this.bmkUri = item.url.href;
    this.description = item.description;
    this.tags = item.tags;
    this.keyword = item.keyword;
  },
};

Object.setPrototypeOf(Bookmark.prototype, PlacesItem.prototype);

Utils.deferGetSet(Bookmark, "cleartext", [
  "title",
  "bmkUri",
  "description",
  "tags",
  "keyword",
]);

export function BookmarkQuery(collection, id) {
  Bookmark.call(this, collection, id, "query");
}

BookmarkQuery.prototype = {
  _logName: "Sync.Record.BookmarkQuery",

  toSyncBookmark() {
    let info = Bookmark.prototype.toSyncBookmark.call(this);
    info.folder = this.folderName || undefined; // empty string -> undefined
    info.query = this.queryId;
    return info;
  },

  fromSyncBookmark(item) {
    Bookmark.prototype.fromSyncBookmark.call(this, item);
    this.folderName = item.folder || undefined; // empty string -> undefined
    this.queryId = item.query;
  },
};

Object.setPrototypeOf(BookmarkQuery.prototype, Bookmark.prototype);

Utils.deferGetSet(BookmarkQuery, "cleartext", ["folderName", "queryId"]);

export function BookmarkFolder(collection, id, type) {
  PlacesItem.call(this, collection, id, type || "folder");
}

BookmarkFolder.prototype = {
  _logName: "Sync.Record.Folder",

  toSyncBookmark() {
    let info = PlacesItem.prototype.toSyncBookmark.call(this);
    info.description = this.description;
    info.title = this.title;
    return info;
  },

  fromSyncBookmark(item) {
    PlacesItem.prototype.fromSyncBookmark.call(this, item);
    this.title = item.title;
    this.description = item.description;
    this.children = item.childRecordIds;
  },
};

Object.setPrototypeOf(BookmarkFolder.prototype, PlacesItem.prototype);

Utils.deferGetSet(BookmarkFolder, "cleartext", [
  "description",
  "title",
  "children",
]);

export function Livemark(collection, id) {
  BookmarkFolder.call(this, collection, id, "livemark");
}

Livemark.prototype = {
  _logName: "Sync.Record.Livemark",

  toSyncBookmark() {
    let info = BookmarkFolder.prototype.toSyncBookmark.call(this);
    info.feed = this.feedUri;
    info.site = this.siteUri;
    return info;
  },

  fromSyncBookmark(item) {
    BookmarkFolder.prototype.fromSyncBookmark.call(this, item);
    this.feedUri = item.feed.href;
    if (item.site) {
      this.siteUri = item.site.href;
    }
  },
};

Object.setPrototypeOf(Livemark.prototype, BookmarkFolder.prototype);

Utils.deferGetSet(Livemark, "cleartext", ["siteUri", "feedUri"]);

export function BookmarkSeparator(collection, id) {
  PlacesItem.call(this, collection, id, "separator");
}

BookmarkSeparator.prototype = {
  _logName: "Sync.Record.Separator",

  fromSyncBookmark(item) {
    PlacesItem.prototype.fromSyncBookmark.call(this, item);
    this.pos = item.index;
  },
};

Object.setPrototypeOf(BookmarkSeparator.prototype, PlacesItem.prototype);

Utils.deferGetSet(BookmarkSeparator, "cleartext", "pos");

/**
 * The bookmarks engine uses a different store that stages downloaded bookmarks
 * in a separate database, instead of writing directly to Places. The buffer
 * handles reconciliation, so we stub out `_reconcile`, and wait to pull changes
 * until we're ready to upload.
 */
export function BookmarksEngine(service) {
  SyncEngine.call(this, "Bookmarks", service);
}

BookmarksEngine.prototype = {
  _recordObj: PlacesItem,
  _trackerObj: BookmarksTracker,
  _storeObj: BookmarksStore,
  version: 2,
  // Used to override the engine name in telemetry, so that we can distinguish
  // this engine from the old, now removed non-buffered engine.
  overrideTelemetryName: "bookmarks-buffered",

  // Needed to ensure we don't miss items when resuming a sync that failed or
  // aborted early.
  _defaultSort: "oldest",

  syncPriority: 4,
  allowSkippedRecord: false,

  async _ensureCurrentSyncID(newSyncID) {
    await lazy.PlacesSyncUtils.bookmarks.ensureCurrentSyncId(newSyncID);
    let buf = await this._store.ensureOpenMirror();
    await buf.ensureCurrentSyncId(newSyncID);
  },

  async ensureCurrentSyncID(newSyncID) {
    let shouldWipeRemote =
      await lazy.PlacesSyncUtils.bookmarks.shouldWipeRemote();
    if (!shouldWipeRemote) {
      this._log.debug(
        "Checking if server sync ID ${newSyncID} matches existing",
        { newSyncID }
      );
      await this._ensureCurrentSyncID(newSyncID);
      return newSyncID;
    }
    // We didn't take the new sync ID because we need to wipe the server
    // and other clients after a restore. Send the command, wipe the
    // server, and reset our sync ID to reupload everything.
    this._log.debug(
      "Ignoring server sync ID ${newSyncID} after restore; " +
        "wiping server and resetting sync ID",
      { newSyncID }
    );
    await this.service.clientsEngine.sendCommand(
      "wipeEngine",
      [this.name],
      null,
      { reason: "bookmark-restore" }
    );
    let assignedSyncID = await this.resetSyncID();
    return assignedSyncID;
  },

  async getSyncID() {
    return lazy.PlacesSyncUtils.bookmarks.getSyncId();
  },

  async resetSyncID() {
    await this._deleteServerCollection();
    return this.resetLocalSyncID();
  },

  async resetLocalSyncID() {
    let newSyncID = await lazy.PlacesSyncUtils.bookmarks.resetSyncId();
    this._log.debug("Assigned new sync ID ${newSyncID}", { newSyncID });
    let buf = await this._store.ensureOpenMirror();
    await buf.ensureCurrentSyncId(newSyncID);
    return newSyncID;
  },

  async getLastSync() {
    let mirror = await this._store.ensureOpenMirror();
    return mirror.getCollectionHighWaterMark();
  },

  async setLastSync(lastSync) {
    let mirror = await this._store.ensureOpenMirror();
    await mirror.setCollectionLastModified(lastSync);
    // Update the last sync time in Places so that reverting to the original
    // bookmarks engine doesn't download records we've already applied.
    await lazy.PlacesSyncUtils.bookmarks.setLastSync(lastSync);
  },

  async _syncStartup() {
    await super._syncStartup();

    try {
      // For first syncs, back up the user's bookmarks.
      let lastSync = await this.getLastSync();
      if (!lastSync) {
        this._log.debug("Bookmarks backup starting");
        await lazy.PlacesBackups.create(null, true);
        this._log.debug("Bookmarks backup done");
      }
    } catch (ex) {
      // Failure to create a backup is somewhat bad, but probably not bad
      // enough to prevent syncing of bookmarks - so just log the error and
      // continue.
      this._log.warn(
        "Error while backing up bookmarks, but continuing with sync",
        ex
      );
    }
  },

  async _sync() {
    try {
      await super._sync();
      if (this._ranMaintenanceOnLastSync) {
        // If the last sync failed, we ran maintenance, and this sync succeeded,
        // maintenance likely fixed the issue.
        this._ranMaintenanceOnLastSync = false;
        Glean.sync.maintenanceFixBookmarks.record();
        this.service.recordTelemetryEvent("maintenance", "fix", "bookmarks");
      }
    } catch (ex) {
      if (
        lazy.Async.isShutdownException(ex) ||
        ex.status > 0 ||
        ex.name == "InterruptedError"
      ) {
        // Don't run maintenance on shutdown or HTTP errors, or if we aborted
        // the sync because the user changed their bookmarks during merging.
        throw ex;
      }
      if (ex.name == "MergeConflictError") {
        this._log.warn(
          "Bookmark syncing ran into a merge conflict error...will retry later"
        );
        return;
      }
      // Run Places maintenance periodically to try to recover from corruption
      // that might have caused the sync to fail. We cap the interval because
      // persistent failures likely indicate a problem that won't be fixed by
      // running maintenance after every failed sync.
      let elapsedSinceMaintenance =
        Date.now() / 1000 -
        Services.prefs.getIntPref("places.database.lastMaintenance", 0);
      if (elapsedSinceMaintenance >= PLACES_MAINTENANCE_INTERVAL_SECONDS) {
        this._log.error(
          "Bookmark sync failed, ${elapsedSinceMaintenance}s " +
            "elapsed since last run; running Places maintenance",
          { elapsedSinceMaintenance }
        );
        await lazy.PlacesDBUtils.maintenanceOnIdle();
        this._ranMaintenanceOnLastSync = true;
        Glean.sync.maintenanceRunBookmarks.record();
        this.service.recordTelemetryEvent("maintenance", "run", "bookmarks");
      } else {
        this._ranMaintenanceOnLastSync = false;
      }
      throw ex;
    }
  },

  async _syncFinish() {
    await SyncEngine.prototype._syncFinish.call(this);
    await lazy.PlacesSyncUtils.bookmarks.ensureMobileQuery();
  },

  async pullAllChanges() {
    return this.pullNewChanges();
  },

  async trackRemainingChanges() {
    let changes = this._modified.changes;
    await lazy.PlacesSyncUtils.bookmarks.pushChanges(changes);
  },

  _deleteId(id) {
    this._noteDeletedId(id);
  },

  // The bookmarks engine rarely calls this method directly, except in tests or
  // when handling a `reset{All, Engine}` command from another client. We
  // usually reset local Sync metadata on a sync ID mismatch, which both engines
  // override with logic that lives in Places and the mirror.
  async _resetClient() {
    await super._resetClient();
    await lazy.PlacesSyncUtils.bookmarks.reset();
    let buf = await this._store.ensureOpenMirror();
    await buf.reset();
  },

  // Cleans up the Places root, reading list items (ignored in bug 762118,
  // removed in bug 1155684), and pinned sites.
  _shouldDeleteRemotely(incomingItem) {
    return (
      FORBIDDEN_INCOMING_IDS.includes(incomingItem.id) ||
      FORBIDDEN_INCOMING_PARENT_IDS.includes(incomingItem.parentid)
    );
  },

  emptyChangeset() {
    return new BookmarksChangeset();
  },

  async _apply() {
    let buf = await this._store.ensureOpenMirror();
    let watchdog = this._newWatchdog();
    watchdog.start(BOOKMARK_APPLY_TIMEOUT_MS);

    try {
      let recordsToUpload = await buf.apply({
        remoteTimeSeconds: lazy.Resource.serverTime,
        signal: watchdog.signal,
      });
      this._modified.replace(recordsToUpload);
    } finally {
      watchdog.stop();
      if (watchdog.abortReason) {
        this._log.warn(`Aborting bookmark merge: ${watchdog.abortReason}`);
      }
    }
  },

  async _processIncoming(newitems) {
    await super._processIncoming(newitems);
    await this._apply();
  },

  async _reconcile() {
    return true;
  },

  async _createRecord(id) {
    let record = await this._doCreateRecord(id);
    if (!record.deleted) {
      // Set hasDupe on all (non-deleted) records since we don't use it and we
      // want to minimize the risk of older clients corrupting records. Note
      // that the SyncedBookmarksMirror sets it for all records that it created,
      // but we would like to ensure that weakly uploaded records are marked as
      // hasDupe as well.
      record.hasDupe = true;
    }
    return record;
  },

  async _doCreateRecord(id) {
    let change = this._modified.changes[id];
    if (!change) {
      this._log.error(
        "Creating record for item ${id} not in strong changeset",
        { id }
      );
      throw new TypeError("Can't create record for unchanged item");
    }
    let record = this._recordFromCleartext(id, change.cleartext);
    record.sortindex = await this._store._calculateIndex(record);
    return record;
  },

  _recordFromCleartext(id, cleartext) {
    let recordObj = getTypeObject(cleartext.type);
    if (!recordObj) {
      this._log.warn(
        "Creating record for item ${id} with unknown type ${type}",
        { id, type: cleartext.type }
      );
      recordObj = PlacesItem;
    }
    let record = new recordObj(this.name, id);
    record.cleartext = cleartext;
    return record;
  },

  async pullChanges() {
    return {};
  },

  /**
   * Writes successfully uploaded records back to the mirror, so that the
   * mirror matches the server. We update the mirror before updating Places,
   * which has implications for interrupted syncs.
   *
   * 1. Sync interrupted during upload; server doesn't support atomic uploads.
   *    We'll download and reapply everything that we uploaded before the
   *    interruption. All locally changed items retain their change counters.
   * 2. Sync interrupted during upload; atomic uploads enabled. The server
   *    discards the batch. All changed local items retain their change
   *    counters, so the next sync resumes cleanly.
   * 3. Sync interrupted during upload; outgoing records can't fit in a single
   *    batch. We'll download and reapply all records through the most recent
   *    committed batch. This is a variation of (1).
   * 4. Sync interrupted after we update the mirror, but before cleanup. The
   *    mirror matches the server, but locally changed items retain their change
   *    counters. Reuploading them on the next sync should be idempotent, though
   *    unnecessary. If another client makes a conflicting remote change before
   *    we sync again, we may incorrectly prefer the local state.
   * 5. Sync completes successfully. We'll update the mirror, and reset the
   *    change counters for all items.
   */
  async _onRecordsWritten(succeeded, failed, serverModifiedTime) {
    let records = [];
    for (let id of succeeded) {
      let change = this._modified.changes[id];
      if (!change) {
        // TODO (Bug 1433178): Write weakly uploaded records back to the mirror.
        this._log.info("Uploaded record not in strong changeset", id);
        continue;
      }
      if (!change.synced) {
        this._log.info("Record in strong changeset not uploaded", id);
        continue;
      }
      let cleartext = change.cleartext;
      if (!cleartext) {
        this._log.error(
          "Missing Sync record cleartext for ${id} in ${change}",
          { id, change }
        );
        throw new TypeError("Missing cleartext for uploaded Sync record");
      }
      let record = this._recordFromCleartext(id, cleartext);
      record.modified = serverModifiedTime;
      records.push(record);
    }
    let buf = await this._store.ensureOpenMirror();
    await buf.store(records, { needsMerge: false });
  },

  async finalize() {
    await super.finalize();
    await this._store.finalize();
  },
};

Object.setPrototypeOf(BookmarksEngine.prototype, SyncEngine.prototype);

/**
 * The bookmarks store delegates to the mirror for staging and applying
 * records. Most `Store` methods intentionally remain abstract, so you can't use
 * this store to create or update bookmarks in Places. All changes must go
 * through the mirror, which takes care of merging and producing a valid tree.
 */
function BookmarksStore(name, engine) {
  Store.call(this, name, engine);
}

BookmarksStore.prototype = {
  _openMirrorPromise: null,

  // For tests.
  _batchChunkSize: 500,

  // Create a record starting from the weave id (places guid)
  async createRecord(id, collection) {
    let item = await lazy.PlacesSyncUtils.bookmarks.fetch(id);
    if (!item) {
      // deleted item
      let record = new PlacesItem(collection, id);
      record.deleted = true;
      return record;
    }

    let recordObj = getTypeObject(item.kind);
    if (!recordObj) {
      this._log.warn("Unknown item type, cannot serialize: " + item.kind);
      recordObj = PlacesItem;
    }
    let record = new recordObj(collection, id);
    record.fromSyncBookmark(item);

    record.sortindex = await this._calculateIndex(record);

    return record;
  },

  async _calculateIndex(record) {
    // Ensure folders have a very high sort index so they're not synced last.
    if (record.type == "folder") {
      return FOLDER_SORTINDEX;
    }

    // For anything directly under the toolbar, give it a boost of more than an
    // unvisited bookmark
    let index = 0;
    if (record.parentid == "toolbar") {
      index += 150;
    }

    // Add in the bookmark's frecency if we have something.
    if (record.bmkUri != null) {
      let frecency = FRECENCY_UNKNOWN;
      try {
        frecency = await lazy.PlacesSyncUtils.history.fetchURLFrecency(
          record.bmkUri
        );
      } catch (ex) {
        this._log.warn(
          `Failed to fetch frecency for ${record.id}; assuming default`,
          ex
        );
        this._log.trace("Record {id} has invalid URL ${bmkUri}", record);
      }
      if (frecency != FRECENCY_UNKNOWN) {
        index += frecency;
      }
    }

    return index;
  },

  async wipe() {
    // Save a backup before clearing out all bookmarks.
    await lazy.PlacesBackups.create(null, true);
    await lazy.PlacesSyncUtils.bookmarks.wipe();
  },

  ensureOpenMirror() {
    if (!this._openMirrorPromise) {
      this._openMirrorPromise = this._openMirror().catch(err => {
        // We may have failed to open the mirror temporarily; for example, if
        // the database is locked. Clear the promise so that subsequent
        // `ensureOpenMirror` calls can try to open the mirror again.
        this._openMirrorPromise = null;
        throw err;
      });
    }
    return this._openMirrorPromise;
  },

  async _openMirror() {
    let mirrorPath = PathUtils.join(
      PathUtils.profileDir,
      "weave",
      "bookmarks.sqlite"
    );
    await IOUtils.makeDirectory(PathUtils.parent(mirrorPath), {
      createAncestors: true,
    });

    return lazy.SyncedBookmarksMirror.open({
      path: mirrorPath,
      recordStepTelemetry: (name, took, counts) => {
        lazy.Observers.notify(
          "weave:engine:sync:step",
          {
            name,
            took,
            counts,
          },
          this.name
        );
      },
      recordValidationTelemetry: (took, checked, problems) => {
        lazy.Observers.notify(
          "weave:engine:validate:finish",
          {
            version: BOOKMARK_VALIDATOR_VERSION,
            took,
            checked,
            problems,
          },
          this.name
        );
      },
    });
  },

  async applyIncomingBatch(records) {
    let buf = await this.ensureOpenMirror();
    for (let chunk of lazy.PlacesUtils.chunkArray(
      records,
      this._batchChunkSize
    )) {
      await buf.store(chunk);
    }
    // Array of failed records.
    return [];
  },

  async applyIncoming(record) {
    let buf = await this.ensureOpenMirror();
    await buf.store([record]);
  },

  async finalize() {
    if (!this._openMirrorPromise) {
      return;
    }
    let buf = await this._openMirrorPromise;
    await buf.finalize();
  },
};

Object.setPrototypeOf(BookmarksStore.prototype, Store.prototype);

// The bookmarks tracker is a special flower. Instead of listening for changes
// via observer notifications, it queries Places for the set of items that have
// changed since the last sync. Because it's a "pull-based" tracker, it ignores
// all concepts of "add a changed ID." However, it still registers an observer
// to bump the score, so that changed bookmarks are synced immediately.
function BookmarksTracker(name, engine) {
  Tracker.call(this, name, engine);
}
BookmarksTracker.prototype = {
  onStart() {
    this._placesListener = new PlacesWeakCallbackWrapper(
      this.handlePlacesEvents.bind(this)
    );
    lazy.PlacesUtils.observers.addListener(
      [
        "bookmark-added",
        "bookmark-removed",
        "bookmark-moved",
        "bookmark-guid-changed",
        "bookmark-keyword-changed",
        "bookmark-tags-changed",
        "bookmark-time-changed",
        "bookmark-title-changed",
        "bookmark-url-changed",
      ],
      this._placesListener
    );
    Svc.Obs.add("bookmarks-restore-begin", this);
    Svc.Obs.add("bookmarks-restore-success", this);
    Svc.Obs.add("bookmarks-restore-failed", this);
  },

  onStop() {
    lazy.PlacesUtils.observers.removeListener(
      [
        "bookmark-added",
        "bookmark-removed",
        "bookmark-moved",
        "bookmark-guid-changed",
        "bookmark-keyword-changed",
        "bookmark-tags-changed",
        "bookmark-time-changed",
        "bookmark-title-changed",
        "bookmark-url-changed",
      ],
      this._placesListener
    );
    Svc.Obs.remove("bookmarks-restore-begin", this);
    Svc.Obs.remove("bookmarks-restore-success", this);
    Svc.Obs.remove("bookmarks-restore-failed", this);
  },

  async getChangedIDs() {
    return lazy.PlacesSyncUtils.bookmarks.pullChanges();
  },

  observe(subject, topic, data) {
    switch (topic) {
      case "bookmarks-restore-begin":
        this._log.debug("Ignoring changes from importing bookmarks.");
        break;
      case "bookmarks-restore-success":
        this._log.debug("Tracking all items on successful import.");

        if (data == "json") {
          this._log.debug(
            "Restore succeeded: wiping server and other clients."
          );
          // Trigger an immediate sync. `ensureCurrentSyncID` will notice we
          // restored, wipe the server and other clients, reset the sync ID, and
          // upload the restored tree.
          this.score += SCORE_INCREMENT_XLARGE;
        } else {
          // "html", "html-initial", or "json-append"
          this._log.debug("Import succeeded.");
        }
        break;
      case "bookmarks-restore-failed":
        this._log.debug("Tracking all items on failed import.");
        break;
    }
  },

  QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]),

  /* Every add/remove/change will trigger a sync for MULTI_DEVICE */
  _upScore: function BMT__upScore() {
    this.score += SCORE_INCREMENT_XLARGE;
  },

  handlePlacesEvents(events) {
    for (let event of events) {
      switch (event.type) {
        case "bookmark-added":
        case "bookmark-removed":
        case "bookmark-moved":
        case "bookmark-keyword-changed":
        case "bookmark-tags-changed":
        case "bookmark-time-changed":
        case "bookmark-title-changed":
        case "bookmark-url-changed":
          if (lazy.IGNORED_SOURCES.includes(event.source)) {
            continue;
          }

          this._log.trace(`'${event.type}': ${event.id}`);
          this._upScore();
          break;
        case "bookmark-guid-changed":
          if (event.source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC) {
            this._log.warn(
              "The source of bookmark-guid-changed event shoud be sync."
            );
            continue;
          }

          this._log.trace(`'${event.type}': ${event.id}`);
          this._upScore();
          break;
        case "purge-caches":
          this._log.trace("purge-caches");
          this._upScore();
          break;
      }
    }
  },
};

Object.setPrototypeOf(BookmarksTracker.prototype, Tracker.prototype);

/**
 * A changeset that stores extra metadata in a change record for each ID. The
 * engine updates this metadata when uploading Sync records, and writes it back
 * to Places in `BookmarksEngine#trackRemainingChanges`.
 *
 * The `synced` property on a change record means its corresponding item has
 * been uploaded, and we should pretend it doesn't exist in the changeset.
 */
class BookmarksChangeset extends Changeset {
  // Only `_reconcile` calls `getModifiedTimestamp` and `has`, and the engine
  // does its own reconciliation.
  getModifiedTimestamp() {
    throw new Error("Don't use timestamps to resolve bookmark conflicts");
  }

  has() {
    throw new Error("Don't use the changeset to resolve bookmark conflicts");
  }

  delete(id) {
    let change = this.changes[id];
    if (change) {
      // Mark the change as synced without removing it from the set. We do this
      // so that we can update Places in `trackRemainingChanges`.
      change.synced = true;
    }
  }

  ids() {
    let results = new Set();
    for (let id in this.changes) {
      if (!this.changes[id].synced) {
        results.add(id);
      }
    }
    return [...results];
  }
}
