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

/* global MozXULElement */

// This is defined in browser.js and only used in the star UI.
/* global setToolbarVisibility */

/* import-globals-from controller.js */

var { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);

ChromeUtils.defineESModuleGetters(this, {
  CustomizableUI:
    "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
  PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
  PlacesUIUtils: "moz-src:///browser/components/places/PlacesUIUtils.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
});

var gEditItemOverlay = {
  // Array of PlacesTransactions accumulated by internal changes. It can be used
  // to wait for completion.
  transactionPromises: null,
  _staticFoldersListBuilt: false,
  _didChangeFolder: false,
  // Tracks bookmark properties changes in the dialog, allowing external consumers
  // to either confirm or discard them.
  _bookmarkState: null,
  _allTags: null,
  _initPanelDeferred: null,
  _updateTagsDeferred: null,
  _paneInfo: null,
  _setPaneInfo(aInitInfo) {
    if (!aInitInfo) {
      return (this._paneInfo = null);
    }

    if ("uris" in aInitInfo && "node" in aInitInfo) {
      throw new Error("ambiguous pane info");
    }
    if (!("uris" in aInitInfo) && !("node" in aInitInfo)) {
      throw new Error("Neither node nor uris set for pane info");
    }

    // We either pass a node or uris.
    let node = "node" in aInitInfo ? aInitInfo.node : null;

    // Since there's no true UI for folder shortcuts (they show up just as their target
    // folders), when the pane shows for them it's opened in read-only mode, showing the
    // properties of the target folder.
    let itemGuid = node ? PlacesUtils.getConcreteItemGuid(node) : null;
    let isItem = !!itemGuid;
    let isFolderShortcut =
      isItem &&
      node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT;
    let isTag = node && PlacesUtils.nodeIsTagQuery(node);
    let tag = null;
    if (isTag) {
      tag =
        PlacesUtils.asQuery(node).query.tags.length == 1
          ? node.query.tags[0]
          : node.title;
    }

    let isURI = node && PlacesUtils.nodeIsURI(node);
    let uri = isURI || isTag ? Services.io.newURI(node.uri) : null;
    let title = node ? node.title : null;
    let isBookmark = isItem && isURI;

    let addedMultipleBookmarks = aInitInfo.addedMultipleBookmarks;
    let bulkTagging = false;
    let uris = null;
    if (!node) {
      bulkTagging = true;
      uris = aInitInfo.uris;
    } else if (addedMultipleBookmarks) {
      bulkTagging = true;
      uris = node.children.map(c => c.url);
    }

    let visibleRows = new Set();
    let isParentReadOnly = false;
    let postData = aInitInfo.postData;
    let parentGuid = null;

    if (node && isItem) {
      if (!node.parent) {
        throw new Error(
          "Cannot use an incomplete node to initialize the edit bookmark panel"
        );
      }
      let parent = node.parent;
      isParentReadOnly = !PlacesUtils.nodeIsFolderOrShortcut(parent);
      // Note this may be an empty string, that'd the case for the root node
      // of a search, or a virtual root node, like the Library left pane.
      parentGuid = parent.bookmarkGuid;
    }

    let focusedElement = aInitInfo.focusedElement;
    let onPanelReady = aInitInfo.onPanelReady;

    return (this._paneInfo = {
      itemGuid,
      parentGuid,
      isItem,
      isURI,
      uri,
      title,
      isBookmark,
      isFolderShortcut,
      addedMultipleBookmarks,
      isParentReadOnly,
      bulkTagging,
      uris,
      visibleRows,
      postData,
      isTag,
      focusedElement,
      onPanelReady,
      tag,
    });
  },

  get initialized() {
    return this._paneInfo != null;
  },

  /**
   * The concrete bookmark GUID is either the bookmark one or, for folder
   * shortcuts, the target one.
   *
   * @returns {string} GUID of the loaded bookmark, or null if not a bookmark.
   */
  get concreteGuid() {
    if (
      !this.initialized ||
      this._paneInfo.isTag ||
      this._paneInfo.bulkTagging
    ) {
      return null;
    }
    return this._paneInfo.itemGuid;
  },

  get uri() {
    if (!this.initialized) {
      return null;
    }
    if (this._paneInfo.bulkTagging) {
      return this._paneInfo.uris[0];
    }
    return this._paneInfo.uri;
  },

  get multiEdit() {
    return this.initialized && this._paneInfo.bulkTagging;
  },

  // Check if the pane is initialized to show only read-only fields.
  get readOnly() {
    // TODO (Bug 1120314): Folder shortcuts are currently read-only due to some
    // quirky implementation details (the most important being the "smart"
    // semantics of node.title that makes hard to edit the right entry).
    // This pane is read-only if:
    //  * the panel is not initialized
    //  * the node is a folder shortcut
    //  * the node is not bookmarked and not a tag container
    //  * the node is child of a read-only container and is not a bookmarked
    //    URI nor a tag container
    return (
      !this.initialized ||
      this._paneInfo.isFolderShortcut ||
      (!this._paneInfo.isItem && !this._paneInfo.isTag) ||
      (this._paneInfo.isParentReadOnly &&
        !this._paneInfo.isBookmark &&
        !this._paneInfo.isTag)
    );
  },

  get didChangeFolder() {
    return this._didChangeFolder;
  },

  // the first field which was edited after this panel was initialized for
  // a certain item
  _firstEditedField: "",

  _initNamePicker() {
    if (this._paneInfo.bulkTagging && !this._paneInfo.addedMultipleBookmarks) {
      throw new Error("_initNamePicker called unexpectedly");
    }

    // title may by null, which, for us, is the same as an empty string.
    this._initTextField(
      this._namePicker,
      this._paneInfo.title || this._paneInfo.tag || ""
    );
  },

  _initLocationField() {
    if (!this._paneInfo.isURI) {
      throw new Error("_initLocationField called unexpectedly");
    }
    this._initTextField(this._locationField, this._paneInfo.uri.spec);
  },

  async _initKeywordField(newKeyword = "") {
    if (!this._paneInfo.isBookmark) {
      throw new Error("_initKeywordField called unexpectedly");
    }

    // Reset the field status synchronously now, eventually we'll reinit it
    // later if we find an existing keyword. This way we can ensure to be in a
    // consistent status when reusing the panel across different bookmarks.
    this._keyword = newKeyword;
    this._initTextField(this._keywordField, newKeyword);

    if (!newKeyword) {
      let entries = [];
      await PlacesUtils.keywords.fetch({ url: this._paneInfo.uri.spec }, e =>
        entries.push(e)
      );
      if (entries.length) {
        // We show an existing keyword if either POST data was not provided, or
        // if the POST data is the same.
        let existingKeyword = entries[0].keyword;
        let postData = this._paneInfo.postData;
        if (postData) {
          let sameEntry = entries.find(e => e.postData === postData);
          existingKeyword = sameEntry ? sameEntry.keyword : "";
        }
        if (existingKeyword) {
          this._keyword = existingKeyword;
          // Update the text field to the existing keyword.
          this._initTextField(this._keywordField, this._keyword);
        }
      }
    }
  },

  async _initAllTags() {
    this._allTags = new Map();
    const fetchedTags = await PlacesUtils.bookmarks.fetchTags();
    for (const tag of fetchedTags) {
      this._allTags?.set(tag.name.toLowerCase(), tag.name);
    }
  },

  /**
   * Initialize the panel.
   *
   * @param {object} aInfo
   *   The initialization info.
   * @param {object} [aInfo.node]
   *   If aInfo.uris is not specified, this must be specified.
   *   Either a result node or a node-like object representing the item to be edited.
   *   A node-like object must have the following properties (with values that
   *   match exactly those a result node would have):
   *   bookmarkGuid, uri, title, type, …
   * @param {nsIURI[]} [aInfo.uris]
   *   If aInfo.node is not specified, this must be specified.
   *   An array of uris for bulk tagging.
   * @param {string[]} [aInfo.hiddenRows]
   *   List of rows to be hidden regardless of the item edited. Possible values:
   *   "title", "location", "keyword", "folderPicker".
   */
  async initPanel(aInfo) {
    const deferred = (this._initPanelDeferred = Promise.withResolvers());
    try {
      if (typeof aInfo != "object" || aInfo === null) {
        throw new Error("aInfo must be an object.");
      }
      if ("node" in aInfo) {
        try {
          aInfo.node.type;
        } catch (e) {
          // If the lazy loader for |type| generates an exception, it means that
          // this bookmark could not be loaded. This sometimes happens when tests
          // create a bookmark by clicking the bookmark star, then try to cleanup
          // before the bookmark panel has finished opening. Either way, if we
          // cannot retrieve the bookmark information, we cannot open the panel.
          return;
        }
      }

      // For sanity ensure that the implementer has uninited the panel before
      // trying to init it again, or we could end up leaking due to observers.
      if (this.initialized) {
        this.uninitPanel(false);
      }

      this._didChangeFolder = false;
      this.transactionPromises = [];

      let {
        parentGuid,
        isItem,
        isURI,
        isBookmark,
        addedMultipleBookmarks,
        bulkTagging,
        uris,
        visibleRows,
        focusedElement,
        onPanelReady,
      } = this._setPaneInfo(aInfo);

      // initPanel can be called multiple times in a row,
      // and awaits Promises. If the reference to `instance`
      // changes, it must mean another caller has called
      // initPanel again, so bail out of the initialization.
      let instance = (this._instance = {});

      // If we're creating a new item on the toolbar, show it:
      if (
        aInfo.isNewBookmark &&
        parentGuid == PlacesUtils.bookmarks.toolbarGuid
      ) {
        this._autoshowBookmarksToolbar();
      }

      // Observe changes.
      if (!this._observersAdded) {
        this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
        PlacesUtils.observers.addListener(
          ["bookmark-title-changed"],
          this.handlePlacesEvents
        );
        window.addEventListener("unload", this);

        let panel = document.getElementById("editBookmarkPanelContent");
        panel.addEventListener("change", this);
        panel.addEventListener("command", this);

        this._observersAdded = true;
      }

      let showOrCollapse = (
        rowId,
        isAppropriateForInput,
        nameInHiddenRows = null
      ) => {
        let visible = isAppropriateForInput;
        if (visible && "hiddenRows" in aInfo && nameInHiddenRows) {
          visible &= !aInfo.hiddenRows.includes(nameInHiddenRows);
        }
        if (visible) {
          visibleRows.add(rowId);
        }
        const cells = document.getElementsByClassName("editBMPanel_" + rowId);
        for (const cell of cells) {
          cell.hidden = !visible;
        }
        return visible;
      };

      if (
        showOrCollapse(
          "nameRow",
          !bulkTagging || addedMultipleBookmarks,
          "name"
        )
      ) {
        this._initNamePicker();
        this._namePicker.readOnly = this.readOnly;
      }

      // In some cases we want to hide the location field, since it's not
      // human-readable, but we still want to initialize it.
      showOrCollapse("locationRow", isURI, "location");
      if (isURI) {
        this._initLocationField();
        this._locationField.readOnly = this.readOnly;
      }

      if (showOrCollapse("keywordRow", isBookmark, "keyword")) {
        await this._initKeywordField().catch(console.error);
        // paneInfo can be null if paneInfo is uninitialized while
        // the process above is awaiting initialization
        if (instance != this._instance || this._paneInfo == null) {
          return;
        }
        this._keywordField.readOnly = this.readOnly;
      }

      // Collapse the tag selector if the item does not accept tags.
      if (showOrCollapse("tagsRow", isBookmark || bulkTagging, "tags")) {
        this._initTagsField();
      } else if (!this._element("tagsSelectorRow").hidden) {
        this.toggleTagsSelector().catch(console.error);
      }

      // Folder picker.
      // Technically we should check that the item is not moveable, but that's
      // not cheap (we don't always have the parent), and there's no use case for
      // this (it's only the Star UI that shows the folderPicker)
      if (showOrCollapse("folderRow", isItem, "folderPicker")) {
        await this._initFolderMenuList(parentGuid).catch(console.error);
        if (instance != this._instance || this._paneInfo == null) {
          return;
        }
      }

      // Selection count.
      if (showOrCollapse("selectionCount", bulkTagging)) {
        document.l10n.setAttributes(
          this._element("itemsCountText"),
          "places-details-pane-items-count",
          { count: uris.length }
        );
      }

      let focusElement = () => {
        // The focusedElement possible values are:
        //  * preferred: focus the field that the user touched first the last
        //    time the pane was shown (either namePicker or tagsField)
        //  * first: focus the first non hidden input
        // Note: since all controls are hidden by default, we don't get the
        // default XUL dialog behavior, that selects the first control, so we set
        // the focus explicitly.

        let elt;
        if (focusedElement === "preferred") {
          elt = this._element(
            Services.prefs.getCharPref(
              "browser.bookmarks.editDialog.firstEditField"
            )
          );
          if (elt.parentNode.hidden) {
            focusedElement = "first";
          }
        }
        if (focusedElement === "first") {
          elt = document
            .getElementById("editBookmarkPanelContent")
            .querySelector('input:not([hidden="true"])');
        }

        if (elt) {
          elt.focus({ preventScroll: true });
          elt.select();
        }
      };

      if (onPanelReady) {
        onPanelReady(focusElement);
      } else {
        focusElement();
      }

      if (this._updateTagsDeferred) {
        await this._updateTagsDeferred.promise;
      }

      this._bookmarkState = this.makeNewStateObject({
        children: aInfo.node?.children,
        index: aInfo.node?.index,
        isFolder:
          aInfo.node != null && PlacesUtils.nodeIsFolderOrShortcut(aInfo.node),
      });
      if (isBookmark || bulkTagging) {
        await this._initAllTags();
        await this._rebuildTagsSelectorList();
      }
    } finally {
      deferred.resolve();
      if (this._initPanelDeferred === deferred) {
        // Since change listeners check _initPanelDeferred for truthiness, we
        // can prevent unnecessary awaits by setting it back to null.
        this._initPanelDeferred = null;
      }
    }
  },

  /**
   * Finds tags that are in common among this._currentInfo.uris;
   *
   * @returns {string[]}
   */
  _getCommonTags() {
    if ("_cachedCommonTags" in this._paneInfo) {
      return this._paneInfo._cachedCommonTags;
    }

    let uris = [...this._paneInfo.uris];
    let firstURI = uris.shift();
    let commonTags = new Set(PlacesUtils.tagging.getTagsForURI(firstURI));
    if (commonTags.size == 0) {
      return (this._cachedCommonTags = []);
    }

    for (let uri of uris) {
      let curentURITags = PlacesUtils.tagging.getTagsForURI(uri);
      for (let tag of commonTags) {
        if (!curentURITags.includes(tag)) {
          commonTags.delete(tag);
          if (commonTags.size == 0) {
            return (this._paneInfo.cachedCommonTags = []);
          }
        }
      }
    }
    return (this._paneInfo._cachedCommonTags = [...commonTags]);
  },

  _initTextField(aElement, aValue) {
    if (aElement.value != aValue) {
      aElement.value = aValue;

      // Clear the editor's undo stack
      // FYI: editor may be null.
      aElement.editor?.clearUndoRedo();
    }
  },

  /**
   * Appends a menu-item representing a bookmarks folder to a menu-popup.
   *
   * @param {DOMElement} aMenupopup
   *   The popup to which the menu-item should be added.
   * @param {string} aFolderGuid
   *   The identifier of the bookmarks folder.
   * @param {string} aTitle
   *   The title to use as a label.
   * @returns {DOMElement}
   *   The new menu item.
   */
  _appendFolderItemToMenupopup(aMenupopup, aFolderGuid, aTitle) {
    // First make sure the folders-separator is visible
    this._element("foldersSeparator").hidden = false;

    var folderMenuItem = document.createXULElement("menuitem");
    folderMenuItem.folderGuid = aFolderGuid;
    folderMenuItem.setAttribute("label", aTitle);
    folderMenuItem.className = "menuitem-iconic folder-icon";
    aMenupopup.appendChild(folderMenuItem);
    return folderMenuItem;
  },

  async _initFolderMenuList(aSelectedFolderGuid) {
    // clean up first
    var menupopup = this._folderMenuList.menupopup;
    while (menupopup.children.length > 6) {
      menupopup.removeChild(menupopup.lastElementChild);
    }

    // Build the static list
    if (!this._staticFoldersListBuilt) {
      let unfiledItem = this._element("unfiledRootItem");
      unfiledItem.label = PlacesUtils.getString("OtherBookmarksFolderTitle");
      unfiledItem.folderGuid = PlacesUtils.bookmarks.unfiledGuid;
      let bmMenuItem = this._element("bmRootItem");
      bmMenuItem.label = PlacesUtils.getString("BookmarksMenuFolderTitle");
      bmMenuItem.folderGuid = PlacesUtils.bookmarks.menuGuid;
      let toolbarItem = this._element("toolbarFolderItem");
      toolbarItem.label = PlacesUtils.getString("BookmarksToolbarFolderTitle");
      toolbarItem.folderGuid = PlacesUtils.bookmarks.toolbarGuid;
      this._staticFoldersListBuilt = true;
    }

    // List of recently used folders:
    let lastUsedFolderGuids = await PlacesUtils.metadata.get(
      PlacesUIUtils.LAST_USED_FOLDERS_META_KEY,
      []
    );

    /**
     * The list of last used folders is sorted in most-recent first order.
     *
     * First we build the annotated folders array, each item has both the
     * folder identifier and the time at which it was last-used by this dialog
     * set. Then we sort it descendingly based on the time field.
     */
    this._recentFolders = [];
    for (let guid of lastUsedFolderGuids) {
      let bm = await PlacesUtils.bookmarks.fetch(guid);
      if (bm) {
        let title = PlacesUtils.bookmarks.getLocalizedTitle(bm);
        this._recentFolders.push({ guid, title });
      }
    }

    var numberOfItems = Math.min(
      PlacesUIUtils.maxRecentFolders,
      this._recentFolders.length
    );
    for (let i = 0; i < numberOfItems; i++) {
      await this._appendFolderItemToMenupopup(
        menupopup,
        this._recentFolders[i].guid,
        this._recentFolders[i].title
      );
    }

    let title = (await PlacesUtils.bookmarks.fetch(aSelectedFolderGuid)).title;
    var defaultItem = this._getFolderMenuItem(aSelectedFolderGuid, title);
    this._folderMenuList.selectedItem = defaultItem;
    // Ensure the selectedGuid attribute is set correctly (the above line wouldn't
    // necessary trigger a select event, so handle it manually, then add the
    // listener).
    this._onFolderListSelected();

    this._folderMenuList.addEventListener("select", this);
    this._folderMenuList.addEventListener("command", this);
    this._folderMenuListListenerAdded = true;

    // Hide the folders-separator if no folder is annotated as recently-used
    this._element("foldersSeparator").hidden = menupopup.children.length <= 6;
    this._folderMenuList.disabled = this.readOnly;
  },

  _onFolderListSelected() {
    // Set a selectedGuid attribute to show special icons
    let folderGuid = this.selectedFolderGuid;
    if (folderGuid) {
      this._folderMenuList.setAttribute("selectedGuid", folderGuid);
    } else {
      this._folderMenuList.removeAttribute("selectedGuid");
    }
  },

  _element(aID) {
    return document.getElementById("editBMPanel_" + aID);
  },

  uninitPanel(aHideCollapsibleElements) {
    if (aHideCollapsibleElements) {
      // Hide the folder tree if it was previously visible.
      var folderTreeRow = this._element("folderTreeRow");
      if (!folderTreeRow.hidden) {
        this.toggleFolderTreeVisibility();
      }

      // Hide the tag selector if it was previously visible.
      var tagsSelectorRow = this._element("tagsSelectorRow");
      if (!tagsSelectorRow.hidden) {
        this.toggleTagsSelector().catch(console.error);
      }
    }

    if (this._observersAdded) {
      PlacesUtils.observers.removeListener(
        ["bookmark-title-changed"],
        this.handlePlacesEvents
      );
      window.removeEventListener("unload", this);
      let panel = document.getElementById("editBookmarkPanelContent");
      panel.removeEventListener("change", this);
      panel.removeEventListener("command", this);
      this._observersAdded = false;
    }

    if (this._folderMenuListListenerAdded) {
      this._folderMenuList.removeEventListener("select", this);
      this._folderMenuList.removeEventListener("command", this);
      this._folderMenuListListenerAdded = false;
    }

    this._setPaneInfo(null);
    this._firstEditedField = "";
    this._didChangeFolder = false;
    this.transactionPromises = [];
    this._bookmarkState = null;
    this._allTags = null;
  },

  get selectedFolderGuid() {
    return (
      this._folderMenuList.selectedItem &&
      this._folderMenuList.selectedItem.folderGuid
    );
  },

  makeNewStateObject(extraOptions) {
    if (
      this._paneInfo.isItem ||
      this._paneInfo.isTag ||
      this._paneInfo.bulkTagging
    ) {
      const isLibraryWindow =
        document.documentElement.getAttribute("windowtype") ===
        "Places:Organizer";
      const options = {
        autosave: isLibraryWindow,
        info: this._paneInfo,
        ...extraOptions,
      };

      if (this._paneInfo.isBookmark) {
        options.tags = this._element("tagsField").value;
        options.keyword = this._keyword;
      }

      if (this._paneInfo.bulkTagging) {
        options.tags = this._element("tagsField").value;
      }

      return new PlacesUIUtils.BookmarkState(options);
    }
    return null;
  },

  async onTagsFieldChange() {
    // Check for _paneInfo existing as the dialog may be closing but receiving
    // async updates from unresolved promises.
    if (
      this._paneInfo &&
      (this._paneInfo.isURI || this._paneInfo.bulkTagging)
    ) {
      if (this._initPanelDeferred) {
        await this._initPanelDeferred.promise;
      }
      this._updateTags().then(() => {
        // Check _paneInfo here as we might be closing the dialog.
        if (this._paneInfo) {
          this._mayUpdateFirstEditField("tagsField");
        }
      }, console.error);
    }
  },

  /**
   * Handle tag list updates from the input field or selector box.
   */
  async _updateTags() {
    const deferred = (this._updateTagsDeferred = Promise.withResolvers());
    try {
      const inputTags = this._getTagsArrayFromTagsInputField();
      const isLibraryWindow =
        document.documentElement.getAttribute("windowtype") ===
        "Places:Organizer";
      await this._bookmarkState._tagsChanged(inputTags);

      if (isLibraryWindow) {
        // Ensure the tagsField is in sync, clean it up from empty tags
        delete this._paneInfo._cachedCommonTags;
        const currentTags = this._paneInfo.bulkTagging
          ? this._getCommonTags()
          : PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
        this._initTextField(this._tagsField, currentTags.join(", "), false);
        await this._initAllTags();
      } else {
        // Autosave is disabled. Update _allTags in memory so that the selector
        // list shows any new tags that haven't been saved yet.
        inputTags.forEach(tag => this._allTags?.set(tag.toLowerCase(), tag));
      }
      await this._rebuildTagsSelectorList();
    } finally {
      deferred.resolve();
      if (this._updateTagsDeferred === deferred) {
        // Since initPanel() checks _updateTagsDeferred for truthiness, we can
        // prevent unnecessary awaits by setting it back to null.
        this._updateTagsDeferred = null;
      }
    }
  },

  /**
   * Stores the first-edit field for this dialog, if the passed-in field
   * is indeed the first edited field.
   *
   * @param {string} aNewField
   *   The id of the field that may be set (without the "editBMPanel_" prefix).
   */
  _mayUpdateFirstEditField(aNewField) {
    // * The first-edit-field behavior is not applied in the multi-edit case
    // * if this._firstEditedField is already set, this is not the first field,
    //   so there's nothing to do
    if (this._paneInfo.bulkTagging || this._firstEditedField) {
      return;
    }

    this._firstEditedField = aNewField;

    // set the pref
    Services.prefs.setCharPref(
      "browser.bookmarks.editDialog.firstEditField",
      aNewField
    );
  },

  async onNamePickerChange() {
    if (this.readOnly || !(this._paneInfo.isItem || this._paneInfo.isTag)) {
      return;
    }
    if (this._initPanelDeferred) {
      await this._initPanelDeferred.promise;
    }

    // Here we update either the item title or its cached static title
    if (this._paneInfo.isTag) {
      let tag = this._namePicker.value;
      if (!tag || tag.includes("&")) {
        // We don't allow setting an empty title for a tag, restore the old one.
        this._initNamePicker();
        return;
      }

      this._bookmarkState._titleChanged(tag);
      return;
    }
    this._mayUpdateFirstEditField("namePicker");
    this._bookmarkState._titleChanged(this._namePicker.value);
  },

  async onLocationFieldChange() {
    if (this.readOnly || !this._paneInfo.isBookmark) {
      return;
    }
    if (this._initPanelDeferred) {
      await this._initPanelDeferred.promise;
    }

    let newURI;
    try {
      newURI = Services.uriFixup.getFixupURIInfo(
        this._locationField.value
      ).preferredURI;
    } catch (ex) {
      // TODO: Bug 1089141 - Provide some feedback about the invalid url.
      return;
    }

    if (this._paneInfo.uri.equals(newURI)) {
      return;
    }
    this._bookmarkState._locationChanged(newURI.spec);
  },

  async onKeywordFieldChange() {
    if (this.readOnly || !this._paneInfo.isBookmark) {
      return;
    }
    if (this._initPanelDeferred) {
      await this._initPanelDeferred.promise;
    }
    this._bookmarkState._keywordChanged(this._keywordField.value);
  },

  toggleFolderTreeVisibility() {
    let expander = this._element("foldersExpander");
    let folderTreeRow = this._element("folderTreeRow");
    let wasHidden = folderTreeRow.hidden;
    expander.classList.toggle("expander-up", wasHidden);
    expander.classList.toggle("expander-down", !wasHidden);
    if (!wasHidden) {
      document.l10n.setAttributes(
        expander,
        "bookmark-overlay-folders-expander2"
      );
      folderTreeRow.hidden = true;
      this._element("chooseFolderSeparator").hidden = this._element(
        "chooseFolderMenuItem"
      ).hidden = false;
      // Stop editing if we were (will no-op if not). This avoids permanently
      // breaking the tree if/when it is reshown.
      this._folderTree.stopEditing(false);
      // Unlinking the view will break the connection with the result. We don't
      // want to pay for live updates while the view is not visible.
      this._folderTree.view = null;
    } else {
      document.l10n.setAttributes(
        expander,
        "bookmark-overlay-folders-expander-hide"
      );
      folderTreeRow.hidden = false;

      // XXXmano: Ideally we would only do this once, but for some odd reason,
      // the editable mode set on this tree, together with its hidden state
      // breaks the view.
      const FOLDER_TREE_PLACE_URI =
        "place:excludeItems=1&excludeQueries=1&type=" +
        Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY;
      this._folderTree.place = FOLDER_TREE_PLACE_URI;

      this._element("chooseFolderSeparator").hidden = this._element(
        "chooseFolderMenuItem"
      ).hidden = true;
      this._folderTree.selectItems([this._bookmarkState.parentGuid]);
      this._folderTree.focus();
    }
  },

  /**
   * Get the corresponding menu-item in the folder-menu-list for a bookmarks
   * folder if such an item exists. Otherwise, this creates a menu-item for the
   * folder. If the items-count limit (see
   * browser.bookmarks.editDialog.maxRecentFolders preference) is reached, the
   * new item replaces the last menu-item.
   *
   * @param {string} aFolderGuid
   *   The identifier of the bookmarks folder.
   * @param {string} aTitle
   *   The title to use in case of menuitem creation.
   * @returns {DOMElement}
   *   The handle to the menuitem.
   */
  _getFolderMenuItem(aFolderGuid, aTitle) {
    let menupopup = this._folderMenuList.menupopup;
    let menuItem = Array.prototype.find.call(
      menupopup.children,
      item => item.folderGuid === aFolderGuid
    );
    if (menuItem !== undefined) {
      return menuItem;
    }

    // 3 special folders + separator + folder-items-count limit
    if (menupopup.children.length == 4 + PlacesUIUtils.maxRecentFolders) {
      menupopup.removeChild(menupopup.lastElementChild);
    }

    return this._appendFolderItemToMenupopup(menupopup, aFolderGuid, aTitle);
  },

  async onFolderMenuListCommand(aEvent) {
    // Check for _paneInfo existing as the dialog may be closing but receiving
    // async updates from unresolved promises.
    if (!this._paneInfo) {
      return;
    }

    if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") {
      // reset the selection back to where it was and expand the tree
      // (this menu-item is hidden when the tree is already visible
      let item = this._getFolderMenuItem(
        this._bookmarkState._originalState.parentGuid,
        this._bookmarkState._originalState.title
      );
      this._folderMenuList.selectedItem = item;
      // XXXmano HACK: setTimeout 100, otherwise focus goes back to the
      // menulist right away
      setTimeout(() => this.toggleFolderTreeVisibility(), 100);
      return;
    }

    // Move the item
    let containerGuid = this._folderMenuList.selectedItem.folderGuid;
    if (this._bookmarkState.parentGuid != containerGuid) {
      this._bookmarkState._parentGuidChanged(containerGuid);

      // Auto-show the bookmarks toolbar when adding / moving an item there.
      if (containerGuid == PlacesUtils.bookmarks.toolbarGuid) {
        this._autoshowBookmarksToolbar();
      }

      // Unless the user cancels the panel, we'll use the chosen folder as
      // the default for new bookmarks.
      this._didChangeFolder = true;
    }

    // Update folder-tree selection
    var folderTreeRow = this._element("folderTreeRow");
    if (!folderTreeRow.hidden) {
      var selectedNode = this._folderTree.selectedNode;
      if (
        !selectedNode ||
        PlacesUtils.getConcreteItemGuid(selectedNode) != containerGuid
      ) {
        this._folderTree.selectItems([containerGuid]);
      }
    }
  },

  _autoshowBookmarksToolbar() {
    let neverShowToolbar =
      Services.prefs.getCharPref(
        "browser.toolbars.bookmarks.visibility",
        "newtab"
      ) == "never";
    let toolbar = document.getElementById("PersonalToolbar");
    if (!toolbar.collapsed || neverShowToolbar) {
      return;
    }

    let placement = CustomizableUI.getPlacementOfWidget("personal-bookmarks");
    let area = placement && placement.area;
    if (area != CustomizableUI.AREA_BOOKMARKS) {
      return;
    }

    // Show the toolbar but don't persist it permanently open
    setToolbarVisibility(toolbar, true, false);
  },

  onFolderTreeSelect() {
    // Ignore this event when the folder tree is hidden, even if the tree is
    // alive, it's clearly not a user activated action.
    if (this._element("folderTreeRow").hidden) {
      return;
    }

    var selectedNode = this._folderTree.selectedNode;

    // Disable the "New Folder" button if we cannot create a new folder
    this._element("newFolderButton").disabled =
      !this._folderTree.insertionPoint || !selectedNode;

    if (!selectedNode) {
      return;
    }

    var folderGuid = PlacesUtils.getConcreteItemGuid(selectedNode);
    if (this._folderMenuList.selectedItem.folderGuid == folderGuid) {
      return;
    }

    var folderItem = this._getFolderMenuItem(folderGuid, selectedNode.title);
    this._folderMenuList.selectedItem = folderItem;
    folderItem.doCommand();
  },

  async _rebuildTagsSelectorList() {
    let tagsSelector = this._element("tagsSelector");
    let tagsSelectorRow = this._element("tagsSelectorRow");
    if (tagsSelectorRow.hidden) {
      return;
    }

    let selectedIndex = tagsSelector.selectedIndex;
    let selectedTag =
      selectedIndex >= 0 ? tagsSelector.selectedItem.label : null;

    while (tagsSelector.hasChildNodes()) {
      tagsSelector.removeChild(tagsSelector.lastElementChild);
    }

    let tagsInField = this._getTagsArrayFromTagsInputField();

    let fragment = document.createDocumentFragment();
    let sortedTags = this._allTags ? [...this._allTags.values()].sort() : [];

    for (let i = 0; i < sortedTags.length; i++) {
      let tag = sortedTags[i];
      let elt = document.createXULElement("richlistitem");
      elt.appendChild(document.createXULElement("image"));
      let label = document.createXULElement("label");
      label.setAttribute("value", tag);
      elt.appendChild(label);
      if (tagsInField.includes(tag)) {
        elt.setAttribute("checked", "true");
      }
      fragment.appendChild(elt);
      if (selectedTag === tag) {
        selectedIndex = i;
      }
    }
    tagsSelector.appendChild(fragment);

    if (selectedIndex >= 0 && tagsSelector.itemCount > 0) {
      selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1);
      tagsSelector.selectedIndex = selectedIndex;
      tagsSelector.ensureIndexIsVisible(selectedIndex);
    }
    let event = new CustomEvent("BookmarkTagsSelectorUpdated", {
      bubbles: true,
    });
    tagsSelector.dispatchEvent(event);
  },

  async toggleTagsSelector() {
    var tagsSelector = this._element("tagsSelector");
    var tagsSelectorRow = this._element("tagsSelectorRow");
    var expander = this._element("tagsSelectorExpander");
    expander.classList.toggle("expander-up", tagsSelectorRow.hidden);
    expander.classList.toggle("expander-down", !tagsSelectorRow.hidden);
    if (tagsSelectorRow.hidden) {
      document.l10n.setAttributes(
        expander,
        "bookmark-overlay-tags-expander-hide"
      );
      tagsSelectorRow.hidden = false;
      await this._rebuildTagsSelectorList();

      // This is a no-op if we've added the listener.
      tagsSelector.addEventListener("mousedown", this);
      tagsSelector.addEventListener("keypress", this);
    } else {
      document.l10n.setAttributes(expander, "bookmark-overlay-tags-expander2");
      tagsSelectorRow.hidden = true;

      // This is a no-op if we've removed the listener.
      tagsSelector.removeEventListener("mousedown", this);
      tagsSelector.removeEventListener("keypress", this);
    }
  },

  /**
   * Splits "tagsField" element value, returning an array of valid tag strings.
   *
   * @returns {string[]}
   *   Array of tag strings found in the field value.
   */
  _getTagsArrayFromTagsInputField() {
    let tags = this._element("tagsField").value;
    return tags
      .trim()
      .split(/\s*,\s*/) // Split on commas and remove spaces.
      .filter(tag => !!tag.length); // Kill empty tags.
  },

  async newFolder() {
    let ip = this._folderTree.insertionPoint;

    // default to the bookmarks menu folder
    if (!ip) {
      ip = new PlacesInsertionPoint({
        parentGuid: PlacesUtils.bookmarks.menuGuid,
      });
    }

    // XXXmano: add a separate "New Folder" string at some point...
    let title = this._element("newFolderButton").label;
    let promise = PlacesTransactions.NewFolder({
      parentGuid: ip.guid,
      title,
      index: await ip.getIndex(),
    }).transact();
    this.transactionPromises.push(promise.catch(console.error));
    let guid = await promise;

    this._folderTree.focus();
    this._folderTree.selectItems([ip.guid]);
    PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true;
    this._folderTree.selectItems([guid]);
    this._folderTree.startEditing(
      this._folderTree.view.selection.currentIndex,
      this._folderTree.columns.getFirstColumn()
    );
  },

  // EventListener
  handleEvent(event) {
    switch (event.type) {
      case "mousedown":
        if (event.button == 0) {
          // Make sure the event is triggered on an item and not the empty space.
          let item = event.target.closest("richlistbox,richlistitem");
          if (item.localName == "richlistitem") {
            this.toggleItemCheckbox(item);
          }
        }
        break;
      case "keypress":
        if (event.key == " ") {
          let item = event.target.currentItem;
          if (item) {
            this.toggleItemCheckbox(item);
          }
        }
        break;
      case "unload":
        this.uninitPanel(false);
        break;
      case "select":
        this._onFolderListSelected();
        break;
      case "change":
        switch (event.target.id) {
          case "editBMPanel_namePicker":
            this.onNamePickerChange().catch(console.error);
            break;

          case "editBMPanel_locationField":
            this.onLocationFieldChange();
            break;

          case "editBMPanel_tagsField":
            this.onTagsFieldChange();
            break;

          case "editBMPanel_keywordField":
            this.onKeywordFieldChange();
            break;
        }
        break;
      case "command":
        if (event.currentTarget.id === "editBMPanel_folderMenuList") {
          this.onFolderMenuListCommand(event).catch(console.error);
          return;
        }

        switch (event.target.id) {
          case "editBMPanel_foldersExpander":
            this.toggleFolderTreeVisibility();
            break;
          case "editBMPanel_newFolderButton":
            this.newFolder().catch(console.error);
            break;
          case "editBMPanel_tagsSelectorExpander":
            this.toggleTagsSelector().catch(console.error);
            break;
        }
        break;
    }
  },

  async handlePlacesEvents(events) {
    for (const event of events) {
      switch (event.type) {
        case "bookmark-title-changed":
          if (this._paneInfo.isItem || this._paneInfo.isTag) {
            // This also updates titles of folders in the folder menu list.
            this._onItemTitleChange(event.id, event.title, event.guid);
          }
          break;
      }
    }
  },

  toggleItemCheckbox(item) {
    // Update the tags field when items are checked/unchecked in the listbox
    let tags = this._getTagsArrayFromTagsInputField();

    let curTagIndex = tags.indexOf(item.label);
    let tagsSelector = this._element("tagsSelector");
    tagsSelector.selectedItem = item;

    if (!item.hasAttribute("checked")) {
      item.setAttribute("checked", "true");
      if (curTagIndex == -1) {
        tags.push(item.label);
      }
    } else {
      item.removeAttribute("checked");
      if (curTagIndex != -1) {
        tags.splice(curTagIndex, 1);
      }
    }
    this._element("tagsField").value = tags.join(", ");
    this._updateTags();
  },

  _initTagsField() {
    let tags;
    if (this._paneInfo.isURI) {
      tags = PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri);
    } else if (this._paneInfo.bulkTagging) {
      tags = this._getCommonTags();
    } else {
      throw new Error("_promiseTagsStr called unexpectedly");
    }

    this._initTextField(this._tagsField, tags.join(", "));
  },

  _onItemTitleChange(aItemId, aNewTitle, aGuid) {
    if (this._paneInfo.visibleRows.has("folderRow")) {
      // If the title of a folder which is listed within the folders
      // menulist has been changed, we need to update the label of its
      // representing element.
      let menupopup = this._folderMenuList.menupopup;
      for (let menuitem of menupopup.children) {
        if ("folderGuid" in menuitem && menuitem.folderGuid == aGuid) {
          menuitem.label = aNewTitle;
          break;
        }
      }
    }
    // We need to also update title of recent folders.
    if (this._recentFolders) {
      for (let folder of this._recentFolders) {
        if (folder.folderGuid == aGuid) {
          folder.title = aNewTitle;
          break;
        }
      }
    }
  },

  /**
   * State object for the bookmark(s) currently being edited.
   *
   * @returns {BookmarkState} The bookmark state.
   */
  get bookmarkState() {
    return this._bookmarkState;
  },
};

ChromeUtils.defineLazyGetter(gEditItemOverlay, "_folderTree", () => {
  if (!customElements.get("places-tree")) {
    Services.scriptloader.loadSubScript(
      "chrome://browser/content/places/places-tree.js",
      window
    );
  }
  gEditItemOverlay._element("folderTreeRow").prepend(
    MozXULElement.parseXULToFragment(`
    <tree id="editBMPanel_folderTree"
          class="placesTree"
          is="places-tree"
          data-l10n-id="bookmark-overlay-folders-tree"
          editable="true"
          disableUserActions="true"
          hidecolumnpicker="true">
      <treecols>
        <treecol anonid="title" flex="1" primary="true" hideheader="true"/>
      </treecols>
      <treechildren flex="1"/>
    </tree>
  `)
  );
  const folderTree = gEditItemOverlay._element("folderTree");
  folderTree.addEventListener("select", () =>
    gEditItemOverlay.onFolderTreeSelect()
  );
  return folderTree;
});

for (let elt of [
  "folderMenuList",
  "namePicker",
  "locationField",
  "keywordField",
  "tagsField",
]) {
  let eltScoped = elt;
  ChromeUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, () =>
    gEditItemOverlay._element(eltScoped)
  );
}
