/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  CLIENT_NOT_CONFIGURED: "resource://services-sync/constants.sys.mjs",
  BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
  CustomizableUI:
    "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
  MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
  OpenInTabsUtils:
    "moz-src:///browser/components/tabbrowser/OpenInTabsUtils.sys.mjs",
  PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  Weave: "resource://services-sync/main.sys.mjs",
  WebNavigationManager: "resource://gre/modules/WebNavigation.sys.mjs",
});

const ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD = 10;

// copied from utilityOverlay.js
const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab";

/**
 * Collects all information for a bookmark and performs editmethods
 */
class BookmarkState {
  /**
   * Construct a new BookmarkState.
   *
   * @param {object} options
   *   The constructor options.
   * @param {object} options.info
   *   Either a result node or a node-like object representing the item to be edited.
   * @param {string} [options.tags]
   *   Tags (if any) for the bookmark in a comma separated string. Empty tags are
   *   skipped
   * @param {string} [options.keyword]
   *   Existing (if there are any) keyword for bookmark
   * @param {boolean} [options.isFolder]
   *   If the item is a folder.
   * @param {Array<{ title: string; url: nsIURI; }>} [options.children]
   *   The list of child URIs to bookmark within the folder.
   * @param {boolean} [options.autosave]
   *   If changes to bookmark fields should be saved immediately after calling
   *   its respective "changed" method, rather than waiting for save() to be
   *   called.
   * @param {number} [options.index]
   *   The insertion point index of the bookmark.
   */
  constructor({
    info,
    tags = "",
    keyword = "",
    isFolder = false,
    children = [],
    autosave = false,
    index,
  }) {
    this._guid = info.itemGuid;
    this._postData = info.postData;
    this._isTagContainer = info.isTag;
    this._bulkTaggingUrls = info.uris?.map(uri => uri.spec);
    this._isFolder = isFolder;
    this._children = children;
    this._autosave = autosave;

    // Original Bookmark
    this._originalState = {
      title: this._isTagContainer ? info.tag : info.title,
      uri: info.uri?.spec,
      tags: tags
        .trim()
        .split(/\s*,\s*/)
        .filter(tag => !!tag.length),
      keyword,
      parentGuid: info.parentGuid,
      index,
    };

    // Edited bookmark
    this._newState = {};
  }

  /**
   * Save edited title for the bookmark
   *
   * @param {string} title
   *   The title of the bookmark
   */
  async _titleChanged(title) {
    this._newState.title = title;
    await this._maybeSave();
  }

  /**
   * Save edited location for the bookmark
   *
   * @param {string} location
   *   The location of the bookmark
   */
  async _locationChanged(location) {
    this._newState.uri = location;
    await this._maybeSave();
  }

  /**
   * Save edited tags for the bookmark
   *
   * @param {string} tags
   *    Comma separated list of tags
   */
  async _tagsChanged(tags) {
    this._newState.tags = tags;
    await this._maybeSave();
  }

  /**
   * Save edited keyword for the bookmark
   *
   * @param {string} keyword
   *   The keyword of the bookmark
   */
  async _keywordChanged(keyword) {
    this._newState.keyword = keyword;
    await this._maybeSave();
  }

  /**
   * Save edited parentGuid for the bookmark
   *
   * @param {string} parentGuid
   *   The parentGuid of the bookmark
   */
  async _parentGuidChanged(parentGuid) {
    this._newState.parentGuid = parentGuid;
    await this._maybeSave();
  }

  /**
   * Save changes if autosave is enabled.
   */
  async _maybeSave() {
    if (this._autosave) {
      await this.save();
    }
  }

  /**
   * Create a new bookmark.
   *
   * @returns {Promise<string>} The bookmark's GUID.
   */
  async _createBookmark() {
    let transactions = [
      lazy.PlacesTransactions.NewBookmark({
        parentGuid: this.parentGuid,
        tags: this._newState.tags,
        title: this._newState.title ?? this._originalState.title,
        url: this._newState.uri ?? this._originalState.uri,
        index: this._originalState.index,
      }),
    ];
    if (this._newState.keyword) {
      transactions.push(previousResults =>
        lazy.PlacesTransactions.EditKeyword({
          guid: previousResults[0],
          keyword: this._newState.keyword,
          postData: this._postData,
        })
      );
    }
    let results = await lazy.PlacesTransactions.batch(
      transactions,
      "BookmarkState::createBookmark"
    );
    this._guid = results?.[0];
    return this._guid;
  }

  /**
   * Create a new folder.
   *
   * @returns {Promise<string>} The folder's GUID.
   */
  async _createFolder() {
    let transactions = [
      lazy.PlacesTransactions.NewFolder({
        parentGuid: this.parentGuid,
        title: this._newState.title ?? this._originalState.title,
        children: this._children,
        index: this._originalState.index,
        tags: this._newState.tags,
      }),
    ];

    if (this._bulkTaggingUrls) {
      this._appendTagsTransactions({
        transactions,
        newTags: this._newState.tags,
        originalTags: this._originalState.tags,
        urls: this._bulkTaggingUrls,
      });
    }

    let results = await lazy.PlacesTransactions.batch(
      transactions,
      "BookmarkState::save::createFolder"
    );
    this._guid = results[0];
    return this._guid;
  }

  get parentGuid() {
    return this._newState.parentGuid ?? this._originalState.parentGuid;
  }

  /**
   * Save() API function for bookmark.
   *
   * @returns {Promise<string>} bookmark.guid
   */
  async save() {
    if (this._guid === lazy.PlacesUtils.bookmarks.unsavedGuid) {
      return this._isFolder ? this._createFolder() : this._createBookmark();
    }

    if (!Object.keys(this._newState).length) {
      return this._guid;
    }

    if (this._isTagContainer && this._newState.title) {
      await lazy.PlacesTransactions.RenameTag({
        oldTag: this._originalState.title,
        tag: this._newState.title,
      })
        .transact()
        .catch(console.error);
      return this._guid;
    }

    let url = this._newState.uri || this._originalState.uri;
    let transactions = [];

    if (this._newState.uri) {
      transactions.push(
        lazy.PlacesTransactions.EditUrl({
          guid: this._guid,
          url,
        })
      );
    }

    for (const [key, value] of Object.entries(this._newState)) {
      switch (key) {
        case "title":
          transactions.push(
            lazy.PlacesTransactions.EditTitle({
              guid: this._guid,
              title: value,
            })
          );
          break;
        case "tags": {
          this._appendTagsTransactions({
            transactions,
            newTags: value,
            originalTags: this._originalState.tags,
            urls: this._bulkTaggingUrls || [url],
          });
          break;
        }
        case "keyword":
          transactions.push(
            lazy.PlacesTransactions.EditKeyword({
              guid: this._guid,
              keyword: value,
              postData: this._postData,
              oldKeyword: this._originalState.keyword,
            })
          );
          break;
        case "parentGuid":
          transactions.push(
            lazy.PlacesTransactions.Move({
              guid: this._guid,
              newParentGuid: this._newState.parentGuid,
            })
          );
          break;
      }
    }
    if (transactions.length) {
      await lazy.PlacesTransactions.batch(transactions, "BookmarkState::save");
    }

    this._originalState = { ...this._originalState, ...this._newState };
    this._newState = {};
    return this._guid;
  }

  /**
   * Append transactions to update tags by given information.
   *
   * @param {object} parameters
   *   The parameters object containing:
   * @param {object[]} parameters.transactions
   *   Array that transactions will be appended to.
   * @param {string[]} parameters.newTags
   *   Tags that will be appended to the given urls.
   * @param {string[]} parameters.originalTags
   *   Tags that had been appended to the given urls.
   * @param {string[]} parameters.urls
   *   URLs that will be updated.
   */
  _appendTagsTransactions({
    transactions,
    newTags = [],
    originalTags = [],
    urls,
  }) {
    const addedTags = newTags.filter(tag => !originalTags.includes(tag));
    const removedTags = originalTags.filter(tag => !newTags.includes(tag));
    if (addedTags.length) {
      transactions.push(
        lazy.PlacesTransactions.Tag({
          urls,
          tags: addedTags,
        })
      );
    }
    if (removedTags.length) {
      transactions.push(
        lazy.PlacesTransactions.Untag({
          urls,
          tags: removedTags,
        })
      );
    }
  }
}

