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

"use strict";

const { l10n } = require("resource://devtools/shared/inspector/css-logic.js");
const {
  PSEUDO_CLASSES,
} = require("resource://devtools/shared/css/constants.js");
const {
  style: { ELEMENT_STYLE, PRES_HINTS },
} = require("resource://devtools/shared/constants.js");
const Rule = require("resource://devtools/client/inspector/rules/models/rule.js");
const {
  InplaceEditor,
  editableField,
  editableItem,
} = require("resource://devtools/client/shared/inplace-editor.js");
const TextPropertyEditor = require("resource://devtools/client/inspector/rules/views/text-property-editor.js");
const {
  createChild,
  blurOnMultipleProperties,
  promiseWarn,
} = require("resource://devtools/client/inspector/shared/utils.js");
const {
  parseNamedDeclarations,
  parsePseudoClassesAndAttributes,
  SELECTOR_ATTRIBUTE,
  SELECTOR_ELEMENT,
  SELECTOR_PSEUDO_CLASS,
} = require("resource://devtools/shared/css/parsing-utils.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const CssLogic = require("resource://devtools/shared/inspector/css-logic.js");

loader.lazyRequireGetter(
  this,
  "Tools",
  "resource://devtools/client/definitions.js",
  true
);
loader.lazyRequireGetter(
  this,
  "PluralForm",
  "resource://devtools/shared/plural-form.js",
  true
);

const STYLE_INSPECTOR_PROPERTIES =
  "devtools/shared/locales/styleinspector.properties";
const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);

const COMPONENT_PROPERTIES = "devtools/client/locales/components.properties";
const COMPONENT_L10N = new LocalizationHelper(COMPONENT_PROPERTIES);

loader.lazyGetter(this, "NEW_PROPERTY_NAME_INPUT_LABEL", function () {
  return STYLE_INSPECTOR_L10N.getStr("rule.newPropertyName.label");
});

const UNUSED_CSS_PROPERTIES_HIDE_THRESHOLD = 10;
const INDENT_SIZE = 2;
const INDENT_STR = " ".repeat(INDENT_SIZE);

/**
 * RuleEditor is responsible for the following:
 *   Owns a Rule object and creates a list of TextPropertyEditors
 *     for its TextProperties.
 *   Manages creation of new text properties.
 *
 * @param {CssRuleView} ruleView
 *        The CssRuleView containg the document holding this rule editor.
 * @param {Rule} rule
 *        The Rule object we're editing.
 * @param {object} options
 * @param {Set} options.elementsWithPendingClicks
 * @param {Function} options.onShowUnusedCustomCssProperties
 * @param {boolean} options.shouldHideUnusedCustomCssProperties
 */
function RuleEditor(ruleView, rule, options = {}) {
  EventEmitter.decorate(this);

  this.ruleView = ruleView;
  this.doc = this.ruleView.styleDocument;
  this.toolbox = this.ruleView.inspector.toolbox;
  this.telemetry = this.toolbox.telemetry;
  this.rule = rule;
  this.options = options;

  this.isEditable = rule.isEditable();
  // Flag that blocks updates of the selector and properties when it is
  // being edited
  this.isEditing = false;

  this._onNewProperty = this._onNewProperty.bind(this);
  this._newPropertyDestroy = this._newPropertyDestroy.bind(this);
  this._onSelectorDone = this._onSelectorDone.bind(this);
  this._locationChanged = this._locationChanged.bind(this);
  this.updateSourceLink = this.updateSourceLink.bind(this);
  this._onToolChanged = this._onToolChanged.bind(this);
  this._updateLocation = this._updateLocation.bind(this);
  this._onSourceClick = this._onSourceClick.bind(this);
  this._onShowUnusedCustomCssPropertiesButtonClick =
    this._onShowUnusedCustomCssPropertiesButtonClick.bind(this);

  this.rule.domRule.on("location-changed", this._locationChanged);
  this.toolbox.on("tool-registered", this._onToolChanged);
  this.toolbox.on("tool-unregistered", this._onToolChanged);

  this._create();
}