export var PlacesUIUtils = {
  BookmarkState,
  _bookmarkToolbarTelemetryListening: false,
  LAST_USED_FOLDERS_META_KEY: "bookmarks/lastusedfolders",

  lastContextMenuTriggerNode: null,

  lastContextMenuCommand: null,

  // This allows to await for all the relevant bookmark changes to be applied
  // when a bookmark dialog is closed. It is resolved to the bookmark guid,
  // if a bookmark was created or modified.
  lastBookmarkDialogDeferred: null,

  /**
   * Obfuscates a place: URL to use it in xulstore without the risk of
   leaking browsing information. Uses md5 to hash the query string.
   *
   * @param {string} url
   *        the URL for xulstore with place: key pairs.
   * @returns {string} "place:[md5_hash]" hashed url
   */

  obfuscateUrlForXulStore(url) {
    if (!url.startsWith("place:")) {
      throw new Error("Method must be used to only obfuscate place: uris!");
    }
    let urlNoProtocol = url.substring(url.indexOf(":") + 1);
    let hashedURL = lazy.PlacesUtils.md5(urlNoProtocol);

    return `place:${hashedURL}`;
  },

  /**
   * Shows the bookmark dialog corresponding to the specified info.
   *
   * @param {object} aInfo
   *        Describes the item to be edited/added in the dialog.
   *        See documentation at the top of bookmarkProperties.js
   * @param {Window} [aParentWindow]
   *        Owner window for the new dialog.
   *
   * @see documentation at the top of bookmarkProperties.js
   * @returns {Promise<string>} The guid of the item that was created or edited,
   *                   undefined otherwise.
   */
  async showBookmarkDialog(aInfo, aParentWindow = null) {
    this.lastBookmarkDialogDeferred = Promise.withResolvers();

    let dialogURL = "chrome://browser/content/places/bookmarkProperties.xhtml";
    let features = "centerscreen,chrome,modal,resizable=no";
    let bookmarkGuid;

    if (!aParentWindow) {
      aParentWindow = Services.wm.getMostRecentWindow(null);
    }

    if (aParentWindow.gDialogBox) {
      await aParentWindow.gDialogBox.open(dialogURL, aInfo);
    } else {
      aParentWindow.openDialog(dialogURL, "", features, aInfo);
    }

    if (aInfo.bookmarkState) {
      bookmarkGuid = await aInfo.bookmarkState.save();
      this.lastBookmarkDialogDeferred.resolve(bookmarkGuid);
      return bookmarkGuid;
    }
    bookmarkGuid = undefined;
    this.lastBookmarkDialogDeferred.resolve(bookmarkGuid);
    return bookmarkGuid;
  },

  /**
   * Bookmarks one or more pages. If there is more than one, this will create
   * the bookmarks in a new folder.
   *
   * @param {{uri: nsIURI, title: string}[]} URIList
   *   The list of URIs to bookmark.
   * @param {string[]} [hiddenRows]
   *   An array of rows to be hidden.
   * @param {Window} [win]
   *   The window to use as the parent to display the bookmark dialog.
   */
  async showBookmarkPagesDialog(URIList, hiddenRows = [], win = null) {
    if (!URIList.length) {
      return;
    }

    const bookmarkDialogInfo = { action: "add", hiddenRows };
    if (URIList.length > 1) {
      bookmarkDialogInfo.type = "folder";
      bookmarkDialogInfo.URIList = URIList;
    } else {
      bookmarkDialogInfo.type = "bookmark";
      bookmarkDialogInfo.title = URIList[0].title;
      bookmarkDialogInfo.uri = URIList[0].uri;
    }

    await PlacesUIUtils.showBookmarkDialog(bookmarkDialogInfo, win);
  },

  /**
   * Returns the closet ancestor places view for the given DOM node
   *
   * @param {DOMNode} aNode
   *        a DOM node
   * @returns {DOMNode} the closest ancestor places view if exists, null otherwsie.
   */
  getViewForNode: function PUIU_getViewForNode(aNode) {
    let node = aNode;

    if (Cu.isDeadWrapper(node)) {
      return null;
    }

    if (node.localName == "panelview" && node._placesView) {
      return node._placesView;
    }

    // The view for a <menu> of which its associated menupopup is a places
    // view, is the menupopup.
    if (
      node.localName == "menu" &&
      !node._placesNode &&
      node.menupopup._placesView
    ) {
      return node.menupopup._placesView;
    }

    while (Element.isInstance(node)) {
      if (node._placesView) {
        return node._placesView;
      }
      if (
        node.localName == "tree" &&
        node.getAttribute("is") == "places-tree"
      ) {
        return node;
      }

      node = node.parentNode;
    }

    return null;
  },

  /**
   * Returns the active PlacesController for a given command.
   *
   * @param {Window} win The window containing the affected view
   * @param {string} command The command
   * @returns {PlacesController} a places controller
   */
  getControllerForCommand(win, command) {
    // If we're building a context menu for a non-focusable view, for example
    // a menupopup, we must return the view that triggered the context menu.
    let popupNode = PlacesUIUtils.lastContextMenuTriggerNode;
    if (popupNode) {
      let isManaged = !!popupNode.closest("#managed-bookmarks");
      if (isManaged) {
        return this.managedBookmarksController;
      }
      let view = this.getViewForNode(popupNode);
      if (view && view._contextMenuShown) {
        return view.controllers.getControllerForCommand(command);
      }
    }

    // When we're not building a context menu, only focusable views
    // are possible.  Thus, we can safely use the command dispatcher.
    let controller =
      win.top.document.commandDispatcher.getControllerForCommand(command);
    return controller || null;
  },

  /**
   * Update all the Places commands for the given window.
   *
   * @param {Window} win The window to update.
   */
  updateCommands(win) {
    // Get the controller for one of the places commands.
    let controller = this.getControllerForCommand(win, "placesCmd_open");
    for (let command of [
      "placesCmd_open",
      "placesCmd_open:window",
      "placesCmd_open:privatewindow",
      "placesCmd_open:tab",
      "placesCmd_new:folder",
      "placesCmd_new:bookmark",
      "placesCmd_new:separator",
      "placesCmd_show:info",
      "placesCmd_reload",
      "placesCmd_sortBy:name",
      "placesCmd_cut",
      "placesCmd_copy",
      "placesCmd_paste",
      "placesCmd_delete",
      "placesCmd_showInFolder",
    ]) {
      win.goSetCommandEnabled(
        command,
        controller && controller.isCommandEnabled(command)
      );
    }
  },

  /**
   * Executes the given command on the currently active controller.
   *
   * @param {Window} win The window containing the affected view
   * @param {string} command The command to execute
   */
  doCommand(win, command) {
    let controller = this.getControllerForCommand(win, command);
    if (controller && controller.isCommandEnabled(command)) {
      PlacesUIUtils.lastContextMenuCommand = command;
      controller.doCommand(command);
    }
  },

  /**
   * By calling this before visiting an URL, the visit will be associated to a
   * TRANSITION_TYPED transition (if there is no a referrer).
   * This is used when visiting pages from the history menu, history sidebar,
   * url bar, url autocomplete results, and history searches from the places
   * organizer.  If this is not called visits will be marked as
   * TRANSITION_LINK.
   *
   * @param {string} aURL
   *   The URL to mark as typed.
   */
  markPageAsTyped: function PUIU_markPageAsTyped(aURL) {
    lazy.PlacesUtils.history.markPageAsTyped(
      Services.uriFixup.getFixupURIInfo(aURL).preferredURI
    );
  },

  /**
   * By calling this before visiting an URL, the visit will be associated to a
   * TRANSITION_BOOKMARK transition.
   * This is used when visiting pages from the bookmarks menu,
   * personal toolbar, and bookmarks from within the places organizer.
   * If this is not called visits will be marked as TRANSITION_LINK.
   *
   * @param {string} aURL
   *   The URL to mark as TRANSITION_BOOKMARK.
   */
  markPageAsFollowedBookmark: function PUIU_markPageAsFollowedBookmark(aURL) {
    lazy.PlacesUtils.history.markPageAsFollowedBookmark(
      Services.uriFixup.getFixupURIInfo(aURL).preferredURI
    );
  },

  /**
   * By calling this before visiting an URL, any visit in frames will be
   * associated to a TRANSITION_FRAMED_LINK transition.
   * This is actually used to distinguish user-initiated visits in frames
   * so automatic visits can be correctly ignored.
   *
   * @param {string} aURL
   *   The URL to mark as TRANSITION_FRAMED_LINK.
   */
  markPageAsFollowedLink: function PUIU_markPageAsFollowedLink(aURL) {
    lazy.PlacesUtils.history.markPageAsFollowedLink(
      Services.uriFixup.getFixupURIInfo(aURL).preferredURI
    );
  },

  /**
   * Sets the character-set for a page. The character set will not be saved
   * if the window is determined to be a private browsing window.
   *
   * @param {string|URL|nsIURI} url The URL of the page to set the charset on.
   * @param {string} charset character-set value.
   * @param {Window} window The window that the charset is being set from.
   * @returns {Promise}
   */
  async setCharsetForPage(url, charset, window) {
    if (lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
      return;
    }

    // UTF-8 is the default. If we are passed the value then set it to null,
    // to ensure any charset is removed from the database.
    if (charset.toLowerCase() == "utf-8") {
      charset = null;
    }

    await lazy.PlacesUtils.history.update({
      url,
      annotations: new Map([[lazy.PlacesUtils.CHARSET_ANNO, charset]]),
    });
  },

  /**
   * Allows opening of javascript/data URI only if the given node is
   * bookmarked (see bug 224521).
   *
   * @param {object} aURINode
   *        a URI node
   * @param {Window} aWindow
   *        a window on which a potential error alert is shown on.
   * @returns {boolean} true if it's safe to open the node in the browser, false otherwise.
   */
  checkURLSecurity(aURINode, aWindow) {
    if (lazy.PlacesUtils.nodeIsBookmark(aURINode)) {
      return true;
    }

    var uri = Services.io.newURI(aURINode.uri);
    if (uri.schemeIs("javascript") || uri.schemeIs("data")) {
      const [title, errorStr] =
        PlacesUIUtils.promptLocalization.formatValuesSync([
          "places-error-title",
          "places-load-js-data-url-error",
        ]);
      Services.prompt.alert(aWindow, title, errorStr);
      return false;
    }
    return true;
  },

  /**
   * Check whether or not the given node represents a removable entry (either in
   * history or in bookmarks).
   *
   * @param {object} aNode
   *        a node, except the root node of a query.
   * @returns {boolean} true if the aNode represents a removable entry, false otherwise.
   */
  canUserRemove(aNode) {
    let parentNode = aNode.parent;
    if (!parentNode) {
      // canUserRemove doesn't accept root nodes.
      return false;
    }

    // Is it a query pointing to one of the special root folders?
    if (lazy.PlacesUtils.nodeIsQuery(parentNode)) {
      if (lazy.PlacesUtils.nodeIsFolderOrShortcut(aNode)) {
        let guid = lazy.PlacesUtils.getConcreteItemGuid(aNode);
        // If the parent folder is not a folder, it must be a query, and so this node
        // cannot be removed.
        if (lazy.PlacesUtils.isRootItem(guid)) {
          return false;
        }
      } else if (lazy.PlacesUtils.isVirtualLeftPaneItem(aNode.bookmarkGuid)) {
        // If the item is a left-pane top-level item, it can't be removed.
        return false;
      }
    }

    // If it's not a bookmark, or it's child of a query, we can remove it.
    if (aNode.itemId == -1 || lazy.PlacesUtils.nodeIsQuery(parentNode)) {
      return true;
    }

    // Otherwise it has to be a child of an editable folder.
    return !this.isFolderReadOnly(parentNode);
  },

  /**
   * DO NOT USE THIS API IN ADDONS. IT IS VERY LIKELY TO CHANGE WHEN THE SWITCH
   * TO GUIDS IS COMPLETE (BUG 1071511).
   *
   * Check whether or not the given Places node points to a folder which
   * should not be modified by the user (i.e. its children should be unremovable
   * and unmovable, new children should be disallowed, etc).
   * These semantics are not inherited, meaning that read-only folder may
   * contain editable items (for instance, the places root is read-only, but all
   * of its direct children aren't).
   *
   * You should only pass folder nodes.
   *
   * @param {object} placesNode
   *        any folder result node.
   * @throws if placesNode is not a folder result node or views is invalid.
   * @returns {boolean} true if placesNode is a read-only folder, false otherwise.
   */
  isFolderReadOnly(placesNode) {
    if (
      typeof placesNode != "object" ||
      !lazy.PlacesUtils.nodeIsFolderOrShortcut(placesNode)
    ) {
      throw new Error("invalid value for placesNode");
    }

    return (
      lazy.PlacesUtils.getConcreteItemGuid(placesNode) ==
      lazy.PlacesUtils.bookmarks.rootGuid
    );
  },

  /**
   * @param {Array<object>} aItemsToOpen
   *   needs to be an array of objects of the form:
   *   {uri: string, isBookmark: boolean}
   * @param {object} aEvent
   *   The associated event triggering the open.
   * @param {Window} aWindow
   *   The window associated with the event.
   */
  openTabset(aItemsToOpen, aEvent, aWindow) {
    if (!aItemsToOpen.length) {
      return;
    }

    let browserWindow = getBrowserWindow(aWindow);
    var urls = [];
    let isPrivate =
      browserWindow && lazy.PrivateBrowsingUtils.isWindowPrivate(browserWindow);
    for (let item of aItemsToOpen) {
      urls.push(item.uri);
      if (isPrivate) {
        continue;
      }

      if (item.isBookmark) {
        this.markPageAsFollowedBookmark(item.uri);
      } else {
        this.markPageAsTyped(item.uri);
      }
    }

    // whereToOpenLink doesn't return "window" when there's no browser window
    // open (Bug 630255).
    var where = browserWindow
      ? lazy.BrowserUtils.whereToOpenLink(aEvent, false, true)
      : "window";
    if (where == "window") {
      // There is no browser window open, thus open a new one.
      let args = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
      let stringsToLoad = Cc["@mozilla.org/array;1"].createInstance(
        Ci.nsIMutableArray
      );
      urls.forEach(url =>
        stringsToLoad.appendElement(lazy.PlacesUtils.toISupportsString(url))
      );
      args.appendElement(stringsToLoad);

      let features = "chrome,dialog=no,all";
      if (isPrivate) {
        features += ",private";
      }

      browserWindow = Services.ww.openWindow(
        aWindow,
        AppConstants.BROWSER_CHROME_URL,
        null,
        features,
        args
      );
      return;
    }

    var loadInBackground = where == "tabshifted";
    // For consistency, we want all the bookmarks to open in new tabs, instead
    // of having one of them replace the currently focused tab.  Hence we call
    // loadTabs with aReplace set to false.
    let tabs = browserWindow.gBrowser.loadTabs(urls, {
      inBackground: loadInBackground,
      replace: false,
      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
    });

    for (const [i, tab] of tabs.entries()) {
      let item = aItemsToOpen[i];
      if (item.isBookmark && !item.uri.startsWith("javascript:")) {
        lazy.WebNavigationManager.setRecentTabTransitionData(
          { auto_bookmark: true },
          tab.linkedBrowser
        );
      }
    }
  },

  /**
   * Loads a selected node's or nodes' URLs in tabs,
   * warning the user when lots of URLs are being opened
   *
   * @param {object | Array} nodeOrNodes
   *          Contains the node or nodes that we're opening in tabs
   * @param {event} event
   *          The DOM mouse/key event with modifier keys set that track the
   *          user's preferred destination window or tab.
   * @param {object} view
   *          The current view that contains the node or nodes selected for
   *          opening
   */
  openMultipleLinksInTabs(nodeOrNodes, event, view) {
    let window = view.ownerWindow;
    let urlsToOpen = [];

    if (lazy.PlacesUtils.nodeIsContainer(nodeOrNodes)) {
      urlsToOpen = lazy.PlacesUtils.getURLsForContainerNode(nodeOrNodes);
    } else {
      for (var i = 0; i < nodeOrNodes.length; i++) {
        // Skip over separators and folders.
        if (lazy.PlacesUtils.nodeIsURI(nodeOrNodes[i])) {
          urlsToOpen.push({
            uri: nodeOrNodes[i].uri,
            isBookmark: lazy.PlacesUtils.nodeIsBookmark(nodeOrNodes[i]),
          });
        }
      }
    }
    if (lazy.OpenInTabsUtils.confirmOpenInTabs(urlsToOpen.length, window)) {
      if (window.updateTelemetry) {
        window.updateTelemetry(urlsToOpen);
      }
      this.openTabset(urlsToOpen, event, window);
    }
  },

  /**
   * Loads the node's URL in the appropriate tab or window given the
   * user's preference specified by modifier keys tracked by a
   * DOM mouse/key event.
   *
   * @param {object} aNode
   *          An uri result node.
   * @param {object} aEvent
   *          The DOM mouse/key event with modifier keys set that track the
   *          user's preferred destination window or tab.
   */
  openNodeWithEvent: function PUIU_openNodeWithEvent(aNode, aEvent) {
    let window = aEvent.target.ownerGlobal;

    let where = lazy.BrowserUtils.whereToOpenLink(aEvent, false, true);
    if (this.loadBookmarksInTabs && lazy.PlacesUtils.nodeIsBookmark(aNode)) {
      if (where == "current" && !aNode.uri.startsWith("javascript:")) {
        where = "tab";
      }
      let browserWindow = getBrowserWindow(window);
      if (where == "tab" && browserWindow?.gBrowser.selectedTab.isEmpty) {
        where = "current";
      }
    }

    this._openNodeIn(aNode, where, window);
  },

  /**
   * Loads the node's URL in the appropriate tab or window.
   * see also URILoadingHelper's openWebLinkIn
   *
   * @param {object} aNode
   *        An uri result node.
   * @param {string} aWhere
   *        Where to open the URL.
   * @param {object} aView
   *        The associated view of the node being opened.
   * @param {boolean} aPrivate
   *        True if the window being opened is private.
   */
  openNodeIn: function PUIU_openNodeIn(aNode, aWhere, aView, aPrivate) {
    let window = aView.ownerWindow;
    this._openNodeIn(aNode, aWhere, window, { aPrivate });
  },

  _openNodeIn: function PUIU__openNodeIn(
    aNode,
    aWhere,
    aWindow,
    { aPrivate = false, userContextId = 0 } = {}
  ) {
    if (
      aNode &&
      this.checkURLSecurity(aNode, aWindow) &&
      this.isURILike(aNode)
    ) {
      let isBookmark = lazy.PlacesUtils.nodeIsBookmark(aNode);

      if (!lazy.PrivateBrowsingUtils.isWindowPrivate(aWindow)) {
        if (isBookmark) {
          this.markPageAsFollowedBookmark(aNode.uri);
        } else {
          this.markPageAsTyped(aNode.uri);
        }
      } else {
        // This is a targeted fix for bug 1792163, where it was discovered
        // that if you open the Library from a Private Browsing window, and then
        // use the "Open in New Window" context menu item to open a new window,
        // that the window will open under the wrong icon on the Windows taskbar.
        aPrivate = true;
      }

      const isJavaScriptURL = aNode.uri.startsWith("javascript:");
      let resolveOnContentBrowserCreated;
      if (isBookmark && !isJavaScriptURL) {
        resolveOnContentBrowserCreated = browser => {
          lazy.WebNavigationManager.setRecentTabTransitionData(
            { auto_bookmark: true },
            browser
          );
        };
      }
      aWindow.openTrustedLinkIn(aNode.uri, aWhere, {
        allowPopups: isJavaScriptURL,
        inBackground: this.loadBookmarksInBackground,
        allowInheritPrincipal: isJavaScriptURL,
        private: aPrivate,
        userContextId,
        resolveOnContentBrowserCreated,
      });
      if (aWindow.updateTelemetry) {
        aWindow.updateTelemetry([aNode]);
      }
    }
  },

  /**
   * Determines whether a node represents a URI.
   *
   * @param {nsINavHistoryResultNode | HTMLElement} aNode
   *   A result node.
   * @returns {boolean}
   *   Whether the node represents a URI.
   */
  isURILike(aNode) {
    if (aNode instanceof Ci.nsINavHistoryResultNode) {
      return lazy.PlacesUtils.nodeIsURI(aNode);
    }
    return !!aNode.uri;
  },

  /**
   * Helper for guessing scheme from an url string.
   * Used to avoid nsIURI overhead in frequently called UI functions. This is not
   * supposed be perfect, so use it only for UI purposes.
   *
   * @param {string} href The url to guess the scheme from.
   * @returns {string} guessed scheme for this url string.
   */
  guessUrlSchemeForUI(href) {
    return href.substr(0, href.indexOf(":"));
  },

  getBestTitle: function PUIU_getBestTitle(aNode, aDoNotCutTitle) {
    var title;
    if (!aNode.title && lazy.PlacesUtils.nodeIsURI(aNode)) {
      // if node title is empty, try to set the label using host and filename
      // Services.io.newURI will throw if aNode.uri is not a valid URI
      try {
        var uri = Services.io.newURI(aNode.uri);
        var host = uri.host;
        var fileName = uri.QueryInterface(Ci.nsIURL).fileName;
        // if fileName is empty, use path to distinguish labels
        if (aDoNotCutTitle) {
          title = host + uri.pathQueryRef;
        } else {
          title =
            host +
            (fileName
              ? (host ? "/" + Services.locale.ellipsis + "/" : "") + fileName
              : uri.pathQueryRef);
        }
      } catch (e) {
        // Use (no title) for non-standard URIs (data:, javascript:, ...)
        title = "";
      }
    } else {
      title = aNode.title;
    }

    return title || this.promptLocalization.formatValueSync("places-no-title");
  },

  shouldShowTabsFromOtherComputersMenuitem() {
    let weaveOK =
      lazy.Weave.Status.checkSetup() != lazy.CLIENT_NOT_CONFIGURED &&
      lazy.Weave.Svc.PrefBranch.getCharPref("firstSync", "") != "notReady";
    return weaveOK;
  },

  /**
   * Helpers for consumers of editBookmarkOverlay which don't have a node as their input.
   *
   * Given a bookmark object for either a url bookmark or a folder, returned by
   * Bookmarks.fetch (see Bookmark.sys.mjs), this creates a node-like object
   * suitable for initialising the edit overlay with it.
   *
   * @param {object} aFetchInfo
   *        a bookmark object returned by Bookmarks.fetch.
   * @returns {Promise<{bookmarkGuid: string, title: string, uri: string, type: nsINavHistoryResultNode.ResultType}>}
   *   A node-like object suitable for initialising editBookmarkOverlay.
   * @throws if aFetchInfo is representing a separator.
   */
  async promiseNodeLikeFromFetchInfo(aFetchInfo) {
    if (aFetchInfo.itemType == lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR) {
      throw new Error("promiseNodeLike doesn't support separators");
    }

    let parent = {
      bookmarkGuid: aFetchInfo.parentGuid,
      type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
    };

    return Object.freeze({
      bookmarkGuid: aFetchInfo.guid,
      title: aFetchInfo.title,
      uri: aFetchInfo.url !== undefined ? aFetchInfo.url.href : "",

      get type() {
        if (aFetchInfo.itemType == lazy.PlacesUtils.bookmarks.TYPE_FOLDER) {
          return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER;
        }

        if (!this.uri.length) {
          throw new Error("Unexpected item type");
        }

        if (/^place:/.test(this.uri)) {
          throw new Error("Place URIs are not supported.");
        }

        return Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
      },

      get parent() {
        return parent;
      },
    });
  },

  /**
   * This function wraps potentially large places transaction operations
   * with batch notifications to the result node, hence switching the views
   * to batch mode. If resultNode is not supplied, the function will
   * pass-through to functionToWrap.
   *
   * @template T
   * @param {nsINavHistoryResult} resultNode The result node to turn on batching.
   * @param {number} itemsBeingChanged The count of items being changed. If the
   *                                    count is lower than a threshold, then
   *                                    batching won't be set.
   * @param {() => T} functionToWrap The function to
   * @returns {Promise<T>} forwards the functionToWrap return value.
   */
  async batchUpdatesForNode(resultNode, itemsBeingChanged, functionToWrap) {
    if (!resultNode) {
      return functionToWrap();
    }

    if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) {
      resultNode.onBeginUpdateBatch();
    }

    try {
      return await functionToWrap();
    } finally {
      if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) {
        resultNode.onEndUpdateBatch();
      }
    }
  },

  /**
   * Processes a set of transfer items that have been dropped or pasted.
   * Batching will be applied where necessary.
   *
   * @param {object[]} items
   *   A list of unwrapped nodes to process.
   * @param {object} insertionPoint
   *   The requested point for insertion.
   * @param {boolean} doCopy
   *   Set to true to copy the items, false will move them if possible.
   * @param {object} view
   *   The view that should be used for batching.
   * @returns {Promise<string[]>}
   *   Returns an empty array when the insertion point is a tag, else returns
   *   an array of copied or moved guids.
   */
  async handleTransferItems(items, insertionPoint, doCopy, view) {
    let transactions;
    let itemsCount;
    if (insertionPoint.isTag) {
      let urls = items.filter(item => "uri" in item).map(item => item.uri);
      itemsCount = urls.length;
      transactions = [
        lazy.PlacesTransactions.Tag({ urls, tag: insertionPoint.tagName }),
      ];
    } else {
      let insertionIndex = await insertionPoint.getIndex();
      itemsCount = items.length;
      transactions = getTransactionsForTransferItems(
        items,
        insertionIndex,
        insertionPoint.guid,
        !doCopy
      );
    }

    // Check if we actually have something to add, if we don't it probably wasn't
    // valid, or it was moving to the same location, so just ignore it.
    if (!transactions.length) {
      return [];
    }

    let guidsToSelect = await this.batchUpdatesForNode(
      getResultForBatching(view),
      itemsCount,
      async () =>
        lazy.PlacesTransactions.batch(transactions, "handleTransferItems")
    );

    // If we're inserting into a tag, we don't get the resulting guids.
    return insertionPoint.isTag ? [] : guidsToSelect.flat();
  },

  onSidebarTreeClick(event) {
    // right-clicks are not handled here
    if (event.button == 2) {
      return;
    }

    let tree = event.target.parentNode;
    let cell = tree.getCellAt(event.clientX, event.clientY);
    if (cell.row == -1 || cell.childElt == "twisty") {
      return;
    }

    // getCoordsForCellItem returns the x coordinate in logical coordinates
    // (i.e., starting from the left and right sides in LTR and RTL modes,
    // respectively.)  Therefore, we make sure to exclude the blank area
    // before the tree item icon (that is, to the left or right of it in
    // LTR and RTL modes, respectively) from the click target area.
    let win = tree.ownerGlobal;
    let rect = tree.getCoordsForCellItem(cell.row, cell.col, "image");
    let isRTL = win.getComputedStyle(tree).direction == "rtl";
    let mouseInGutter = isRTL ? event.clientX > rect.x : event.clientX < rect.x;

    let metaKey =
      AppConstants.platform === "macosx" ? event.metaKey : event.ctrlKey;
    let modifKey = metaKey || event.shiftKey;
    let isContainer = tree.view.isContainer(cell.row);
    let openInTabs =
      isContainer &&
      (event.button == 1 || (event.button == 0 && modifKey)) &&
      lazy.PlacesUtils.hasChildURIs(tree.view.nodeForTreeIndex(cell.row));

    if (event.button == 0 && isContainer && !openInTabs) {
      tree.view.toggleOpenState(cell.row);
    } else if (
      !mouseInGutter &&
      openInTabs &&
      event.originalTarget.localName == "treechildren"
    ) {
      tree.view.selection.select(cell.row);
      this.openMultipleLinksInTabs(tree.selectedNode, event, tree);
    } else if (
      !mouseInGutter &&
      !isContainer &&
      event.originalTarget.localName == "treechildren"
    ) {
      // Clear all other selection since we're loading a link now. We must
      // do this *before* attempting to load the link since openURL uses
      // selection as an indication of which link to load.
      tree.view.selection.select(cell.row);
      this.openNodeWithEvent(tree.selectedNode, event);
    }
  },

  onSidebarTreeKeyPress(event) {
    let node = event.target.selectedNode;
    if (node) {
      if (event.keyCode == event.DOM_VK_RETURN) {
        PlacesUIUtils.openNodeWithEvent(node, event);
      }
    }
  },

  /**
   * The following function displays the URL of a node that is being
   * hovered over.
   *
   * @param {object} event
   *   The event that triggered the hover.
   */
  onSidebarTreeMouseMove(event) {
    let treechildren = event.target;
    if (treechildren.localName != "treechildren") {
      return;
    }

    let tree = treechildren.parentNode;
    let cell = tree.getCellAt(event.clientX, event.clientY);

    // cell.row is -1 when the mouse is hovering an empty area within the tree.
    // To avoid showing a URL from a previously hovered node for a currently
    // hovered non-url node, we must clear the moused-over URL in these cases.
    if (cell.row != -1) {
      let node = tree.view.nodeForTreeIndex(cell.row);
      if (lazy.PlacesUtils.nodeIsURI(node)) {
        this.setMouseoverURL(node.uri, tree.ownerGlobal);
        return;
      }
    }
    this.setMouseoverURL("", tree.ownerGlobal);
  },

  setMouseoverURL(url, win) {
    // When the browser window is closed with an open sidebar, the sidebar
    // unload event happens after the browser's one.  In this case
    // top.XULBrowserWindow has been nullified already.
    if (win.top.XULBrowserWindow) {
      win.top.XULBrowserWindow.setOverLink(url);
    }
  },

  /**
   * Uncollapses PersonalToolbar if its collapsed status is not
   * persisted, and user customized it or changed default bookmarks.
   *
   * If the user does not have a persisted value for the toolbar's
   * "collapsed" attribute, try to determine whether it's customized.
   *
   * @param {boolean} aForceVisible Set to true to ignore if the user had
   * previously collapsed the toolbar manually.
   */
  NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE: 3,
  async maybeToggleBookmarkToolbarVisibility(aForceVisible = false) {
    const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL;
    let xulStore = Services.xulStore;

    if (
      aForceVisible ||
      !xulStore.hasValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed")
    ) {
      function uncollapseToolbar() {
        Services.obs.notifyObservers(
          null,
          "browser-set-toolbar-visibility",
          JSON.stringify([lazy.CustomizableUI.AREA_BOOKMARKS, "true"])
        );
      }
      // We consider the toolbar customized if it has more than
      // NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE children, or if it has a persisted
      // currentset value.
      let toolbarIsCustomized = xulStore.hasValue(
        BROWSER_DOCURL,
        "PersonalToolbar",
        "currentset"
      );
      if (aForceVisible || toolbarIsCustomized) {
        uncollapseToolbar();
        return;
      }

      let numBookmarksOnToolbar = (
        await lazy.PlacesUtils.bookmarks.fetch(
          lazy.PlacesUtils.bookmarks.toolbarGuid
        )
      ).childCount;
      if (numBookmarksOnToolbar > this.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE) {
        uncollapseToolbar();
      }
    }
  },

  /**
   * Determines whether the given "placesContext" menu item would open a link
   * under some special conditions, but those special conditions cannot be met.
   *
   * @param {Element} item The menu or menu item to decide for.
   *
   * @returns {boolean} Whether the item is an "open" item that should be
   *   hidden.
   */
  shouldHideOpenMenuItem(item) {
    if (
      item.hasAttribute("hide-if-disabled-private-browsing") &&
      !lazy.PrivateBrowsingUtils.enabled
    ) {
      return true;
    }

    if (
      item.hasAttribute("hide-if-private-browsing") &&
      lazy.PrivateBrowsingUtils.isWindowPrivate(item.ownerGlobal)
    ) {
      return true;
    }

    if (
      item.hasAttribute("hide-if-usercontext-disabled") &&
      !Services.prefs.getBoolPref("privacy.userContext.enabled", false)
    ) {
      return true;
    }

    return false;
  },

  async managedPlacesContextShowing(event) {
    let menupopup = event.target;
    let document = menupopup.ownerDocument;
    let window = menupopup.ownerGlobal;
    // We need to populate the submenus in order to have information
    // to show the context menu.
    if (
      menupopup.triggerNode.id == "managed-bookmarks" &&
      !menupopup.triggerNode.menupopup.hasAttribute("hasbeenopened")
    ) {
      await window.PlacesToolbarHelper.populateManagedBookmarks(
        menupopup.triggerNode.menupopup
      );
    }
    // Hide everything. We'll unhide the things we need.
    Array.from(menupopup.children).forEach(function (child) {
      child.hidden = true;
    });
    // Store triggerNode in controller for checking if commands are enabled
    this.managedBookmarksController.triggerNode = menupopup.triggerNode;
    // Container in this context means a folder.
    let isFolder = menupopup.triggerNode.hasAttribute("container");
    if (isFolder) {
      // Disable the openContainerInTabs menuitem if there
      // are no children of the menu that have links.
      let openContainerInTabs_menuitem = document.getElementById(
        "placesContext_openContainer:tabs"
      );
      let menuitems = menupopup.triggerNode.menupopup.children;
      let openContainerInTabs = Array.from(menuitems).some(
        menuitem => menuitem.link
      );
      openContainerInTabs_menuitem.disabled = !openContainerInTabs;
      openContainerInTabs_menuitem.hidden = false;
    } else {
      for (let id of [
        "placesContext_open:newtab",
        "placesContext_open:newcontainertab",
        "placesContext_open:newwindow",
        "placesContext_open:newprivatewindow",
      ]) {
        let item = document.getElementById(id);
        item.hidden = this.shouldHideOpenMenuItem(item);
      }
      for (let id of ["placesContext_openSeparator", "placesContext_copy"]) {
        document.getElementById(id).hidden = false;
      }
    }

    event.target.ownerGlobal.updateCommands("places");
  },

  placesContextShowing(event) {
    let menupopup = /** @type {XULPopupElement} */ (event.target);
    if (
      !["placesContext", "sidebar-history-context-menu"].includes(menupopup.id)
    ) {
      // Ignore any popupshowing events from submenus
      return;
    }

    if (menupopup.id == "sidebar-history-context-menu") {
      PlacesUIUtils.lastContextMenuTriggerNode =
        menupopup.triggerNode.triggerNode;
      return;
    }

    PlacesUIUtils.lastContextMenuTriggerNode = menupopup.triggerNode;

    if (Services.prefs.getBoolPref("browser.tabs.loadBookmarksInTabs", false)) {
      menupopup.ownerDocument
        .getElementById("placesContext_open")
        .removeAttribute("default");
      menupopup.ownerDocument
        .getElementById("placesContext_open:newtab")
        .setAttribute("default", "true");
      // else clause ensures correct behavior if pref is repeatedly toggled
    } else {
      menupopup.ownerDocument
        .getElementById("placesContext_open:newtab")
        .removeAttribute("default");
      menupopup.ownerDocument
        .getElementById("placesContext_open")
        .setAttribute("default", "true");
    }

    let isManaged = !!menupopup.triggerNode.closest("#managed-bookmarks");
    if (isManaged) {
      this.managedPlacesContextShowing(event);
      return;
    }
    menupopup._view = this.getViewForNode(menupopup.triggerNode);
    if (!menupopup._view) {
      // This can happen if we try to invoke the context menu on
      // an uninitialized places toolbar. Just bail out:
      event.preventDefault();
      return;
    }
    if (!this.openInTabClosesMenu) {
      menupopup.ownerDocument
        .getElementById("placesContext_open:newtab")
        .setAttribute("closemenu", "single");
    }
    if (!menupopup._view.buildContextMenu(menupopup)) {
      event.preventDefault();
    }
  },

  placesContextHiding(event) {
    let menupopup = event.target;
    if (menupopup._view) {
      menupopup._view.destroyContextMenu();
    }

    if (
      [
        "sidebar-history-context-menu",
        "placesContext",
        "sidebar-synced-tabs-context-menu",
      ].includes(menupopup.id)
    ) {
      PlacesUIUtils.lastContextMenuTriggerNode = null;
      PlacesUIUtils.lastContextMenuCommand = null;
    }
  },

  createContainerTabMenu(event) {
    let window = event.target.ownerGlobal;
    return window.createUserContextMenu(event, { isContextMenu: true });
  },

  openInContainerTab(event) {
    PlacesUIUtils.lastContextMenuCommand = "placesCmd_open:newcontainertab";
    let userContextId = parseInt(
      event.target.getAttribute("data-usercontextid")
    );
    let triggerNode = this.lastContextMenuTriggerNode;
    let isManaged = !!triggerNode?.closest("#managed-bookmarks");
    if (isManaged) {
      let window = triggerNode.ownerGlobal;
      window.openTrustedLinkIn(triggerNode.link, "tab", { userContextId });
      return;
    }
    let view = this.getViewForNode(triggerNode);
    this._openNodeIn(
      view?.selectedNode || triggerNode,
      "tab",
      view?.ownerWindow || triggerNode.ownerGlobal.top,
      {
        userContextId,
      }
    );
  },

  openSelectionInTabs(event) {
    let isManaged =
      !!event.target.parentNode.triggerNode.closest("#managed-bookmarks");
    let controller;
    if (isManaged) {
      controller = this.managedBookmarksController;
    } else {
      controller = PlacesUIUtils.getViewForNode(
        PlacesUIUtils.lastContextMenuTriggerNode
      ).controller;
    }
    controller.openSelectionInTabs(event);
  },

  managedBookmarksController: {
    triggerNode: null,

    openSelectionInTabs(event) {
      let window = event.target.ownerGlobal;
      let menuitems = event.target.parentNode.triggerNode.menupopup.children;
      let items = [];
      for (let i = 0; i < menuitems.length; i++) {
        if (menuitems[i].link) {
          let item = {};
          item.uri = menuitems[i].link;
          item.isBookmark = true;
          items.push(item);
        }
      }
      PlacesUIUtils.openTabset(items, event, window);
    },

    isCommandEnabled(command) {
      switch (command) {
        case "placesCmd_copy":
        case "placesCmd_open:window":
        case "placesCmd_open:privatewindow":
        case "placesCmd_open:tab": {
          return true;
        }
      }
      return false;
    },

    doCommand(command) {
      let window = this.triggerNode.ownerGlobal;
      switch (command) {
        case "placesCmd_copy": {
          lazy.BrowserUtils.copyLink(
            this.triggerNode.link,
            this.triggerNode.label
          );
          break;
        }
        case "placesCmd_open:privatewindow":
          window.openTrustedLinkIn(this.triggerNode.link, "window", {
            private: true,
          });
          break;
        case "placesCmd_open:window":
          window.openTrustedLinkIn(this.triggerNode.link, "window", {
            private: false,
          });
          break;
        case "placesCmd_open:tab": {
          window.openTrustedLinkIn(this.triggerNode.link, "tab");
        }
      }
    },
  },

  async maybeAddImportButton() {
    if (!Services.policies.isAllowed("profileImport")) {
      return;
    }

    let numberOfBookmarks = await lazy.PlacesUtils.withConnectionWrapper(
      "PlacesUIUtils: maybeAddImportButton",
      async db => {
        let rows = await db.execute(
          `SELECT COUNT(*) as n FROM moz_bookmarks b
           JOIN moz_bookmarks p ON p.id = b.parent
           WHERE p.guid = :guid`,
          { guid: lazy.PlacesUtils.bookmarks.toolbarGuid }
        );
        return rows[0].getResultByName("n");
      }
    ).catch(e => {
      // We want to report errors, but we still want to add the button then:
      console.error(e);
      return 0;
    });

    if (numberOfBookmarks < 3) {
      lazy.CustomizableUI.addWidgetToArea(
        "import-button",
        lazy.CustomizableUI.AREA_BOOKMARKS,
        0
      );
      Services.prefs.setBoolPref("browser.bookmarks.addedImportButton", true);
      this.removeImportButtonWhenImportSucceeds();
    }
  },

  removeImportButtonWhenImportSucceeds() {
    // If the user (re)moved the button, clear the pref and stop worrying about
    // moving the item.
    let placement = lazy.CustomizableUI.getPlacementOfWidget("import-button");
    if (placement?.area != lazy.CustomizableUI.AREA_BOOKMARKS) {
      Services.prefs.clearUserPref("browser.bookmarks.addedImportButton");
      return;
    }
    // Otherwise, wait for a successful migration:
    let obs = (subject, topic, data) => {
      if (
        data == lazy.MigrationUtils.resourceTypes.BOOKMARKS &&
        lazy.MigrationUtils.getImportedCount("bookmarks") > 0
      ) {
        lazy.CustomizableUI.removeWidgetFromArea("import-button");
        Services.prefs.clearUserPref("browser.bookmarks.addedImportButton");
        Services.obs.removeObserver(obs, "Migration:ItemAfterMigrate");
        Services.obs.removeObserver(obs, "Migration:ItemError");
      }
    };
    Services.obs.addObserver(obs, "Migration:ItemAfterMigrate");
    Services.obs.addObserver(obs, "Migration:ItemError");
  },

  /**
   * Tries to initiate a speculative connection to a given url. This is not
   * infallible, if a speculative connection cannot be initialized, it will be a
   * no-op.
   *
   * @param {string} url entity to initiate
   *        a speculative connection for.
   * @param {Window} window the window from where the connection is initialized.
   */
  setupSpeculativeConnection(url, window) {
    if (
      !Services.prefs.getBoolPref(
        "browser.places.speculativeConnect.enabled",
        true
      )
    ) {
      return;
    }
    if (!url.startsWith("http")) {
      return;
    }
    try {
      let uri = Services.io.newURI(url);
      Services.io.speculativeConnect(
        uri,
        window.gBrowser.contentPrincipal,
        null,
        false
      );
    } catch (ex) {
      // Can't setup speculative connection for this url, just ignore it.
    }
  },

  /**
   * Sets up a speculative connection to the target of a
   * clicked places DOM node on left and middle click.
   *
   * @param {MouseEvent} event the mousedown event.
   */
  maybeSpeculativeConnectOnMouseDown(event) {
    if (
      event.type == "mousedown" &&
      event.target._placesNode?.uri &&
      event.button != 2
    ) {
      PlacesUIUtils.setupSpeculativeConnection(
        event.target._placesNode.uri,
        event.target.ownerGlobal
      );
    }
  },

  /**
   * Generates a cached-favicon: link for an icon URL, that will allow to fetch
   * the icon from the local favicons cache, rather than from the network.
   * If the icon URL is invalid, fallbacks to the default favicon URL.
   *
   * @param {string} icon The url of the icon to load from local cache.
   * @returns {string} a "cached-favicon:" prefixed URL, unless the original
   *   URL protocol refers to a local resource, then it will just pass-through
   *   unchanged.
   */
  getImageURL(icon) {
    // don't initiate a connection just to fetch a favicon (see bug 467828)
    try {
      return lazy.PlacesUtils.favicons.getFaviconLinkForIcon(
        Services.io.newURI(icon)
      ).spec;
    } catch (ex) {}
    return lazy.PlacesUtils.favicons.defaultFavicon.spec;
  },

  /**
   * Determines the string indexes where titles differ from similar titles (where
   * the first n characters are the same) in the provided list of items, and
   * adds that into the item.
   *
   * This assumes the titles will be displayed along the lines of
   * `Start of title ... place where differs` the index would be reference
   * the `p` here.
   *
   * @param {object[]} candidates
   *   An array of candidates to modify. The candidates should have a `title`
   *   property which should be a string or null.
   *   The order of the array does not matter. The objects are modified
   *   in-place.
   *   If a difference to other similar titles is found then a
   *   `titleDifferentIndex` property will be inserted into all similar
   *   candidates with the index of the start of the difference.
   */
  insertTitleStartDiffs(candidates) {
    function findStartDifference(a, b) {
      let i;
      // We already know the start is the same, so skip that part.
      for (i = PlacesUIUtils.similarTitlesMinChars; i < a.length; i++) {
        if (a[i] != b[i]) {
          return i;
        }
      }
      if (b.length > i) {
        return i;
      }
      // They are the same.
      return -1;
    }

    let longTitles = new Map();

    for (let candidate of candidates) {
      // Title is too short for us to care about, simply continue.
      if (
        !candidate.title ||
        candidate.title.length < this.similarTitlesMinChars
      ) {
        continue;
      }
      let titleBeginning = candidate.title.slice(0, this.similarTitlesMinChars);
      let matches = longTitles.get(titleBeginning);
      if (matches) {
        for (let match of matches) {
          let startDiff = findStartDifference(candidate.title, match.title);
          if (startDiff > 0) {
            candidate.titleDifferentIndex = startDiff;
            // If we have an existing difference index for the match, move
            // it forward if this one is earlier in the string.
            if (
              !("titleDifferentIndex" in match) ||
              match.titleDifferentIndex > startDiff
            ) {
              match.titleDifferentIndex = startDiff;
            }
          }
        }

        matches.push(candidate);
      } else {
        longTitles.set(titleBeginning, [candidate]);
      }
    }
  },
};

/**
 * Promise used by the toolbar view browser-places to determine whether we
 * can start loading its content (which involves IO, and so is postponed
 * during startup).
 */
PlacesUIUtils.canLoadToolbarContentPromise = new Promise(resolve => {
  PlacesUIUtils.unblockToolbars = resolve;
});

// These are lazy getters to avoid importing PlacesUtils immediately.
ChromeUtils.defineLazyGetter(PlacesUIUtils, "PLACES_FLAVORS", () => {
  return [
    lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER,
    lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR,
    lazy.PlacesUtils.TYPE_X_MOZ_PLACE,
  ];
});
ChromeUtils.defineLazyGetter(PlacesUIUtils, "URI_FLAVORS", () => {
  return [
    lazy.PlacesUtils.TYPE_X_MOZ_URL,
    TAB_DROP_TYPE,
    lazy.PlacesUtils.TYPE_PLAINTEXT,
  ];
});
ChromeUtils.defineLazyGetter(PlacesUIUtils, "SUPPORTED_FLAVORS", () => {
  return [...PlacesUIUtils.PLACES_FLAVORS, ...PlacesUIUtils.URI_FLAVORS];
});

ChromeUtils.defineLazyGetter(PlacesUIUtils, "promptLocalization", () => {
  return new Localization(
    ["browser/placesPrompts.ftl", "branding/brand.ftl"],
    true
  );
});