RuleEditor.prototype = {
  destroy() {
    for (const prop of this.rule.textProps) {
      prop.editor?.destroy();
    }

    this._unusedCssVariableDeclarations = null;

    if (this._showUnusedCustomCssPropertiesButton) {
      this._nullifyShowUnusedCustomCssProperties({ removeFromDom: false });
    }

    this.rule.domRule.off("location-changed");
    this.toolbox.off("tool-registered", this._onToolChanged);
    this.toolbox.off("tool-unregistered", this._onToolChanged);

    if (this._unsubscribeSourceMap) {
      this._unsubscribeSourceMap();
    }
  },

  get sourceMapURLService() {
    if (!this._sourceMapURLService) {
      // sourceMapURLService is a lazy getter in the toolbox.
      this._sourceMapURLService = this.toolbox.sourceMapURLService;
    }

    return this._sourceMapURLService;
  },

  get isSelectorEditable() {
    return (
      this.isEditable &&
      this.rule.domRule.type !== ELEMENT_STYLE &&
      this.rule.domRule.type !== CSSRule.KEYFRAME_RULE &&
      this.rule.domRule.className !== "CSSPositionTryRule"
    );
  },

  get showSelectorHighlighterButton() {
    return (
      this.rule.domRule.type !== CSSRule.KEYFRAME_RULE &&
      this.rule.domRule.className !== "CSSPositionTryRule"
    );
  },

  _create() {
    this.element = this.doc.createElement("div");
    this.element.className =
      "ruleview-rule devtools-monospace" +
      (this.rule.inherited ? " ruleview-rule-inherited" : "");
    this.element.dataset.ruleId = this.rule.domRule.actorID;
    this.element.setAttribute("uneditable", !this.isEditable);
    this.element.setAttribute("unmatched", this.rule.isUnmatched);
    this.element._ruleEditor = this;

    // Give a relative position for the inplace editor's measurement
    // span to be placed absolutely against.
    this.element.style.position = "relative";

    // Add the source link for supported rules. inline style and pres hints are not visible
    // in the StyleEditor, so don't show anything for such rule.
    if (
      this.rule.domRule.type !== ELEMENT_STYLE &&
      this.rule.domRule.type !== PRES_HINTS
    ) {
      this.source = createChild(this.element, "div", {
        class: "ruleview-rule-source theme-link",
      });
      this.source.addEventListener("click", this._onSourceClick);

      const sourceLabel = this.doc.createElement("a");
      sourceLabel.classList.add("ruleview-rule-source-label");
      this.source.appendChild(sourceLabel);
    }
    this.updateSourceLink();

    if (this.rule.domRule.ancestorData.length) {
      const ancestorsFrag = this.doc.createDocumentFragment();
      this.rule.domRule.ancestorData.forEach((ancestorData, index) => {
        const ancestorItem = this.doc.createElement("div");
        ancestorItem.setAttribute("role", "listitem");
        ancestorsFrag.append(ancestorItem);
        ancestorItem.setAttribute("data-ancestor-index", index);
        ancestorItem.classList.add("ruleview-rule-ancestor");
        if (ancestorData.type) {
          ancestorItem.classList.add(ancestorData.type);
        }

        // Indent each parent selector
        if (index) {
          createChild(ancestorItem, "span", {
            class: "ruleview-rule-indent",
            textContent: INDENT_STR.repeat(index),
          });
        }

        const selectorContainer = createChild(ancestorItem, "span", {
          class: "ruleview-rule-ancestor-selectorcontainer",
        });

        if (ancestorData.type == "container") {
          ancestorItem.classList.add("container-query", "has-tooltip");

          createChild(selectorContainer, "span", {
            class: "container-query-declaration",
            textContent: `@container${
              ancestorData.containerName ? " " + ancestorData.containerName : ""
            }`,
          });

          const jumpToNodeButton = createChild(selectorContainer, "button", {
            class: "open-inspector",
            title: l10n("rule.containerQuery.selectContainerButton.tooltip"),
          });

          let containerNodeFront;
          const getNodeFront = async () => {
            if (!containerNodeFront) {
              const res = await this.rule.domRule.getQueryContainerForNode(
                index,
                this.rule.inherited ||
                  this.ruleView.inspector.selection.nodeFront
              );
              containerNodeFront = res.node;
            }
            return containerNodeFront;
          };

          jumpToNodeButton.addEventListener("click", async () => {
            const front = await getNodeFront();
            if (!front) {
              return;
            }
            this.ruleView.inspector.selection.setNodeFront(front);
            await this.ruleView.inspector.highlighters.hideHighlighterType(
              this.ruleView.inspector.highlighters.TYPES.BOXMODEL
            );
          });

          ancestorItem.addEventListener("mouseenter", async () => {
            const front = await getNodeFront();
            if (!front) {
              return;
            }

            await this.ruleView.inspector.highlighters.showHighlighterTypeForNode(
              this.ruleView.inspector.highlighters.TYPES.BOXMODEL,
              front
            );
          });
          ancestorItem.addEventListener("mouseleave", async () => {
            await this.ruleView.inspector.highlighters.hideHighlighterType(
              this.ruleView.inspector.highlighters.TYPES.BOXMODEL
            );
          });

          createChild(selectorContainer, "span", {
            // Add a space between the container name (or @container if there's no name)
            // and the query so the title, which is computed from the DOM, displays correctly.
            textContent: " " + ancestorData.containerQuery,
          });
        } else if (ancestorData.type == "layer") {
          selectorContainer.append(
            this.doc.createTextNode(
              `@layer${ancestorData.value ? " " + ancestorData.value : ""}`
            )
          );
        } else if (ancestorData.type == "media") {
          selectorContainer.append(
            this.doc.createTextNode(`@media ${ancestorData.value}`)
          );
        } else if (ancestorData.type == "supports") {
          selectorContainer.append(
            this.doc.createTextNode(`@supports ${ancestorData.conditionText}`)
          );
        } else if (ancestorData.type == "import") {
          selectorContainer.append(
            this.doc.createTextNode(`@import ${ancestorData.value}`)
          );
        } else if (ancestorData.type == "scope") {
          let text = `@scope`;
          if (ancestorData.start) {
            text += ` (${ancestorData.start})`;

            if (ancestorData.end) {
              text += ` to (${ancestorData.end})`;
            }
          }
          selectorContainer.append(this.doc.createTextNode(text));
        } else if (ancestorData.type == "starting-style") {
          selectorContainer.append(this.doc.createTextNode(`@starting-style`));
        } else if (ancestorData.selectors) {
          ancestorData.selectors.forEach((selector, i) => {
            if (i !== 0) {
              createChild(selectorContainer, "span", {
                class: "ruleview-selector-separator",
                textContent: ", ",
              });
            }

            const selectorEl = createChild(selectorContainer, "span", {
              class: "ruleview-selector",
              textContent: selector,
            });

            const warningsContainer = this._createWarningsElementForSelector(
              i,
              ancestorData.selectorWarnings
            );
            if (warningsContainer) {
              selectorEl.append(warningsContainer);
            }
          });
        } else {
          // We shouldn't get here as `type` should only match to what can be set in
          // the StyleRuleActor form, but just in case, let's return an empty string.
          console.warn("Unknown ancestor data type:", ancestorData.type);
          return;
        }

        createChild(ancestorItem, "span", {
          class: "ruleview-ancestor-ruleopen",
          textContent: " {",
        });
      });

      // We can't use a proper "ol" as it will mess with selection copy text,
      // adding spaces on list item instead of the one we craft (.ruleview-rule-indent)
      this.ancestorDataEl = createChild(this.element, "div", {
        class: "ruleview-rule-ancestor-data theme-link",
        role: "list",
      });
      this.ancestorDataEl.append(ancestorsFrag);
    }

    this.ruleviewCodeEl = createChild(this.element, "div", {
      class: "ruleview-code",
    });

    const header = createChild(this.ruleviewCodeEl, "div", {});

    createChild(header, "span", {
      class: "ruleview-rule-indent",
      textContent: INDENT_STR.repeat(this.rule.domRule.ancestorData.length),
    });

    this.selectorText = createChild(header, "span", {
      class: "ruleview-selectors-container",
      tabindex: this.isSelectorEditable ? "0" : "-1",
    });

    if (
      this.rule.domRule.type === ELEMENT_STYLE ||
      this.rule.domRule.type === PRES_HINTS
    ) {
      this.selectorText.classList.add("alternative-selector");
    }

    if (this.isSelectorEditable) {
      this.selectorText.addEventListener("click", event => {
        // Clicks within the selector shouldn't propagate any further.
        event.stopPropagation();
      });

      editableField({
        element: this.selectorText,
        done: this._onSelectorDone,
        cssProperties: this.rule.cssProperties,
        // (Shift+)Tab will move the focus to the previous/next editable field (so property name,
        // or new property of the previous rule).
        focusEditableFieldAfterApply: true,
        focusEditableFieldContainerSelector: ".ruleview-rule",
        // We don't want Enter to trigger the next editable field, just to validate
        // what the user entered, close the editor, and focus the span so the user can
        // navigate with the keyboard as expected, unless the user has
        // devtools.inspector.rule-view.focusNextOnEnter set to true
        stopOnReturn: this.ruleView.inplaceEditorFocusNextOnEnter !== true,
      });
    } else {
      this.selectorText.classList.add("uneditable-selector");
    }

    if (this.rule.domRule.type !== CSSRule.KEYFRAME_RULE) {
      // This is a "normal" rule with a selector.
      let computedSelector = "";
      if (this.rule.domRule.selectors) {
        computedSelector = this.rule.domRule.computedSelector;
        // Otherwise, the rule is either inherited or inline, and selectors will
        // be computed on demand when the highlighter is requested.
      }

      if (this.showSelectorHighlighterButton) {
        const isHighlighted =
          this.ruleView.isSelectorHighlighted(computedSelector);
        // Handling of click events is delegated to CssRuleView.handleEvent()
        createChild(header, "button", {
          class:
            "ruleview-selectorhighlighter js-toggle-selector-highlighter" +
            (isHighlighted ? " highlighted" : ""),
          "aria-pressed": isHighlighted,
          // This is used in rules.js for the selector highlighter
          "data-computed-selector": computedSelector,
          title: l10n("rule.selectorHighlighter.tooltip"),
        });
      }
    }

    this.openBrace = createChild(header, "span", {
      class: "ruleview-ruleopen",
      textContent: " {",
    });

    // We can't use a proper "ol" as it will mess with selection copy text,
    // adding spaces on list item instead of the one we craft (.ruleview-rule-indent)
    this.propertyList = createChild(this.ruleviewCodeEl, "div", {
      class: "ruleview-propertylist",
      role: "list",
    });

    this.populate();

    this.closeBrace = createChild(this.ruleviewCodeEl, "div", {
      class: "ruleview-ruleclose",
      tabindex: this.isEditable ? "0" : "-1",
    });

    if (this.rule.domRule.ancestorData.length) {
      createChild(this.closeBrace, "span", {
        class: "ruleview-rule-indent",
        textContent: INDENT_STR.repeat(this.rule.domRule.ancestorData.length),
      });
    }
    this.closeBrace.append(this.doc.createTextNode("}"));

    if (this.rule.domRule.ancestorData.length) {
      let closingBracketsText = "";
      for (let i = this.rule.domRule.ancestorData.length - 1; i >= 0; i--) {
        if (i) {
          closingBracketsText += INDENT_STR.repeat(i);
        }
        closingBracketsText += "}\n";
      }
      createChild(this.ruleviewCodeEl, "div", {
        class: "ruleview-ancestor-ruleclose",
        textContent: closingBracketsText,
      });
    }

    if (this.isEditable) {
      // A newProperty editor should only be created when no editor was
      // previously displayed. Since the editors are cleared on blur,
      // check this.ruleview.isEditing on mousedown
      this._ruleViewIsEditing = false;

      this.ruleviewCodeEl.addEventListener("mousedown", () => {
        this._ruleViewIsEditing = this.ruleView.isEditing;
      });

      this.ruleviewCodeEl.addEventListener("click", () => {
        const selection = this.doc.defaultView.getSelection();
        if (selection.isCollapsed && !this._ruleViewIsEditing) {
          this.newProperty();
        }
        // Cleanup the _ruleViewIsEditing flag
        this._ruleViewIsEditing = false;
      });

      this.element.addEventListener("mousedown", () => {
        this.doc.defaultView.focus();
      });

      // Create a property editor when the close brace is clicked.
      editableItem({ element: this.closeBrace }, () => {
        this.newProperty();
      });
    }
  },

  /**
   * Returns the selector warnings element, or null if selector at selectorIndex
   * does not have any warning.
   *
   * @param {Integer} selectorIndex: The index of the selector we want to create the
   *        warnings for
   * @param {Array<object>} selectorWarnings: An array of object of the following shape:
   *        - {Integer} index: The index of the selector this applies to
   *        - {String} kind: Identifies the warning
   * @returns {Element|null}
   */
  _createWarningsElementForSelector(selectorIndex, selectorWarnings) {
    if (!selectorWarnings) {
      return null;
    }

    const warningKinds = [];
    for (const { index, kind } of selectorWarnings) {
      if (index !== selectorIndex) {
        continue;
      }
      warningKinds.push(kind);
    }

    if (!warningKinds.length) {
      return null;
    }

    const warningsContainer = this.doc.createElement("div");
    warningsContainer.classList.add(
      "ruleview-selector-warnings",
      "has-tooltip"
    );

    warningsContainer.setAttribute(
      "data-selector-warning-kind",
      warningKinds.join(",")
    );

    if (warningKinds.includes("UnconstrainedHas")) {
      warningsContainer.classList.add("slow");
    }

    return warningsContainer;
  },

  /**
   * Called when a tool is registered or unregistered.
   */
  _onToolChanged() {
    if (!this.source) {
      return;
    }

    // When the source editor is registered, update the source links
    // to be clickable; and if it is unregistered, update the links to
    // be unclickable.
    if (this.toolbox.isToolRegistered("styleeditor")) {
      this.source.removeAttribute("unselectable");
    } else {
      this.source.setAttribute("unselectable", "true");
    }
  },

  /**
   * Event handler called when a property changes on the
   * StyleRuleActor.
   */
  _locationChanged() {
    this.updateSourceLink();
  },

  _onSourceClick(e) {
    e.preventDefault();
    if (this.source.hasAttribute("unselectable")) {
      return;
    }

    const { inspector } = this.ruleView;
    if (Tools.styleEditor.isToolSupported(inspector.toolbox)) {
      inspector.toolbox.viewSourceInStyleEditorByResource(
        this.rule.sheet,
        this.rule.ruleLine,
        this.rule.ruleColumn
      );
    }
  },

  /**
   * Update the text of the source link to reflect whether we're showing
   * original sources or not.  This is a callback for
   * SourceMapURLService.subscribeByID, which see.
   *
   * @param {object | null} originalLocation
   *        The original position object (url/line/column) or null.
   */
  _updateLocation(originalLocation) {
    let displayURL = this.rule.sheet?.href;
    const constructed = this.rule.sheet?.constructed;
    let line = this.rule.ruleLine;
    if (originalLocation) {
      displayURL = originalLocation.url;
      line = originalLocation.line;
    }

    let sourceTextContent = CssLogic.shortSource({
      constructed,
      href: displayURL,
    });

    let displayLocation = displayURL ? displayURL : sourceTextContent;
    if (line > 0) {
      sourceTextContent += ":" + line;
      displayLocation += ":" + line;
    }
    const title = COMPONENT_L10N.getFormatStr(
      "frame.viewsourceinstyleeditor",
      displayLocation
    );

    const sourceLabel = this.element.querySelector(
      ".ruleview-rule-source-label"
    );
    sourceLabel.setAttribute("title", title);
    sourceLabel.setAttribute("href", displayURL);
    sourceLabel.textContent = sourceTextContent;
  },

  updateSourceLink() {
    if (this.source) {
      if (this.rule.isSystem) {
        const sourceLabel = this.element.querySelector(
          ".ruleview-rule-source-label"
        );
        const uaLabel = STYLE_INSPECTOR_L10N.getStr("rule.userAgentStyles");
        sourceLabel.textContent = uaLabel + " " + this.rule.title;
        sourceLabel.setAttribute("href", this.rule.sheet?.href);
      } else {
        this._updateLocation(null);
      }

      if (this.rule.sheet && !this.rule.isSystem) {
        // Only get the original source link if the rule isn't a system
        // rule and if it isn't an inline rule.
        if (this._unsubscribeSourceMap) {
          this._unsubscribeSourceMap();
        }
        this._unsubscribeSourceMap = this.sourceMapURLService.subscribeByID(
          this.rule.sheet.resourceId,
          this.rule.ruleLine,
          this.rule.ruleColumn,
          this._updateLocation
        );
      }
      // Set "unselectable" appropriately.
      this._onToolChanged();
    }

    Promise.resolve().then(() => {
      this.emit("source-link-updated");
    });
  },

  /**
   * Update the rule editor with the contents of the rule.
   *
   * @param {boolean} reset
   *        True to completely reset the rule editor before populating.
   */
  populate(reset) {
    // Clear out existing viewers.
    this.selectorText.replaceChildren();

    // If selector text comes from a css rule, highlight selectors that
    // actually match.  For custom selector text (such as for the 'element'
    // style, just show the text directly.
    if (
      this.rule.domRule.type === ELEMENT_STYLE ||
      this.rule.domRule.type === PRES_HINTS
    ) {
      this.selectorText.textContent = this.rule.selectorText;
    } else if (this.rule.domRule.type === CSSRule.KEYFRAME_RULE) {
      this.selectorText.textContent = this.rule.domRule.keyText;
    } else if (this.rule.domRule.className === "CSSPositionTryRule") {
      this.selectorText.textContent = this.rule.domRule.name;
    } else {
      this.rule.domRule.selectors.forEach((selector, i) => {
        this._populateSelector(selector, i);
      });
    }

    let focusedElSelector;
    if (reset) {
      // If we're going to reset the rule (i.e. if this is the `element` rule),
      // we want to restore the focus after the rule is populated.
      // So if this element contains the active element, retrieve its selector for later use.
      if (this.element.contains(this.doc.activeElement)) {
        focusedElSelector = CssLogic.findCssSelector(this.doc.activeElement);
      }

      this.propertyList.replaceChildren();
    }

    this._unusedCssVariableDeclarations =
      this._getUnusedCssVariableDeclarations();
    const hideUnusedCssVariableDeclarations =
      this._unusedCssVariableDeclarations.size >=
      // If the button was already displayed, hide unused variables if we have at least
      // one, even if it's less than the threshold
      (this._showUnusedCustomCssPropertiesButton
        ? 1
        : UNUSED_CSS_PROPERTIES_HIDE_THRESHOLD);

    // If we won't hide any variable, clear the Set of unused variables as it's used in
    // updateUnusedCssVariables and we might do unnecessary computation if we still
    // track variables which are actually visible.
    if (!hideUnusedCssVariableDeclarations) {
      this._unusedCssVariableDeclarations.clear();
    }

    for (const prop of this.rule.textProps) {
      if (hideUnusedCssVariableDeclarations && prop.isUnusedVariable) {
        continue;
      }
      if (!prop.editor && !prop.invisible) {
        const editor = new TextPropertyEditor(this, prop, {
          elementsWithPendingClicks: this.options.elementsWithPendingClicks,
        });
        this.propertyList.appendChild(editor.element);
      } else if (prop.editor) {
        // If an editor already existed, append it to the bottom now to make sure the
        // order of editors in the DOM follow the order of the rule's properties.
        this.propertyList.appendChild(prop.editor.element);
      }
    }

    if (hideUnusedCssVariableDeclarations) {
      if (!this._showUnusedCustomCssPropertiesButton) {
        this._showUnusedCustomCssPropertiesButton =
          this.doc.createElement("button");
        this._showUnusedCustomCssPropertiesButton.classList.add(
          "devtools-button",
          "devtools-button-standalone",
          "ruleview-show-unused-custom-css-properties"
        );
        this._showUnusedCustomCssPropertiesButton.addEventListener(
          "click",
          this._onShowUnusedCustomCssPropertiesButtonClick
        );
      }
      this.ruleviewCodeEl.insertBefore(
        this._showUnusedCustomCssPropertiesButton,
        this.closeBrace
      );
      this._updateShowUnusedCustomCssPropertiesButtonText();
    } else if (this._showUnusedCustomCssPropertiesButton) {
      this._nullifyShowUnusedCustomCssProperties();
    }

    // Set focus if the focus is still in the current document (avoid stealing
    // the focus, see Bug 1911627).
    if (this.doc.hasFocus() && focusedElSelector) {
      const elementToFocus = this.doc.querySelector(focusedElSelector);
      if (elementToFocus && this.element.contains(elementToFocus)) {
        // We need to wait for a tick for the focus to be properly set
        setTimeout(() => {
          elementToFocus.focus();
          this.ruleView.emitForTests("rule-editor-focus-reset");
        }, 0);
      }
    }
  },

  updateUnusedCssVariables() {
    if (
      !this._unusedCssVariableDeclarations ||
      !this._unusedCssVariableDeclarations.size
    ) {
      return;
    }

    // Store the list of what used to be unused
    const previouslyUnused = Array.from(this._unusedCssVariableDeclarations);
    // Then compute the list of unused variables again
    this._unusedCssVariableDeclarations =
      this._getUnusedCssVariableDeclarations();

    for (const prop of previouslyUnused) {
      if (this._unusedCssVariableDeclarations.has(prop)) {
        continue;
      }

      // The prop wasn't used, but now is, so let's show it
      this.showUnusedCssVariable(prop, {
        updateButton: false,
      });
    }

    this._updateShowUnusedCustomCssPropertiesButtonText();
  },

  /**
   * Create a TextPropertyEditor for TextProperty representing an unused CSS variable.
   *
   * @param {TextProperty} prop
   * @param {object} options
   * @param {boolean} options.updateButton
   * @returns {TextPropertyEditor|null} Returns null if passed TextProperty isn't found
   *          in the list of unused css variables
   */
  showUnusedCssVariable(prop, { updateButton = true } = {}) {
    if (prop.editor) {
      return null;
    }

    this._unusedCssVariableDeclarations.delete(prop);

    const editor = new TextPropertyEditor(this, prop, {
      elementsWithPendingClicks: this.options.elementsWithPendingClicks,
    });
    const declarationIndex = this.rule.textProps.indexOf(prop);
    // We need to insert the editor according to its index in the list of declarations.
    // So let's try to find the prop which is placed higher and is visible
    let nextSibling;
    for (let i = declarationIndex + 1; i < this.rule.textProps.length; i++) {
      const currentProp = this.rule.textProps[i];
      if (currentProp.editor) {
        nextSibling = currentProp.editor.element;
        break;
      }
    }
    // If we couldn't find nextSibling, that means that no declaration with higher index
    // is visible, so we can put the newly visible property at the end
    this.propertyList.insertBefore(editor.element, nextSibling || null);

    if (updateButton) {
      this._updateShowUnusedCustomCssPropertiesButtonText();
    }

    return editor;
  },

  /**
   * Returns a Set containing the list of unused CSS variable TextProperty which shouldn't
   * be visible.
   *
   * @returns {Set<TextProperty>}
   */
  _getUnusedCssVariableDeclarations() {
    const unusedCssVariableDeclarations = new Set();

    // No need to go through the declarations if we shouldn't hide unused custom properties
    if (!this.options.shouldHideUnusedCustomCssProperties) {
      return unusedCssVariableDeclarations;
    }

    // Compute a list of variables that will be visible, as there might be unused variables
    // that will be visible (e.g. if the user added one in the rules view)
    for (const prop of this.rule.textProps) {
      if (prop.isUnusedVariable) {
        unusedCssVariableDeclarations.add(prop);
      }
    }

    return unusedCssVariableDeclarations;
  },

  /**
   * Handle click on "Show X unused custom CSS properties" button
   *
   * @param {Event} e
   */
  _onShowUnusedCustomCssPropertiesButtonClick(e) {
    e.stopPropagation();

    this._nullifyShowUnusedCustomCssProperties();

    for (const prop of this._unusedCssVariableDeclarations) {
      if (!prop.invisible) {
        const editor = new TextPropertyEditor(this, prop, {
          elementsWithPendingClicks: this.options.elementsWithPendingClicks,
        });
        // Insert at the original declaration index
        this.propertyList.insertBefore(
          editor.element,
          this.propertyList.childNodes[this.rule.textProps.indexOf(prop)] ||
            null
        );
      }
    }
    if (typeof this.options.onShowUnusedCustomCssProperties === "function") {
      this.options.onShowUnusedCustomCssProperties();
    }
  },

  /**
   * Update the text for the "Show X unused custom CSS properties" button, or remove it
   * if there's no hidden custom properties anymore
   */
  _updateShowUnusedCustomCssPropertiesButtonText() {
    if (!this._showUnusedCustomCssPropertiesButton) {
      return;
    }

    const unusedVariablesCount = this._unusedCssVariableDeclarations.size;
    if (!unusedVariablesCount) {
      this._nullifyShowUnusedCustomCssProperties();
      return;
    }

    const label = PluralForm.get(
      unusedVariablesCount,
      STYLE_INSPECTOR_L10N.getStr("rule.showUnusedCssVariable")
    ).replace("#1", unusedVariablesCount);

    this._showUnusedCustomCssPropertiesButton.replaceChildren(label);
  },

  /**
   * Nullify this._showUnusedCustomCssPropertiesButton, remove its click event handler
   * and remove it from the DOM if `removeFromDom` is set to true.
   *
   * @param {object} [options]
   * @param {boolean} [options.removeFromDom]
   *        Should the button be removed from the DOM (defaults to true)
   */
  _nullifyShowUnusedCustomCssProperties({ removeFromDom = true } = {}) {
    if (!this._showUnusedCustomCssPropertiesButton) {
      return;
    }

    this._showUnusedCustomCssPropertiesButton.removeEventListener(
      "click",
      this._onShowUnusedCustomCssPropertiesButtonClick
    );

    if (removeFromDom) {
      this._showUnusedCustomCssPropertiesButton.remove();
    }
    this._showUnusedCustomCssPropertiesButton = null;
  },

  /**
   * Render a given rule selector in this.selectorText element
   *
   * @param {string} selector: The selector text to display
   * @param {number} selectorIndex: Its index in the rule
   */
  _populateSelector(selector, selectorIndex) {
    if (selectorIndex !== 0) {
      createChild(this.selectorText, "span", {
        class: "ruleview-selector-separator",
        textContent: ", ",
      });
    }

    const containerClass =
      "ruleview-selector " +
      (this.rule.matchedSelectorIndexes.includes(selectorIndex)
        ? "matched"
        : "unmatched");

    let selectorContainerTitle;
    if (
      typeof this.rule.selector.selectorsSpecificity?.[selectorIndex] !==
      "undefined"
    ) {
      // The specificity that we get from the platform is a single number that we
      // need to format into the common `(x,y,z)` specificity string.
      const specificity =
        this.rule.selector.selectorsSpecificity?.[selectorIndex];
      const a = Math.floor(specificity / (1024 * 1024));
      const b = Math.floor((specificity % (1024 * 1024)) / 1024);
      const c = specificity % 1024;
      selectorContainerTitle = STYLE_INSPECTOR_L10N.getFormatStr(
        "rule.selectorSpecificity.title",
        `(${a},${b},${c})`
      );
    }
    const selectorContainer = createChild(this.selectorText, "span", {
      class: containerClass,
      title: selectorContainerTitle,
    });

    const parsedSelector = parsePseudoClassesAndAttributes(selector);

    for (const selectorText of parsedSelector) {
      let selectorClass = "";

      switch (selectorText.type) {
        case SELECTOR_ATTRIBUTE:
          selectorClass = "ruleview-selector-attribute";
          break;
        case SELECTOR_ELEMENT:
          selectorClass = "ruleview-selector-element";
          break;
        case SELECTOR_PSEUDO_CLASS:
          selectorClass = PSEUDO_CLASSES.some(
            pseudo => selectorText.value === pseudo
          )
            ? "ruleview-selector-pseudo-class-lock"
            : "ruleview-selector-pseudo-class";
          break;
        default:
          break;
      }

      createChild(selectorContainer, "span", {
        textContent: selectorText.value,
        class: selectorClass,
      });
    }

    const warningsContainer = this._createWarningsElementForSelector(
      selectorIndex,
      this.rule.domRule.selectorWarnings
    );
    if (warningsContainer) {
      selectorContainer.append(warningsContainer);
    }
  },

  /**
   * Programatically add a new property to the rule.
   *
   * @param {string} name
   *        Property name.
   * @param {string} value
   *        Property value.
   * @param {string} priority
   *        Property priority.
   * @param {boolean} enabled
   *        True if the property should be enabled.
   * @param {TextProperty} siblingProp
   *        Optional, property next to which the new property will be added.
   * @return {TextProperty}
   *        The new property
   */
  addProperty(name, value, priority, enabled, siblingProp) {
    const prop = this.rule.createProperty(
      name,
      value,
      priority,
      enabled,
      siblingProp
    );
    const index = this.rule.textProps.indexOf(prop);
    const editor = new TextPropertyEditor(this, prop, {
      elementsWithPendingClicks: this.options.elementsWithPendingClicks,
    });

    // Insert this node before the DOM node that is currently at its new index
    // in the property list.  There is currently one less node in the DOM than
    // in the property list, so this causes it to appear after siblingProp.
    // If there is no node at its index, as is the case where this is the last
    // node being inserted, then this behaves as appendChild.
    this.propertyList.insertBefore(
      editor.element,
      this.propertyList.children[index]
    );

    return prop;
  },

  /**
   * Programatically add a list of new properties to the rule.  Focus the UI
   * to the proper location after adding (either focus the value on the
   * last property if it is empty, or create a new property and focus it).
   *
   * @param {Array} properties
   *        Array of properties, which are objects with this signature:
   *        {
   *          name: {string},
   *          value: {string},
   *          priority: {string}
   *        }
   * @param {TextProperty} siblingProp
   *        Optional, the property next to which all new props should be added.
   */
  addProperties(properties, siblingProp) {
    if (!properties || !properties.length) {
      return;
    }

    let lastProp = siblingProp;
    for (const p of properties) {
      const isCommented = Boolean(p.commentOffsets);
      const enabled = !isCommented;
      lastProp = this.addProperty(
        p.name,
        p.value,
        p.priority,
        enabled,
        lastProp
      );
    }

    // Either focus on the last value if incomplete, or start a new one.
    if (lastProp && lastProp.value.trim() === "") {
      lastProp.editor.valueSpan.click();
    } else {
      this.newProperty();
    }
  },

  /**
   * Create a text input for a property name.  If a non-empty property
   * name is given, we'll create a real TextProperty and add it to the
   * rule.
   */
  newProperty() {
    // If we're already creating a new property, ignore this.
    if (!this.closeBrace.hasAttribute("tabindex")) {
      return;
    }

    // While we're editing a new property, it doesn't make sense to start a second new
    // property editor, so disable focusing the close brace for now.
    this.closeBrace.removeAttribute("tabindex");
    // We also need to make the "Show Unused Variables" button non-focusable so hitting
    // Tab while focused in the new property editor will move the focus to the next rule
    // selector editor.
    if (this._showUnusedCustomCssPropertiesButton) {
      this._showUnusedCustomCssPropertiesButton.setAttribute("tabindex", "-1");
    }

    this.newPropItem = createChild(this.propertyList, "div", {
      class: "ruleview-property ruleview-newproperty",
      role: "listitem",
    });

    this.newPropSpan = createChild(this.newPropItem, "span", {
      class: "ruleview-propertyname",
      tabindex: "0",
    });

    this.multipleAddedProperties = null;

    this.editor = new InplaceEditor({
      element: this.newPropSpan,
      done: this._onNewProperty,
      // (Shift+)Tab will move the focus to the previous/next editable field
      focusEditableFieldAfterApply: true,
      focusEditableFieldContainerSelector: ".ruleview-rule",
      destroy: this._newPropertyDestroy,
      advanceChars: ":",
      contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY,
      popup: this.ruleView.popup,
      cssProperties: this.rule.cssProperties,
      inputAriaLabel: NEW_PROPERTY_NAME_INPUT_LABEL,
      getCssVariables: () =>
        this.rule.elementStyle.getAllCustomProperties(this.rule.pseudoElement),
    });

    // Auto-close the input if multiple rules get pasted into new property.
    this.editor.input.addEventListener(
      "paste",
      blurOnMultipleProperties(this.rule.cssProperties)
    );
  },

  /**
   * Called when the new property input has been dismissed.
   *
   * @param {string} value
   *        The value in the editor.
   * @param {boolean} commit
   *        True if the value should be committed.
   */
  _onNewProperty(value, commit) {
    if (!value || !commit) {
      return;
    }

    // parseDeclarations allows for name-less declarations, but in the present
    // case, we're creating a new declaration, it doesn't make sense to accept
    // these entries
    this.multipleAddedProperties = parseNamedDeclarations(
      this.rule.cssProperties.isKnown,
      value,
      true
    );

    // Blur the editor field now and deal with adding declarations later when
    // the field gets destroyed (see _newPropertyDestroy)
    this.editor.input.blur();

    this.telemetry.recordEvent("edit_rule", "ruleview");
  },

  /**
   * Called when the new property editor is destroyed.
   * This is where the properties (type TextProperty) are actually being
   * added, since we want to wait until after the inplace editor `destroy`
   * event has been fired to keep consistent UI state.
   */
  _newPropertyDestroy() {
    // We're done, make the close brace and "Show unused variable" button focusable again.
    this.closeBrace.setAttribute("tabindex", "0");
    if (this._showUnusedCustomCssPropertiesButton) {
      this._showUnusedCustomCssPropertiesButton.removeAttribute("tabindex");
    }

    this.propertyList.removeChild(this.newPropItem);
    delete this.newPropItem;
    delete this.newPropSpan;

    // If properties were added, we want to focus the proper element.
    // If the last new property has no value, focus the value on it.
    // Otherwise, start a new property and focus that field.
    if (this.multipleAddedProperties && this.multipleAddedProperties.length) {
      this.addProperties(this.multipleAddedProperties);
    }
  },

  /**
   * Called when the selector's inplace editor is closed.
   * Ignores the change if the user pressed escape, otherwise
   * commits it.
   *
   * @param {string} value
   *        The value contained in the editor.
   * @param {boolean} commit
   *        True if the change should be applied.
   * @param {number} direction
   *        The move focus direction number.
   */
  async _onSelectorDone(value, commit, direction) {
    if (
      !commit ||
      this.isEditing ||
      value === "" ||
      value === this.rule.selectorText
    ) {
      return;
    }

    const ruleView = this.ruleView;
    const elementStyle = ruleView._elementStyle;
    const element = elementStyle.element;

    this.isEditing = true;

    // Remove highlighter for the previous selector.
    const computedSelector = this.rule.domRule.computedSelector;
    if (this.ruleView.isSelectorHighlighted(computedSelector)) {
      await this.ruleView.toggleSelectorHighlighter(
        this.rule,
        computedSelector
      );
    }

    try {
      const response = await this.rule.domRule.modifySelector(element, value);

      // Modifying the selector might have removed the element (e.g. for pseudo element)
      if (!element.actorID) {
        return;
      }

      // We recompute the list of applied styles, because editing a
      // selector might cause this rule's position to change.
      const applied = await elementStyle.pageStyle.getApplied(element, {
        inherited: true,
        matchedSelectors: true,
        filter: elementStyle.showUserAgentStyles ? "ua" : undefined,
      });

      // The element might have been removed while we were trying to get the applied declarations
      if (!element.actorID) {
        return;
      }

      this.isEditing = false;

      const { ruleProps, isMatching } = response;
      if (!ruleProps) {
        // Notify for changes, even when nothing changes,
        // just to allow tests being able to track end of this request.
        ruleView.emit("ruleview-invalid-selector");
        return;
      }

      ruleProps.isUnmatched = !isMatching;
      const newRule = new Rule(elementStyle, ruleProps);
      const editor = new RuleEditor(ruleView, newRule);
      const rules = elementStyle.rules;

      let newRuleIndex = applied.findIndex(r => r.rule == ruleProps.rule);
      const oldIndex = rules.indexOf(this.rule);

      // If the selector no longer matches, then we leave the rule in
      // the same relative position.
      if (newRuleIndex === -1) {
        newRuleIndex = oldIndex;
      }

      // Remove the old rule and insert the new rule.
      rules.splice(oldIndex, 1);
      rules.splice(newRuleIndex, 0, newRule);
      elementStyle._changed();
      elementStyle.onRuleUpdated();

      // We install the new editor in place of the old -- you might
      // think we would replicate the list-modification logic above,
      // but that is complicated due to the way the UI installs
      // pseudo-element rules and the like.
      this.element.parentNode.replaceChild(editor.element, this.element);

      // As the rules elements will be replaced, and given that the inplace-editor doesn't
      // wait for this `done` callback to be resolved, the focus management we do there
      // will be useless as this specific code will usually happen later (and the focused
      // element might be replaced).
      // Because of this, we need to handle setting the focus ourselves from here.
      editor._moveSelectorFocus(direction);
    } catch (err) {
      this.isEditing = false;
      promiseWarn(err);
    }
  },

  /**
   * Handle moving the focus change after a Tab keypress in the selector inplace editor.
   *
   * @param {number} direction
   *        The move focus direction number.
   */
  _moveSelectorFocus(direction) {
    if (!direction || direction === Services.focus.MOVEFOCUS_BACKWARD) {
      return;
    }

    if (this.rule.textProps.length) {
      this.rule.textProps[0].editor.nameSpan.click();
    } else {
      this.propertyList.click();
    }
  },
};

module.exports = RuleEditor;