XPCOMUtils.defineLazyPreferenceGetter(
  PlacesUIUtils,
  "similarTitlesMinChars",
  "browser.places.similarTitlesMinChars",
  20
);
XPCOMUtils.defineLazyPreferenceGetter(
  PlacesUIUtils,
  "loadBookmarksInBackground",
  "browser.tabs.loadBookmarksInBackground",
  false
);
XPCOMUtils.defineLazyPreferenceGetter(
  PlacesUIUtils,
  "loadBookmarksInTabs",
  "browser.tabs.loadBookmarksInTabs",
  false
);
XPCOMUtils.defineLazyPreferenceGetter(
  PlacesUIUtils,
  "openInTabClosesMenu",
  "browser.bookmarks.openInTabClosesMenu",
  false
);
XPCOMUtils.defineLazyPreferenceGetter(
  PlacesUIUtils,
  "maxRecentFolders",
  "browser.bookmarks.editDialog.maxRecentFolders",
  7
);

XPCOMUtils.defineLazyPreferenceGetter(
  PlacesUIUtils,
  "defaultParentGuid",
  "browser.bookmarks.defaultLocation",
  "", // Avoid eagerly loading PlacesUtils.
  null,
  async prefValue => {
    if (!prefValue) {
      return lazy.PlacesUtils.bookmarks.toolbarGuid;
    }
    if (["toolbar", "menu", "unfiled"].includes(prefValue)) {
      return lazy.PlacesUtils.bookmarks[prefValue + "Guid"];
    }

    try {
      return await lazy.PlacesUtils.bookmarks
        .fetch({ guid: prefValue })
        .then(bm => bm.guid);
    } catch (ex) {
      // The guid may have an invalid format.
      return lazy.PlacesUtils.bookmarks.toolbarGuid;
    }
  }
);

/**
 * Determines if an unwrapped node can be moved.
 *
 * @param {object} unwrappedNode
 *        A node unwrapped by PlacesUtils.unwrapNodes().
 * @returns {boolean} True if the node can be moved, false otherwise.
 */
function canMoveUnwrappedNode(unwrappedNode) {
  if (
    (unwrappedNode.concreteGuid &&
      lazy.PlacesUtils.isRootItem(unwrappedNode.concreteGuid)) ||
    (unwrappedNode.guid && lazy.PlacesUtils.isRootItem(unwrappedNode.guid))
  ) {
    return false;
  }

  let parentGuid = unwrappedNode.parentGuid;
  if (parentGuid == lazy.PlacesUtils.bookmarks.rootGuid) {
    return false;
  }

  return true;
}

/**
 * This gets the most appropriate item for using for batching. In the case of multiple
 * views being related, the method returns the most expensive result to batch.
 * For example, if it detects the left-hand library pane, then it will look for
 * and return the reference to the right-hand pane.
 *
 * @param {object} viewOrElement The item to check.
 * @returns {object} Will return the best result node to batch, or null
 *                  if one could not be found.
 */
function getResultForBatching(viewOrElement) {
  if (
    viewOrElement &&
    Element.isInstance(viewOrElement) &&
    viewOrElement.id === "placesList"
  ) {
    // Note: fall back to the existing item if we can't find the right-hane pane.
    viewOrElement =
      viewOrElement.ownerDocument.getElementById("placeContent") ||
      viewOrElement;
  }

  if (viewOrElement && viewOrElement.result) {
    return viewOrElement.result;
  }

  return null;
}

/**
 * Processes a set of transfer items and returns transactions to insert or
 * move them.
 *
 * @param {Array} items A list of unwrapped nodes to get transactions for.
 * @param {number} insertionIndex The requested index for insertion.
 * @param {string} insertionParentGuid The guid of the parent folder to insert
 *                                     or move the items to.
 * @param {boolean} doMove Set to true to MOVE the items if possible, false will
 *                         copy them.
 * @returns {Array} Returns an array of created PlacesTransactions.
 */
function getTransactionsForTransferItems(
  items,
  insertionIndex,
  insertionParentGuid,
  doMove
) {
  let canMove = true;
  for (let item of items) {
    if (!PlacesUIUtils.SUPPORTED_FLAVORS.includes(item.type)) {
      throw new Error(`Unsupported '${item.type}' data type`);
    }

    // Work out if this is data from the same app session we're running in.
    if (
      !("instanceId" in item) ||
      item.instanceId != lazy.PlacesUtils.instanceId
    ) {
      if (item.type == lazy.PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) {
        throw new Error(
          "Can't copy a container from a legacy-transactions build"
        );
      }
      // Only log if this is one of "our" types as external items, e.g. drag from
      // url bar to toolbar, shouldn't complain.
      if (PlacesUIUtils.PLACES_FLAVORS.includes(item.type)) {
        console.error(
          "Tried to move an unmovable Places " +
            "node, reverting to a copy operation."
        );
      }

      // We can never move from an external copy.
      canMove = false;
    }

    if (doMove && canMove) {
      canMove = canMoveUnwrappedNode(item);
    }
  }

  if (doMove && !canMove) {
    doMove = false;
  }

  if (doMove) {
    // Move is simple, we pass the transaction a list of GUIDs and where to move
    // them to.
    return [
      lazy.PlacesTransactions.Move({
        guids: items.map(item => item.itemGuid),
        newParentGuid: insertionParentGuid,
        newIndex: insertionIndex,
      }),
    ];
  }

  return getTransactionsForCopy(items, insertionIndex, insertionParentGuid);
}

/**
 * Processes a set of transfer items and returns an array of transactions.
 *
 * @param {Array} items A list of unwrapped nodes to get transactions for.
 * @param {number} insertionIndex The requested index for insertion.
 * @param {string} insertionParentGuid The guid of the parent folder to insert
 *                                     or move the items to.
 * @returns {Array} Returns an array of created PlacesTransactions.
 */
function getTransactionsForCopy(items, insertionIndex, insertionParentGuid) {
  let transactions = [];
  let index = insertionIndex;

  for (let item of items) {
    let transaction;
    let guid = item.itemGuid;

    if (
      PlacesUIUtils.PLACES_FLAVORS.includes(item.type) &&
      // For anything that is comming from within this session, we do a
      // direct copy, otherwise we fallback and form a new item below.
      "instanceId" in item &&
      item.instanceId == lazy.PlacesUtils.instanceId &&
      // If the Item doesn't have a guid, this could be a virtual tag query or
      // other item, so fallback to inserting a new bookmark with the URI.
      guid &&
      // For virtual root items, we fallback to creating a new bookmark, as
      // we want a shortcut to be created, not a full tree copy.
      !lazy.PlacesUtils.bookmarks.isVirtualRootItem(guid) &&
      !lazy.PlacesUtils.isVirtualLeftPaneItem(guid)
    ) {
      transaction = lazy.PlacesTransactions.Copy({
        guid,
        newIndex: index,
        newParentGuid: insertionParentGuid,
      });
    } else if (item.type == lazy.PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) {
      transaction = lazy.PlacesTransactions.NewSeparator({
        index,
        parentGuid: insertionParentGuid,
      });
    } else {
      let title =
        item.type != lazy.PlacesUtils.TYPE_PLAINTEXT ? item.title : item.uri;
      transaction = lazy.PlacesTransactions.NewBookmark({
        index,
        parentGuid: insertionParentGuid,
        title,
        url: item.uri,
      });
    }

    transactions.push(transaction);

    if (index != -1) {
      index++;
    }
  }
  return transactions;
}

function getBrowserWindow(aWindow) {
  // Prefer the caller window if it's a browser window, otherwise use
  // the top browser window.
  return aWindow &&
    aWindow.document.documentElement.getAttribute("windowtype") ==
      "navigator:browser"
    ? aWindow
    : lazy.BrowserWindowTracker.getTopWindow();
}
