/* 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 flags = require("resource://devtools/shared/flags.js");
const { l10n } = require("resource://devtools/shared/inspector/css-logic.js");
const {
  style: { ELEMENT_STYLE },
} = require("resource://devtools/shared/constants.js");
const {
  PSEUDO_CLASSES,
} = require("resource://devtools/shared/css/constants.js");
const OutputParser = require("resource://devtools/client/shared/output-parser.js");
const { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
const ElementStyle = require("resource://devtools/client/inspector/rules/models/element-style.js");
const RuleEditor = require("resource://devtools/client/inspector/rules/views/rule-editor.js");
const RegisteredPropertyEditor = require("resource://devtools/client/inspector/rules/views/registered-property-editor.js");
const TooltipsOverlay = require("resource://devtools/client/inspector/shared/tooltips-overlay.js");
const {
  createChild,
  promiseWarn,
} = require("resource://devtools/client/inspector/shared/utils.js");
const { debounce } = require("resource://devtools/shared/debounce.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");

loader.lazyRequireGetter(
  this,
  ["flashElementOn", "flashElementOff"],
  "resource://devtools/client/inspector/markup/utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "ClassListPreviewer",
  "resource://devtools/client/inspector/rules/views/class-list-previewer.js"
);
loader.lazyRequireGetter(
  this,
  ["getNodeInfo", "getNodeCompatibilityInfo", "getRuleFromNode"],
  "resource://devtools/client/inspector/rules/utils/utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "StyleInspectorMenu",
  "resource://devtools/client/inspector/shared/style-inspector-menu.js"
);
loader.lazyRequireGetter(
  this,
  "AutocompletePopup",
  "resource://devtools/client/shared/autocomplete-popup.js"
);
loader.lazyRequireGetter(
  this,
  "KeyShortcuts",
  "resource://devtools/client/shared/key-shortcuts.js"
);
loader.lazyRequireGetter(
  this,
  "clipboardHelper",
  "resource://devtools/shared/platform/clipboard.js"
);
loader.lazyRequireGetter(
  this,
  "openContentLink",
  "resource://devtools/client/shared/link.js",
  true
);

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
});

const HTML_NS = "http://www.w3.org/1999/xhtml";
const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles";
const PREF_DEFAULT_COLOR_UNIT = "devtools.defaultColorUnit";
const PREF_DRAGGABLE = "devtools.inspector.draggable_properties";
const PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER =
  "devtools.inspector.rule-view.focusNextOnEnter";
const FILTER_CHANGED_TIMEOUT = 150;
// Removes the flash-out class from an element after 1 second (100ms in tests so they
// don't take too long to run).
const PROPERTY_FLASHING_DURATION = flags.testing ? 100 : 1000;

// This is used to parse user input when filtering.
const FILTER_PROP_RE = /\s*([^:\s]*)\s*:\s*(.*?)\s*;?$/;
// This is used to parse the filter search value to see if the filter
// should be strict or not
const FILTER_STRICT_RE = /\s*`(.*?)`\s*$/;

const RULE_VIEW_HEADER_CLASSNAME = "ruleview-header";
const PSEUDO_ELEMENTS_CONTAINER_ID = "pseudo-elements-container";
const REGISTERED_PROPERTIES_CONTAINER_ID = "registered-properties-container";

/**
 * Our model looks like this:
 *
 * ElementStyle:
 *   Responsible for keeping track of which properties are overridden.
 *   Maintains a list of Rule objects that apply to the element.
 * Rule:
 *   Manages a single style declaration or rule.
 *   Responsible for applying changes to the properties in a rule.
 *   Maintains a list of TextProperty objects.
 * TextProperty:
 *   Manages a single property from the authoredText attribute of the
 *     relevant declaration.
 *   Maintains a list of computed properties that come from this
 *     property declaration.
 *   Changes to the TextProperty are sent to its related Rule for
 *     application.
 *
 * View hierarchy mostly follows the model hierarchy.
 *
 * CssRuleView:
 *   Owns an ElementStyle and creates a list of RuleEditors for its
 *    Rules.
 * RuleEditor:
 *   Owns a Rule object and creates a list of TextPropertyEditors
 *     for its TextProperties.
 *   Manages creation of new text properties.
 * TextPropertyEditor:
 *   Owns a TextProperty object.
 *   Manages changes to the TextProperty.
 *   Can be expanded to display computed properties.
 *   Can mark a property disabled or enabled.
 */

/**
 * CssRuleView is a view of the style rules and declarations that
 * apply to a given element.  After construction, the 'element'
 * property will be available with the user interface.
 *
 * @param {Inspector} inspector
 *        Inspector toolbox panel
 * @param {Document} document
 *        The document that will contain the rule view.
 * @param {Object} store
 *        The CSS rule view can use this object to store metadata
 *        that might outlast the rule view, particularly the current
 *        set of disabled properties.
 */
function CssRuleView(inspector, document, store) {
  EventEmitter.decorate(this);

  this.inspector = inspector;
  this.cssProperties = inspector.cssProperties;
  this.styleDocument = document;
  this.styleWindow = this.styleDocument.defaultView;
  this.store = store || {
    expandedUnusedCustomCssPropertiesRuleActorIds: new Set(),
  };

  // Allow tests to override debouncing behavior, as this can cause intermittents.
  this.debounce = debounce;

  // Variable used to stop the propagation of mouse events to children
  // when we are updating a value by dragging the mouse and we then release it
  this.childHasDragged = false;

  this._outputParser = new OutputParser(document, this.cssProperties);
  this._abortController = new this.styleWindow.AbortController();

  this._onAddRule = this._onAddRule.bind(this);
  this._onContextMenu = this._onContextMenu.bind(this);
  this._onCopy = this._onCopy.bind(this);
  this._onFilterStyles = this._onFilterStyles.bind(this);
  this._onClearSearch = this._onClearSearch.bind(this);
  this._onTogglePseudoClassPanel = this._onTogglePseudoClassPanel.bind(this);
  this._onTogglePseudoClass = this._onTogglePseudoClass.bind(this);
  this._onToggleClassPanel = this._onToggleClassPanel.bind(this);
  this._onToggleLightColorSchemeSimulation =
    this._onToggleLightColorSchemeSimulation.bind(this);
  this._onToggleDarkColorSchemeSimulation =
    this._onToggleDarkColorSchemeSimulation.bind(this);
  this._onTogglePrintSimulation = this._onTogglePrintSimulation.bind(this);
  this.highlightProperty = this.highlightProperty.bind(this);
  this.refreshPanel = this.refreshPanel.bind(this);

  const doc = this.styleDocument;
  // Delegate bulk handling of events happening within the DOM tree of the Rules view
  // to this.handleEvent(). Listening on the capture phase of the event bubbling to be
  // able to stop event propagation on a case-by-case basis and prevent event target
  // ancestor nodes from handling them.
  this.styleDocument.addEventListener("click", this, { capture: true });
  this.element = doc.getElementById("ruleview-container-focusable");
  this.addRuleButton = doc.getElementById("ruleview-add-rule-button");
  this.searchField = doc.getElementById("ruleview-searchbox");
  this.searchClearButton = doc.getElementById("ruleview-searchinput-clear");
  this.pseudoClassPanel = doc.getElementById("pseudo-class-panel");
  this.pseudoClassToggle = doc.getElementById("pseudo-class-panel-toggle");
  this.classPanel = doc.getElementById("ruleview-class-panel");
  this.classToggle = doc.getElementById("class-panel-toggle");
  this.colorSchemeLightSimulationButton = doc.getElementById(
    "color-scheme-simulation-light-toggle"
  );
  this.colorSchemeDarkSimulationButton = doc.getElementById(
    "color-scheme-simulation-dark-toggle"
  );
  this.printSimulationButton = doc.getElementById("print-simulation-toggle");

  this._initSimulationFeatures();

  this.searchClearButton.hidden = true;

  this.onHighlighterShown = data =>
    this.handleHighlighterEvent("highlighter-shown", data);
  this.onHighlighterHidden = data =>
    this.handleHighlighterEvent("highlighter-hidden", data);
  this.inspector.highlighters.on("highlighter-shown", this.onHighlighterShown);
  this.inspector.highlighters.on(
    "highlighter-hidden",
    this.onHighlighterHidden
  );

  this.shortcuts = new KeyShortcuts({ window: this.styleWindow });
  this._onShortcut = this._onShortcut.bind(this);
  this.shortcuts.on("Escape", event => this._onShortcut("Escape", event));
  this.shortcuts.on("Return", event => this._onShortcut("Return", event));
  this.shortcuts.on("Space", event => this._onShortcut("Space", event));
  this.shortcuts.on("CmdOrCtrl+F", event =>
    this._onShortcut("CmdOrCtrl+F", event)
  );
  this.element.addEventListener("copy", this._onCopy);
  this.element.addEventListener("contextmenu", this._onContextMenu);
  this.addRuleButton.addEventListener("click", this._onAddRule);
  this.searchField.addEventListener("input", this._onFilterStyles);
  this.searchClearButton.addEventListener("click", this._onClearSearch);
  this.pseudoClassToggle.addEventListener(
    "click",
    this._onTogglePseudoClassPanel
  );
  this.classToggle.addEventListener("click", this._onToggleClassPanel);
  // The "change" event bubbles up from checkbox inputs nested within the panel container.
  this.pseudoClassPanel.addEventListener("change", this._onTogglePseudoClass);

  if (flags.testing) {
    // In tests, we start listening immediately to avoid having to simulate a mousemove.
    this.highlighters.addToView(this);
  } else {
    this.element.addEventListener(
      "mousemove",
      () => {
        this.highlighters.addToView(this);
      },
      { once: true }
    );
  }

  this._handlePrefChange = this._handlePrefChange.bind(this);
  this._handleUAStylePrefChange = this._handleUAStylePrefChange.bind(this);
  this._handleDefaultColorUnitPrefChange =
    this._handleDefaultColorUnitPrefChange.bind(this);
  this._handleDraggablePrefChange = this._handleDraggablePrefChange.bind(this);
  this._handleInplaceEditorFocusNextOnEnterPrefChange =
    this._handleInplaceEditorFocusNextOnEnterPrefChange.bind(this);

  this._prefObserver = new PrefObserver("devtools.");
  this._prefObserver.on(PREF_UA_STYLES, this._handleUAStylePrefChange);
  this._prefObserver.on(
    PREF_DEFAULT_COLOR_UNIT,
    this._handleDefaultColorUnitPrefChange
  );
  this._prefObserver.on(PREF_DRAGGABLE, this._handleDraggablePrefChange);
  // Initialize value of this.draggablePropertiesEnabled
  this._handleDraggablePrefChange();

  this._prefObserver.on(
    PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER,
    this._handleInplaceEditorFocusNextOnEnterPrefChange
  );
  // Initialize value of this.inplaceEditorFocusNextOnEnter
  this._handleInplaceEditorFocusNextOnEnterPrefChange();

  this.pseudoClassCheckboxes = this._createPseudoClassCheckboxes();
  this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES);

  // Add the tooltips and highlighters to the view
  this.tooltips = new TooltipsOverlay(this);

  this.cssRegisteredPropertiesByTarget = new Map();
  this._elementsWithPendingClicks = new this.styleWindow.WeakSet();
}

CssRuleView.prototype = {
  // The element that we're inspecting.
  _viewedElement: null,

  // Used for cancelling timeouts in the style filter.
  _filterChangedTimeout: null,

  // Empty, unconnected element of the same type as this node, used
  // to figure out how shorthand properties will be parsed.
  _dummyElement: null,

  get popup() {
    if (!this._popup) {
      // The popup will be attached to the toolbox document.
      this._popup = new AutocompletePopup(this.inspector.toolbox.doc, {
        autoSelect: true,
      });
    }

    return this._popup;
  },

  get classListPreviewer() {
    if (!this._classListPreviewer) {
      this._classListPreviewer = new ClassListPreviewer(
        this.inspector,
        this.classPanel
      );
    }

    return this._classListPreviewer;
  },

  get contextMenu() {
    if (!this._contextMenu) {
      this._contextMenu = new StyleInspectorMenu(this, { isRuleView: true });
    }

    return this._contextMenu;
  },

  // Get the dummy elemenet.
  get dummyElement() {
    return this._dummyElement;
  },

  // Get the highlighters overlay from the Inspector.
  get highlighters() {
    if (!this._highlighters) {
      // highlighters is a lazy getter in the inspector.
      this._highlighters = this.inspector.highlighters;
    }

    return this._highlighters;
  },

  // Get the filter search value.
  get searchValue() {
    return this.searchField.value.toLowerCase();
  },

  get rules() {
    return this._elementStyle ? this._elementStyle.rules : [];
  },

  get currentTarget() {
    return this.inspector.toolbox.target;
  },

  /**
   * Highlight/unhighlight all the nodes that match a given rule's selector
   * inside the document of the current selected node.
   * Only one selector can be highlighted at a time, so calling the method a
   * second time with a different rule will first unhighlight the previously
   * highlighted nodes.
   * Calling the method a second time with the same rule will just
   * unhighlight the highlighted nodes.
   *
   * @param {Rule} rule
   * @param {String} selector
   *        Elements matching this selector will be highlighted on the page.
   * @param {Boolean} highlightFromRulesSelector
   */
  async toggleSelectorHighlighter(
    rule,
    selector,
    highlightFromRulesSelector = true
  ) {
    if (this.isSelectorHighlighted(selector)) {
      await this.inspector.highlighters.hideHighlighterType(
        this.inspector.highlighters.TYPES.SELECTOR
      );
    } else {
      const options = {
        hideInfoBar: true,
        hideGuides: true,
        // we still pass the selector (which can be the StyleRuleFront#computedSelector)
        // even if highlightFromRulesSelector is set to true, as it's how we keep track
        // of which selector is highlighted.
        selector,
      };
      if (highlightFromRulesSelector) {
        options.ruleActorID = rule.domRule.actorID;
      }
      await this.inspector.highlighters.showHighlighterTypeForNode(
        this.inspector.highlighters.TYPES.SELECTOR,
        this.inspector.selection.nodeFront,
        options
      );
    }
  },

  isPanelVisible() {
    return (
      this.inspector.toolbox &&
      this.inspector.sidebar &&
      this.inspector.toolbox.currentToolId === "inspector" &&
      (this.inspector.sidebar.getCurrentTabID() == "ruleview" ||
        this.inspector.isThreePaneModeEnabled)
    );
  },

  /**
   * Check whether a SelectorHighlighter is active for the given selector text.
   *
   * @param {String} selector
   * @return {Boolean}
   */
  isSelectorHighlighted(selector) {
    const options = this.inspector.highlighters.getOptionsForActiveHighlighter(
      this.inspector.highlighters.TYPES.SELECTOR
    );

    return options?.selector === selector;
  },

  /**
   * Delegate handler for events happening within the DOM tree of the Rules view.
   * Itself delegates to specific handlers by event type.
   *
   * Use this instead of attaching specific event handlers when:
   * - there are many elements with the same event handler (eases memory pressure)
   * - you want to avoid having to remove event handlers manually
   * - elements are added/removed from the DOM tree arbitrarily over time
   *
   * @param {MouseEvent|UIEvent} event
   */
  handleEvent(event) {
    if (this.childHasDragged) {
      this.childHasDragged = false;
      event.stopPropagation();
      return;
    }
    switch (event.type) {
      case "click":
        this.handleClickEvent(event);
        break;
      default:
    }
  },

  /**
   * Delegate handler for click events happening within the DOM tree of the Rules view.
   * Stop propagation of click event wrapping a CSS rule or CSS declaration to avoid
   * triggering the prompt to add a new CSS declaration or to edit the existing one.
   *
   * @param {MouseEvent} event
   */
  async handleClickEvent(event) {
    const target = event.target;

    // Handle click on the icon next to a CSS selector.
    if (target.classList.contains("js-toggle-selector-highlighter")) {
      event.stopPropagation();
      let selector = target.dataset.computedSelector;
      const highlightFromRulesSelector =
        !!selector && !target.dataset.isUniqueSelector;
      // dataset.computedSelector will be initially empty for inline styles (inherited or not)
      // Rules associated with a regular selector should have this data-attribute
      // set in devtools/client/inspector/rules/views/rule-editor.js
      const rule = getRuleFromNode(target, this._elementStyle);
      if (selector === "") {
        try {
          if (rule.inherited) {
            // This is an inline style from an inherited rule. Need to resolve the
            // unique selector from the node which this rule is inherited from.
            selector = await rule.inherited.getUniqueSelector();
          } else {
            // This is an inline style from the current node.
            selector =
              await this.inspector.selection.nodeFront.getUniqueSelector();
          }

          // Now that the selector was computed, we can store it for subsequent usage.
          target.dataset.computedSelector = selector;
          target.dataset.isUniqueSelector = true;
        } finally {
          // Could not resolve a unique selector for the inline style.
        }
      }

      this.toggleSelectorHighlighter(
        rule,
        selector,
        highlightFromRulesSelector
      );
    }

    // Handle click on swatches next to flex and inline-flex CSS properties
    if (target.classList.contains("js-toggle-flexbox-highlighter")) {
      event.stopPropagation();
      this.inspector.highlighters.toggleFlexboxHighlighter(
        this.inspector.selection.nodeFront,
        "rule"
      );
    }

    // Handle click on swatches next to grid CSS properties
    if (target.classList.contains("js-toggle-grid-highlighter")) {
      event.stopPropagation();
      this.inspector.highlighters.toggleGridHighlighter(
        this.inspector.selection.nodeFront,
        "rule"
      );
    }

    const valueSpan = target.closest(".ruleview-propertyvalue");
    if (valueSpan) {
      if (this._elementsWithPendingClicks.has(valueSpan)) {
        // When we start handling a drag in the TextPropertyEditor valueSpan,
        // we make the valueSpan capture the pointer. Then, `click` event target is always
        // the valueSpan with the latest spec of Pointer Events.
        // Therefore, we should stop immediate propagation of the `click` event
        // if we've handled a drag to prevent moving focus to the inplace editor.
        event.stopImmediatePropagation();
        return;
      }

      // Handle link click in RuleEditor property value
      if (target.nodeName === "a") {
        event.stopPropagation();
        event.preventDefault();
        openContentLink(target.href, {
          relatedToCurrent: true,
          inBackground:
            event.button === 1 ||
            (lazy.AppConstants.platform === "macosx"
              ? event.metaKey
              : event.ctrlKey),
        });
      }
    }
  },

  /**
   * Delegate handler for highlighter events.
   *
   * This is the place to observe for highlighter events, check the highlighter type and
   * event name, then react to specific events, for example by modifying the DOM.
   *
   * @param {String} eventName
   *        Highlighter event name. One of: "highlighter-hidden", "highlighter-shown"
   * @param {Object} data
   *        Object with data associated with the highlighter event.
   */
  handleHighlighterEvent(eventName, data) {
    switch (data.type) {
      // Toggle the "highlighted" class on selector icons in the Rules view when
      // the SelectorHighlighter is shown/hidden for a certain CSS selector.
      case this.inspector.highlighters.TYPES.SELECTOR:
        {
          const selector = data?.options?.selector;
          if (!selector) {
            return;
          }

          const query = `.js-toggle-selector-highlighter[data-computed-selector='${selector}']`;
          for (const node of this.styleDocument.querySelectorAll(query)) {
            const isHighlighterDisplayed = eventName == "highlighter-shown";
            node.classList.toggle("highlighted", isHighlighterDisplayed);
            node.setAttribute("aria-pressed", isHighlighterDisplayed);
          }
        }
        break;

      // Toggle the "aria-pressed" attribute on swatches next to flex and inline-flex CSS properties
      // when the FlexboxHighlighter is shown/hidden for the currently selected node.
      case this.inspector.highlighters.TYPES.FLEXBOX:
        {
          const query = ".js-toggle-flexbox-highlighter";
          for (const node of this.styleDocument.querySelectorAll(query)) {
            node.setAttribute("aria-pressed", eventName == "highlighter-shown");
          }
        }
        break;

      // Toggle the "aria-pressed" class on swatches next to grid CSS properties
      // when the GridHighlighter is shown/hidden for the currently selected node.
      case this.inspector.highlighters.TYPES.GRID:
        {
          const query = ".js-toggle-grid-highlighter";
          for (const node of this.styleDocument.querySelectorAll(query)) {
            // From the Layout panel, we can toggle grid highlighters for nodes which are
            // not currently selected. The Rules view shows `display: grid` declarations
            // only for the selected node. Avoid mistakenly marking them as "active".
            if (data.nodeFront === this.inspector.selection.nodeFront) {
              node.setAttribute(
                "aria-pressed",
                eventName == "highlighter-shown"
              );
            }

            // When the max limit of grid highlighters is reached (default 3),
            // mark inactive grid swatches as disabled.
            node.toggleAttribute(
              "disabled",
              !this.inspector.highlighters.canGridHighlighterToggle(
                this.inspector.selection.nodeFront
              )
            );
          }
        }
        break;
    }
  },

  /**
   * Enables the print and color scheme simulation only for local and remote tab debugging.
   */
  async _initSimulationFeatures() {
    if (!this.inspector.commands.descriptorFront.isTabDescriptor) {
      return;
    }
    this.colorSchemeLightSimulationButton.removeAttribute("hidden");
    this.colorSchemeDarkSimulationButton.removeAttribute("hidden");
    this.printSimulationButton.removeAttribute("hidden");
    this.printSimulationButton.addEventListener(
      "click",
      this._onTogglePrintSimulation
    );
    this.colorSchemeLightSimulationButton.addEventListener(
      "click",
      this._onToggleLightColorSchemeSimulation
    );
    this.colorSchemeDarkSimulationButton.addEventListener(
      "click",
      this._onToggleDarkColorSchemeSimulation
    );
    const { rfpCSSColorScheme } = this.inspector.walker;
    if (rfpCSSColorScheme) {
      this.colorSchemeLightSimulationButton.setAttribute("disabled", true);
      this.colorSchemeDarkSimulationButton.setAttribute("disabled", true);
      console.warn("Color scheme simulation is disabled in RFP mode.");
    }
  },

  /**
   * Get the type of a given node in the rule-view
   *
   * @param {DOMNode} node
   *        The node which we want information about
   * @return {Object|null} containing the following props:
   * - type {String} One of the VIEW_NODE_XXX_TYPE const in
   *   client/inspector/shared/node-types.
   * - rule {Rule} The Rule object.
   * - value {Object} Depends on the type of the node.
   * Otherwise, returns null if the node isn't anything we care about.
   */
  getNodeInfo(node) {
    return getNodeInfo(node, this._elementStyle);
  },

  /**
   * Get the node's compatibility issues
   *
   * @param {DOMNode} node
   *        The node which we want information about
   * @return {Object|null} containing the following props:
   * - type {String} Compatibility issue type.
   * - property {string} The incompatible rule
   * - alias {Array} The browser specific alias of rule
   * - url {string} Link to MDN documentation
   * - deprecated {bool} True if the rule is deprecated
   * - experimental {bool} True if rule is experimental
   * - unsupportedBrowsers {Array} Array of unsupported browser
   * Otherwise, returns null if the node has cross-browser compatible CSS
   */
  async getNodeCompatibilityInfo(node) {
    const compatibilityInfo = await getNodeCompatibilityInfo(
      node,
      this._elementStyle
    );

    return compatibilityInfo;
  },

  /**
   * Context menu handler.
   */
  _onContextMenu(event) {
    if (
      event.originalTarget.closest("input[type=text]") ||
      event.originalTarget.closest("input:not([type])") ||
      event.originalTarget.closest("textarea")
    ) {
      return;
    }

    event.stopPropagation();
    event.preventDefault();

    this.contextMenu.show(event);
  },

  /**
   * Callback for copy event. Copy the selected text.
   *
   * @param {Event} event
   *        copy event object.
   */
  _onCopy(event) {
    if (event) {
      this.copySelection(event.target);
      event.preventDefault();
      event.stopPropagation();
    }
  },

  /**
   * Copy the current selection. The current target is necessary
   * if the selection is inside an input or a textarea
   *
   * @param {DOMNode} target
   *        DOMNode target of the copy action
   */
  copySelection(target) {
    try {
      let text = "";

      const nodeName = target?.nodeName;
      const targetType = target?.type;

      if (
        // The target can be the enable/disable rule checkbox here (See Bug 1680893).
        (nodeName === "input" && targetType !== "checkbox") ||
        nodeName == "textarea"
      ) {
        const start = Math.min(target.selectionStart, target.selectionEnd);
        const end = Math.max(target.selectionStart, target.selectionEnd);
        const count = end - start;
        text = target.value.substr(start, count);
      } else {
        text = this.styleWindow.getSelection().toString();

        // Remove any double newlines.
        text = text.replace(/(\r?\n)\r?\n/g, "$1");
      }

      clipboardHelper.copyString(text);
    } catch (e) {
      console.error(e);
    }
  },

  /**
   * Add a new rule to the current element.
   */
  async _onAddRule() {
    const elementStyle = this._elementStyle;
    const element = elementStyle.element;
    const pseudoClasses = element.pseudoClassLocks;

    // Clear the search input so the new rule is visible
    this._onClearSearch();

    this._focusNextUserAddedRule = true;
    this.pageStyle.addNewRule(element, pseudoClasses);
  },

  /**
   * Disables add rule button when needed
   */
  refreshAddRuleButtonState() {
    const shouldBeDisabled =
      !this._viewedElement ||
      !this.inspector.selection.isElementNode() ||
      this.inspector.selection.isAnonymousNode();
    this.addRuleButton.disabled = shouldBeDisabled;
  },

  /**
   * Return {Boolean} true if the rule view currently has an input
   * editor visible.
   */
  get isEditing() {
    return (
      this.tooltips.isEditing ||
      !!this.element.querySelectorAll(".styleinspector-propertyeditor").length
    );
  },

  _handleUAStylePrefChange() {
    this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES);
    this._handlePrefChange(PREF_UA_STYLES);
  },

  _handleDefaultColorUnitPrefChange() {
    this._handlePrefChange(PREF_DEFAULT_COLOR_UNIT);
  },

  _handleDraggablePrefChange() {
    this.draggablePropertiesEnabled = Services.prefs.getBoolPref(
      PREF_DRAGGABLE,
      false
    );
    // This event is consumed by text-property-editor instances in order to
    // update their draggable behavior. Preferences observer are costly, so
    // we are forwarding the preference update via the EventEmitter.
    this.emit("draggable-preference-updated");
  },

  _handleInplaceEditorFocusNextOnEnterPrefChange() {
    this.inplaceEditorFocusNextOnEnter = Services.prefs.getBoolPref(
      PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER,
      false
    );
    this._handlePrefChange(PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER);
  },

  _handlePrefChange(pref) {
    // Reselect the currently selected element
    const refreshOnPrefs = [
      PREF_UA_STYLES,
      PREF_DEFAULT_COLOR_UNIT,
      PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER,
    ];
    if (this._viewedElement && refreshOnPrefs.includes(pref)) {
      this.selectElement(this._viewedElement, true);
    }
  },

  /**
   * Set the filter style search value.
   * @param {String} value
   *        The search value.
   */
  setFilterStyles(value = "") {
    this.searchField.value = value;
    this.searchField.focus();
    this._onFilterStyles();
  },

  /**
   * Called when the user enters a search term in the filter style search box.
   * The actual filtering (done in _doFilterStyles) will be throttled if the search input
   * isn't empty, but will happen immediately when the search gets cleared.
   */
  _onFilterStyles() {
    if (this._filterChangedTimeout) {
      clearTimeout(this._filterChangedTimeout);
    }

    const isSearchEmpty = this.searchValue.length === 0;
    this.searchClearButton.hidden = isSearchEmpty;

    // If the search is cleared update the UI directly so calls to this function (or any
    // callsite of it) can assume the UI is up to date directly after the call.
    if (isSearchEmpty) {
      this._doFilterStyles();
    } else {
      this._filterChangedTimeout = setTimeout(
        () => this._doFilterStyles(),
        FILTER_CHANGED_TIMEOUT
      );
    }
  },

  /**
   * Actually update search data and update the UI to reflect the current search.
   *
   * @emits ruleview-filtered
   */
  _doFilterStyles() {
    this.searchData = {
      searchPropertyMatch: FILTER_PROP_RE.exec(this.searchValue),
      searchPropertyName: this.searchValue,
      searchPropertyValue: this.searchValue,
      strictSearchValue: "",
      strictSearchPropertyName: false,
      strictSearchPropertyValue: false,
      strictSearchAllValues: false,
    };

    if (this.searchData.searchPropertyMatch) {
      // Parse search value as a single property line and extract the
      // property name and value. If the parsed property name or value is
      // contained in backquotes (`), extract the value within the backquotes
      // and set the corresponding strict search for the property to true.
      if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[1])) {
        this.searchData.strictSearchPropertyName = true;
        this.searchData.searchPropertyName = FILTER_STRICT_RE.exec(
          this.searchData.searchPropertyMatch[1]
        )[1];
      } else {
        this.searchData.searchPropertyName =
          this.searchData.searchPropertyMatch[1];
      }

      if (FILTER_STRICT_RE.test(this.searchData.searchPropertyMatch[2])) {
        this.searchData.strictSearchPropertyValue = true;
        this.searchData.searchPropertyValue = FILTER_STRICT_RE.exec(
          this.searchData.searchPropertyMatch[2]
        )[1];
      } else {
        this.searchData.searchPropertyValue =
          this.searchData.searchPropertyMatch[2];
      }

      // Strict search for stylesheets will match the property line regex.
      // Extract the search value within the backquotes to be used
      // in the strict search for stylesheets in _highlightStyleSheet.
      if (FILTER_STRICT_RE.test(this.searchValue)) {
        this.searchData.strictSearchValue = FILTER_STRICT_RE.exec(
          this.searchValue
        )[1];
      }
    } else if (FILTER_STRICT_RE.test(this.searchValue)) {
      // If the search value does not correspond to a property line and
      // is contained in backquotes, extract the search value within the
      // backquotes and set the flag to perform a strict search for all
      // the values (selector, stylesheet, property and computed values).
      const searchValue = FILTER_STRICT_RE.exec(this.searchValue)[1];
      this.searchData.strictSearchAllValues = true;
      this.searchData.searchPropertyName = searchValue;
      this.searchData.searchPropertyValue = searchValue;
      this.searchData.strictSearchValue = searchValue;
    }

    this._clearHighlight(this.element);
    this._clearRules();
    this._createEditors();

    this.inspector.emit("ruleview-filtered");

    this._filterChangeTimeout = null;
  },

  /**
   * Called when the user clicks on the clear button in the filter style search
   * box. Returns true if the search box is cleared and false otherwise.
   */
  _onClearSearch() {
    if (this.searchField.value) {
      this.setFilterStyles("");
      return true;
    }

    return false;
  },

  destroy() {
    this.isDestroyed = true;
    this.clear();

    this._dummyElement = null;
    // off handlers must have the same reference as their on handlers
    this._prefObserver.off(PREF_UA_STYLES, this._handleUAStylePrefChange);
    this._prefObserver.off(
      PREF_DEFAULT_COLOR_UNIT,
      this._handleDefaultColorUnitPrefChange
    );
    this._prefObserver.off(PREF_DRAGGABLE, this._handleDraggablePrefChange);
    this._prefObserver.off(
      PREF_INPLACE_EDITOR_FOCUS_NEXT_ON_ENTER,
      this._handleInplaceEditorFocusNextOnEnterPrefChange
    );
    this._prefObserver.destroy();

    this._outputParser = null;

    if (this._classListPreviewer) {
      this._classListPreviewer.destroy();
      this._classListPreviewer = null;
    }

    if (this._contextMenu) {
      this._contextMenu.destroy();
      this._contextMenu = null;
    }

    if (this._highlighters) {
      this._highlighters.removeFromView(this);
      this._highlighters = null;
    }

    // Clean-up for simulations.
    this.colorSchemeLightSimulationButton.removeEventListener(
      "click",
      this._onToggleLightColorSchemeSimulation
    );
    this.colorSchemeDarkSimulationButton.removeEventListener(
      "click",
      this._onToggleDarkColorSchemeSimulation
    );
    this.printSimulationButton.removeEventListener(
      "click",
      this._onTogglePrintSimulation
    );

    this.colorSchemeLightSimulationButton = null;
    this.colorSchemeDarkSimulationButton = null;
    this.printSimulationButton = null;

    this.tooltips.destroy();

    // Remove bound listeners
    this._abortController.abort();
    this._abortController = null;
    this.shortcuts.destroy();
    this.styleDocument.removeEventListener("click", this, { capture: true });
    this.element.removeEventListener("copy", this._onCopy);
    this.element.removeEventListener("contextmenu", this._onContextMenu);
    this.addRuleButton.removeEventListener("click", this._onAddRule);
    this.searchField.removeEventListener("input", this._onFilterStyles);
    this.searchClearButton.removeEventListener("click", this._onClearSearch);
    this.pseudoClassPanel.removeEventListener(
      "change",
      this._onTogglePseudoClass
    );
    this.pseudoClassToggle.removeEventListener(
      "click",
      this._onTogglePseudoClassPanel
    );
    this.classToggle.removeEventListener("click", this._onToggleClassPanel);
    this.inspector.highlighters.off(
      "highlighter-shown",
      this.onHighlighterShown
    );
    this.inspector.highlighters.off(
      "highlighter-hidden",
      this.onHighlighterHidden
    );

    this.searchField = null;
    this.searchClearButton = null;
    this.pseudoClassPanel = null;
    this.pseudoClassToggle = null;
    this.pseudoClassCheckboxes = null;
    this.classPanel = null;
    this.classToggle = null;

    this.inspector = null;
    this.styleDocument = null;
    this.styleWindow = null;

    if (this.element.parentNode) {
      this.element.remove();
    }

    if (this._elementStyle) {
      this._elementStyle.destroy();
    }

    if (this._popup) {
      this._popup.destroy();
      this._popup = null;
    }
  },

  /**
   * Mark the view as selecting an element, disabling all interaction, and
   * visually clearing the view after a few milliseconds to avoid confusion
   * about which element's styles the rule view shows.
   */
  _startSelectingElement() {
    this.element.classList.add("non-interactive");
  },

  /**
   * Mark the view as no longer selecting an element, re-enabling interaction.
   */
  _stopSelectingElement() {
    this.element.classList.remove("non-interactive");
  },

  /**
   * Update the view with a new selected element.
   *
   * @param {NodeActor} element
   *        The node whose style rules we'll inspect.
   * @param {Boolean} allowRefresh
   *        Update the view even if the element is the same as last time.
   */
  selectElement(element, allowRefresh = false) {
    const refresh = this._viewedElement === element;
    if (refresh && !allowRefresh) {
      return Promise.resolve(undefined);
    }

    if (this._popup && this.popup.isOpen) {
      this.popup.hidePopup();
    }

    this.clear(false);
    this._viewedElement = element;

    this.clearPseudoClassPanel();
    this.refreshAddRuleButtonState();

    if (!this._viewedElement) {
      this._stopSelectingElement();
      this._clearRules();
      this._showEmpty();
      this.refreshPseudoClassPanel();
      if (this.pageStyle) {
        this.pageStyle.off("stylesheet-updated", this.refreshPanel);
        this.pageStyle = null;
      }
      return Promise.resolve(undefined);
    }

    const isProfilerActive = Services.profiler?.IsActive();
    const startTime = isProfilerActive ? ChromeUtils.now() : null;

    this.pageStyle = element.inspectorFront.pageStyle;
    this.pageStyle.on("stylesheet-updated", this.refreshPanel);

    // To figure out how shorthand properties are interpreted by the
    // engine, we will set properties on a dummy element and observe
    // how their .style attribute reflects them as computed values.
    const dummyElementPromise = Promise.resolve(this.styleDocument)
      .then(document => {
        // ::before and ::after do not have a namespaceURI
        const namespaceURI =
          this.element.namespaceURI || document.documentElement.namespaceURI;
        this._dummyElement = document.createElementNS(
          namespaceURI,
          this.element.tagName
        );
      })
      .catch(promiseWarn);

    const elementStyle = new ElementStyle(
      element,
      this,
      this.store,
      this.pageStyle,
      this.showUserAgentStyles
    );
    this._elementStyle = elementStyle;

    this._startSelectingElement();

    return dummyElementPromise
      .then(() => {
        if (this._elementStyle === elementStyle) {
          return this._populate();
        }
        return undefined;
      })
      .then(() => {
        if (this._elementStyle === elementStyle) {
          if (!refresh) {
            this.element.scrollTop = 0;
          }
          this._stopSelectingElement();
          this._elementStyle.onChanged = () => {
            this._changed();
          };
        }
        if (isProfilerActive && this._elementStyle.rules) {
          let declarations = 0;
          for (const rule of this._elementStyle.rules) {
            declarations += rule.textProps.length;
          }
          ChromeUtils.addProfilerMarker(
            "DevTools:CssRuleView.selectElement",
            startTime,
            `${declarations} CSS declarations in ${this._elementStyle.rules.length} rules`
          );
        }
      })
      .catch(e => {
        if (this._elementStyle === elementStyle) {
          this._stopSelectingElement();
          this._clearRules();
        }
        console.error(e);
      });
  },

  /**
   * Update the rules for the currently highlighted element.
   */
  refreshPanel() {
    // Ignore refreshes when the panel is hidden, or during editing or when no element is selected.
    if (!this.isPanelVisible() || this.isEditing || !this._elementStyle) {
      return Promise.resolve(undefined);
    }

    // Repopulate the element style once the current modifications are done.
    const promises = [];
    for (const rule of this._elementStyle.rules) {
      if (rule._applyingModifications) {
        promises.push(rule._applyingModifications);
      }
    }

    return Promise.all(promises).then(() => {
      return this._populate();
    });
  },

  /**
   * Clear the pseudo class options panel by removing the checked and disabled
   * attributes for each checkbox.
   */
  clearPseudoClassPanel() {
    this.pseudoClassCheckboxes.forEach(checkbox => {
      checkbox.checked = false;
      checkbox.disabled = false;
    });
  },

  /**
   * For each item in PSEUDO_CLASSES, create a checkbox input element for toggling a
   * pseudo-class on the selected element and append it to the pseudo-class panel.
   *
   * Returns an array with the checkbox input elements for pseudo-classes.
   *
   * @return {Array}
   */
  _createPseudoClassCheckboxes() {
    const doc = this.styleDocument;
    const fragment = doc.createDocumentFragment();

    for (const pseudo of PSEUDO_CLASSES) {
      const label = doc.createElement("label");
      const checkbox = doc.createElement("input");
      checkbox.setAttribute("tabindex", "-1");
      checkbox.setAttribute("type", "checkbox");
      checkbox.setAttribute("value", pseudo);

      label.append(checkbox, pseudo);
      fragment.append(label);
    }

    this.pseudoClassPanel.append(fragment);
    return Array.from(
      this.pseudoClassPanel.querySelectorAll("input[type=checkbox]")
    );
  },

  /**
   * Update the pseudo class options for the currently highlighted element.
   */
  refreshPseudoClassPanel() {
    if (!this._elementStyle || !this.inspector.selection.isElementNode()) {
      this.pseudoClassCheckboxes.forEach(checkbox => {
        checkbox.disabled = true;
      });
      return;
    }

    const pseudoClassLocks = this._elementStyle.element.pseudoClassLocks;
    this.pseudoClassCheckboxes.forEach(checkbox => {
      checkbox.disabled = false;
      checkbox.checked = pseudoClassLocks.includes(checkbox.value);
    });
  },

  _populate() {
    const elementStyle = this._elementStyle;
    return this._elementStyle
      .populate()
      .then(() => {
        if (this._elementStyle !== elementStyle || this.isDestroyed) {
          return null;
        }

        this._clearRules();
        const onEditorsReady = this._createEditors();
        this.refreshPseudoClassPanel();

        // Notify anyone that cares that we refreshed.
        return onEditorsReady.then(() => {
          this.emit("ruleview-refreshed");
        }, console.error);
      })
      .catch(promiseWarn);
  },

  /**
   * Show the user that the rule view has no node selected.
   */
  _showEmpty() {
    if (this.styleDocument.getElementById("ruleview-no-results")) {
      return;
    }

    createChild(this.element, "div", {
      id: "ruleview-no-results",
      class: "devtools-sidepanel-no-result",
      textContent: l10n("rule.empty"),
    });
  },

  /**
   * Clear the rules.
   */
  _clearRules() {
    this.element.innerHTML = "";
  },

  /**
   * Clear the rule view.
   */
  clear(clearDom = true) {
    if (clearDom) {
      this._clearRules();
    }
    this._viewedElement = null;

    if (this._elementStyle) {
      this._elementStyle.destroy();
      this._elementStyle = null;
    }

    if (this.pageStyle) {
      this.pageStyle.off("stylesheet-updated", this.refreshPanel);
      this.pageStyle = null;
    }
  },

  /**
   * Called when the user has made changes to the ElementStyle.
   * Emits an event that clients can listen to.
   */
  _changed() {
    this.emit("ruleview-changed");
  },

  /**
   * Text for header that shows above rules for this element
   */
  get selectedElementLabel() {
    if (this._selectedElementLabel) {
      return this._selectedElementLabel;
    }
    this._selectedElementLabel = l10n("rule.selectedElement");
    return this._selectedElementLabel;
  },

  /**
   * Text for header that shows above rules for pseudo elements
   */
  get pseudoElementLabel() {
    if (this._pseudoElementLabel) {
      return this._pseudoElementLabel;
    }
    this._pseudoElementLabel = l10n("rule.pseudoElement");
    return this._pseudoElementLabel;
  },

  get showPseudoElements() {
    if (this._showPseudoElements === undefined) {
      this._showPseudoElements = Services.prefs.getBoolPref(
        "devtools.inspector.show_pseudo_elements"
      );
    }
    return this._showPseudoElements;
  },

  /**
   * Creates an expandable container in the rule view
   *
   * @param  {String} label
   *         The label for the container header
   * @param  {String} containerId
   *         The id that will be set on the container
   * @param  {Boolean} isPseudo
   *         Whether or not the container will hold pseudo element rules
   * @return {DOMNode} The container element
   */
  createExpandableContainer(label, containerId, isPseudo = false) {
    const header = this.styleDocument.createElementNS(HTML_NS, "div");
    header.classList.add(
      RULE_VIEW_HEADER_CLASSNAME,
      "ruleview-expandable-header"
    );
    header.setAttribute("role", "heading");

    const toggleButton = this.styleDocument.createElementNS(HTML_NS, "button");
    toggleButton.setAttribute(
      "title",
      l10n("rule.expandableContainerToggleButton.title")
    );
    toggleButton.setAttribute("aria-expanded", "true");
    toggleButton.setAttribute("aria-controls", containerId);

    const twisty = this.styleDocument.createElementNS(HTML_NS, "span");
    twisty.className = "ruleview-expander theme-twisty";

    toggleButton.append(twisty, this.styleDocument.createTextNode(label));
    header.append(toggleButton);

    const container = this.styleDocument.createElementNS(HTML_NS, "div");
    container.id = containerId;
    container.classList.add("ruleview-expandable-container");
    container.hidden = false;

    this.element.append(header, container);

    toggleButton.addEventListener("click", () => {
      this._toggleContainerVisibility(
        toggleButton,
        container,
        isPseudo,
        !this.showPseudoElements
      );
    });

    if (isPseudo) {
      this._toggleContainerVisibility(
        toggleButton,
        container,
        isPseudo,
        this.showPseudoElements
      );
    }

    return container;
  },

  /**
   * Create the `@property` expandable container
   *
   * @returns {Element}
   */
  createRegisteredPropertiesExpandableContainer() {
    const el = this.createExpandableContainer(
      "@property",
      REGISTERED_PROPERTIES_CONTAINER_ID
    );
    el.classList.add("registered-properties");
    return el;
  },

  /**
   * Return the RegisteredPropertyEditor element for a given property name
   *
   * @param {String} registeredPropertyName
   * @returns {Element|null}
   */
  getRegisteredPropertyElement(registeredPropertyName) {
    return this.styleDocument.querySelector(
      `#${REGISTERED_PROPERTIES_CONTAINER_ID} [data-name="${registeredPropertyName}"]`
    );
  },

  /**
   * Toggle the visibility of an expandable container
   *
   * @param  {DOMNode}  twisty
   *         Clickable toggle DOM Node
   * @param  {DOMNode}  container
   *         Expandable container DOM Node
   * @param  {Boolean}  isPseudo
   *         Whether or not the container will hold pseudo element rules
   * @param  {Boolean}  showPseudo
   *         Whether or not pseudo element rules should be displayed
   */
  _toggleContainerVisibility(toggleButton, container, isPseudo, showPseudo) {
    let isOpen = toggleButton.getAttribute("aria-expanded") === "true";

    if (isPseudo) {
      this._showPseudoElements = !!showPseudo;

      Services.prefs.setBoolPref(
        "devtools.inspector.show_pseudo_elements",
        this.showPseudoElements
      );

      container.hidden = !this.showPseudoElements;
      isOpen = !this.showPseudoElements;
    } else {
      container.hidden = !container.hidden;
    }

    toggleButton.setAttribute("aria-expanded", !isOpen);
  },

  /**
   * Creates editor UI for each of the rules in _elementStyle.
   */
  // eslint-disable-next-line complexity
  _createEditors() {
    // Run through the current list of rules, attaching
    // their editors in order.  Create editors if needed.
    let lastInherited = null;
    let lastinheritedSectionLabel = "";
    let seenNormalElement = false;
    let seenSearchTerm = false;
    const containers = new Map();

    if (!this._elementStyle.rules) {
      return Promise.resolve();
    }

    const editorReadyPromises = [];
    for (const rule of this._elementStyle.rules) {
      if (rule.domRule.system) {
        continue;
      }

      // Initialize rule editor
      if (!rule.editor) {
        const ruleActorID = rule.domRule.actorID;
        rule.editor = new RuleEditor(this, rule, {
          elementsWithPendingClicks: this._elementsWithPendingClicks,
          onShowUnusedCustomCssProperties: () => {
            this.store.expandedUnusedCustomCssPropertiesRuleActorIds.add(
              ruleActorID
            );
          },
          shouldHideUnusedCustomCssProperties:
            !this.store.expandedUnusedCustomCssPropertiesRuleActorIds.has(
              ruleActorID
            ),
        });
        editorReadyPromises.push(rule.editor.once("source-link-updated"));
      }

      // Filter the rules and highlight any matches if there is a search input
      if (this.searchValue && this.searchData) {
        if (this.highlightRule(rule)) {
          seenSearchTerm = true;
        } else if (rule.domRule.type !== ELEMENT_STYLE) {
          continue;
        }
      }

      const isNonInheritedPseudo = !!rule.pseudoElement && !rule.inherited;

      // Only print header for this element if there are pseudo elements
      if (
        containers.has(PSEUDO_ELEMENTS_CONTAINER_ID) &&
        !seenNormalElement &&
        !rule.pseudoElement
      ) {
        seenNormalElement = true;
        const div = this.styleDocument.createElementNS(HTML_NS, "div");
        div.className = RULE_VIEW_HEADER_CLASSNAME;
        div.setAttribute("role", "heading");
        div.textContent = this.selectedElementLabel;
        this.element.appendChild(div);
      }

      const { inherited, inheritedSectionLabel } = rule;
      // We need to check both `inherited` (a NodeFront) and `inheritedSectionLabel` (string),
      // as element-backed pseudo element rules (e.g. `::details-content`) can have the same
      // `inherited` property as a regular rule (e.g. on `<details>`), but the element is
      // to be considered as a child of the binding element.
      // e.g. we want to have
      // This element
      // Inherited by details::details-content
      // Inherited by details
      if (
        inherited &&
        (inherited !== lastInherited ||
          inheritedSectionLabel !== lastinheritedSectionLabel)
      ) {
        const div = this.styleDocument.createElementNS(HTML_NS, "div");
        div.classList.add(RULE_VIEW_HEADER_CLASSNAME);
        div.setAttribute("role", "heading");
        div.setAttribute("aria-level", "3");
        div.textContent = rule.inheritedSectionLabel;
        lastInherited = inherited;
        lastinheritedSectionLabel = inheritedSectionLabel;
        this.element.appendChild(div);
      }

      const keyframes = rule.keyframes;

      let containerKey = null;

      // Don't display inherited pseudo element rules (e.g. ::details-content) inside
      // the pseudo element container
      if (isNonInheritedPseudo) {
        containerKey = PSEUDO_ELEMENTS_CONTAINER_ID;
        if (!containers.has(containerKey)) {
          containers.set(
            containerKey,
            this.createExpandableContainer(
              this.pseudoElementLabel,
              containerKey,
              true
            )
          );
        }
      } else if (keyframes) {
        containerKey = keyframes;
        if (!containers.has(containerKey)) {
          containers.set(
            containerKey,
            this.createExpandableContainer(
              rule.keyframesName,
              `keyframes-container-${keyframes.name}`
            )
          );
        }
      }

      rule.editor.element.setAttribute("role", "article");
      const container = containers.get(containerKey);
      if (container) {
        container.appendChild(rule.editor.element);
      } else {
        this.element.appendChild(rule.editor.element);
      }

      // Automatically select the selector input when we are adding a user-added rule
      if (this._focusNextUserAddedRule && rule.domRule.userAdded) {
        this._focusNextUserAddedRule = null;
        rule.editor.selectorText.click();
        this.emitForTests("new-rule-added", rule);
      }
    }

    const targetRegisteredProperties =
      this.getRegisteredPropertiesForSelectedNodeTarget();
    if (targetRegisteredProperties?.size) {
      const registeredPropertiesContainer =
        this.createRegisteredPropertiesExpandableContainer();

      // Sort properties by their name, as we want to display them in alphabetical order
      const propertyDefinitions = Array.from(
        targetRegisteredProperties.values()
      ).sort((a, b) => (a.name < b.name ? -1 : 1));
      for (const propertyDefinition of propertyDefinitions) {
        const registeredPropertyEditor = new RegisteredPropertyEditor(
          this,
          propertyDefinition
        );

        registeredPropertiesContainer.appendChild(
          registeredPropertyEditor.element
        );
      }
    }

    const searchBox = this.searchField.parentNode;
    searchBox.classList.toggle(
      "devtools-searchbox-no-match",
      this.searchValue && !seenSearchTerm
    );

    return Promise.all(editorReadyPromises);
  },

  /**
   * Highlight rules that matches the filter search value and returns a
   * boolean indicating whether or not rules were highlighted.
   *
   * @param  {Rule} rule
   *         The rule object we're highlighting if its rule selectors or
   *         property values match the search value.
   * @return {Boolean} true if the rule was highlighted, false otherwise.
   */
  highlightRule(rule) {
    const isRuleSelectorHighlighted = this._highlightRuleSelector(rule);
    const isStyleSheetHighlighted = this._highlightStyleSheet(rule);
    const isAncestorRulesHighlighted = this._highlightAncestorRules(rule);
    let isHighlighted =
      isRuleSelectorHighlighted ||
      isStyleSheetHighlighted ||
      isAncestorRulesHighlighted;

    // Highlight search matches in the rule properties
    for (const textProp of rule.textProps) {
      if (!textProp.invisible && this._highlightProperty(textProp)) {
        isHighlighted = true;
      }
    }

    return isHighlighted;
  },

  /**
   * Highlights the rule selector that matches the filter search value and
   * returns a boolean indicating whether or not the selector was highlighted.
   *
   * @param  {Rule} rule
   *         The Rule object.
   * @return {Boolean} true if the rule selector was highlighted,
   *         false otherwise.
   */
  _highlightRuleSelector(rule) {
    let isSelectorHighlighted = false;

    let selectorNodes = [...rule.editor.selectorText.childNodes];
    if (rule.domRule.type === CSSRule.KEYFRAME_RULE) {
      selectorNodes = [rule.editor.selectorText];
    } else if (rule.domRule.type === ELEMENT_STYLE) {
      selectorNodes = [];
    }

    // Highlight search matches in the rule selectors
    for (const selectorNode of selectorNodes) {
      const selector = selectorNode.textContent.toLowerCase();
      if (
        (this.searchData.strictSearchAllValues &&
          selector === this.searchData.strictSearchValue) ||
        (!this.searchData.strictSearchAllValues &&
          selector.includes(this.searchValue))
      ) {
        selectorNode.classList.add("ruleview-highlight");
        isSelectorHighlighted = true;
      }
    }

    return isSelectorHighlighted;
  },

  /**
   * Highlights the ancestor rules data (@media / @layer) that matches the filter search
   * value and returns a boolean indicating whether or not element was highlighted.
   *
   * @return {Boolean} true if the element was highlighted, false otherwise.
   */
  _highlightAncestorRules(rule) {
    const element = rule.editor.ancestorDataEl;
    if (!element) {
      return false;
    }

    const ancestorSelectors = element.querySelectorAll(
      ".ruleview-rule-ancestor-selectorcontainer"
    );

    let isHighlighted = false;
    for (const child of ancestorSelectors) {
      const dataText = child.innerText.toLowerCase();
      const matches = this.searchData.strictSearchValue
        ? dataText === this.searchData.strictSearchValue
        : dataText.includes(this.searchValue);
      if (matches) {
        isHighlighted = true;
        child.classList.add("ruleview-highlight");
      }
    }

    return isHighlighted;
  },

  /**
   * Highlights the stylesheet source that matches the filter search value and
   * returns a boolean indicating whether or not the stylesheet source was
   * highlighted.
   *
   * @return {Boolean} true if the stylesheet source was highlighted, false
   *         otherwise.
   */
  _highlightStyleSheet(rule) {
    const styleSheetSource = rule.title.toLowerCase();
    const isStyleSheetHighlighted = this.searchData.strictSearchValue
      ? styleSheetSource === this.searchData.strictSearchValue
      : styleSheetSource.includes(this.searchValue);

    if (isStyleSheetHighlighted && rule.editor.source) {
      rule.editor.source.classList.add("ruleview-highlight");
    }

    return isStyleSheetHighlighted;
  },

  /**
   * Highlights the rule properties and computed properties that match the
   * filter search value and returns a boolean indicating whether or not the
   * property or computed property was highlighted.
   *
   * @param  {TextProperty} textProperty
   *         The rule property.
   * @returns {boolean} true if the property or computed property was
   *         highlighted, false otherwise.
   */
  _highlightProperty(textProperty) {
    const isPropertyHighlighted = this._highlightRuleProperty(textProperty);
    const isComputedHighlighted = this._highlightComputedProperty(textProperty);

    // Expand the computed list if a computed property is highlighted and the
    // property rule is not highlighted
    if (
      !isPropertyHighlighted &&
      isComputedHighlighted &&
      !textProperty.editor.computed.hasAttribute("user-open")
    ) {
      textProperty.editor.expandForFilter();
    }

    return isPropertyHighlighted || isComputedHighlighted;
  },

  /**
   * Called when TextPropertyEditor is updated and updates the rule property
   * highlight.
   *
   * @param  {TextPropertyEditor} editor
   *         The rule property TextPropertyEditor object.
   */
  _updatePropertyHighlight(editor) {
    if (!this.searchValue || !this.searchData) {
      return;
    }

    this._clearHighlight(editor.element);

    if (this._highlightProperty(editor.prop)) {
      this.searchField.classList.remove("devtools-style-searchbox-no-match");
    }
  },

  /**
   * Highlights the rule property that matches the filter search value
   * and returns a boolean indicating whether or not the property was
   * highlighted.
   *
   * @param  {TextProperty} textProperty
   *         The rule property object.
   * @returns {boolean} true if the rule property was highlighted,
   *         false otherwise.
   */
  _highlightRuleProperty(textProperty) {
    const propertyName = textProperty.name.toLowerCase();
    // Get the actual property value displayed in the rule view if we have an editor for
    // it (that might not be the case for unused CSS custom properties).
    const propertyValue = textProperty.editor
      ? textProperty.editor.valueSpan.textContent.toLowerCase()
      : textProperty.value.toLowerCase();

    return this._highlightMatches({
      element: textProperty.editor?.container,
      propertyName,
      propertyValue,
      textProperty,
    });
  },

  /**
   * Highlights the computed property that matches the filter search value and
   * returns a boolean indicating whether or not the computed property was
   * highlighted.
   *
   * @param  {TextProperty} textProperty
   *         The rule property object.
   * @returns {boolean} true if the computed property was highlighted, false
   *         otherwise.
   */
  _highlightComputedProperty(textProperty) {
    if (!textProperty.editor) {
      return false;
    }

    let isComputedHighlighted = false;

    // Highlight search matches in the computed list of properties
    textProperty.editor.populateComputed();
    for (const computed of textProperty.computed) {
      if (computed.element) {
        // Get the actual property value displayed in the computed list
        const computedName = computed.name.toLowerCase();
        const computedValue = computed.parsedValue.toLowerCase();

        isComputedHighlighted = this._highlightMatches({
          element: computed.element,
          propertyName: computedName,
          propertyValue: computedValue,
          textProperty,
        })
          ? true
          : isComputedHighlighted;
      }
    }

    return isComputedHighlighted;
  },

  /**
   * Helper function for highlightRules that carries out highlighting the given
   * element if the search terms match the property, and returns a boolean
   * indicating whether or not the search terms match.
   *
   * @param  {Object} options
   * @param  {DOMNode} options.element
   *         The node to highlight if search terms match
   * @param  {String} options.propertyName
   *         The property name of a rule
   * @param  {String} options.propertyValue
   *         The property value of a rule
   * @param  {TextProperty} options.textProperty
   *         The text property that we may highlight. It's helpful in cases we don't have
   *         an element yet (e.g. if the property is a hidden unused variable)
   * @return {Boolean} true if the given search terms match the property, false
   *         otherwise.
   */
  _highlightMatches({ element, propertyName, propertyValue, textProperty }) {
    const {
      searchPropertyName,
      searchPropertyValue,
      searchPropertyMatch,
      strictSearchPropertyName,
      strictSearchPropertyValue,
      strictSearchAllValues,
    } = this.searchData;
    let matches = false;

    // If the inputted search value matches a property line like
    // `font-family: arial`, then check to make sure the name and value match.
    // Otherwise, just compare the inputted search string directly against the
    // name and value of the rule property.
    const hasNameAndValue =
      searchPropertyMatch && searchPropertyName && searchPropertyValue;
    const isMatch = (value, query, isStrict) => {
      return isStrict ? value === query : query && value.includes(query);
    };

    if (hasNameAndValue) {
      matches =
        isMatch(propertyName, searchPropertyName, strictSearchPropertyName) &&
        isMatch(propertyValue, searchPropertyValue, strictSearchPropertyValue);
    } else {
      matches =
        isMatch(
          propertyName,
          searchPropertyName,
          strictSearchPropertyName || strictSearchAllValues
        ) ||
        isMatch(
          propertyValue,
          searchPropertyValue,
          strictSearchPropertyValue || strictSearchAllValues
        );
    }

    if (!matches) {
      return false;
    }

    // We might not have an element when the prop is an unused custom css property.
    if (!element && textProperty?.isUnusedVariable) {
      const editor =
        textProperty.rule.editor.showUnusedCssVariable(textProperty);

      // The editor couldn't be created, bail (shouldn't happen)
      if (!editor) {
        return false;
      }

      element = editor.container;
    }

    element.classList.add("ruleview-highlight");

    return true;
  },

  /**
   * Clear all search filter highlights in the panel, and close the computed
   * list if toggled opened
   */
  _clearHighlight(element) {
    for (const el of element.querySelectorAll(".ruleview-highlight")) {
      el.classList.remove("ruleview-highlight");
    }

    for (const computed of element.querySelectorAll(
      ".ruleview-computedlist[filter-open]"
    )) {
      computed.parentNode._textPropertyEditor.collapseForFilter();
    }
  },

  /**
   * Called when the pseudo class panel button is clicked and toggles
   * the display of the pseudo class panel.
   */
  _onTogglePseudoClassPanel() {
    if (this.pseudoClassPanel.hidden) {
      this.showPseudoClassPanel();
    } else {
      this.hidePseudoClassPanel();
    }
  },

  showPseudoClassPanel() {
    this.hideClassPanel();

    this.pseudoClassToggle.setAttribute("aria-pressed", "true");
    this.pseudoClassCheckboxes.forEach(checkbox => {
      checkbox.setAttribute("tabindex", "0");
    });
    this.pseudoClassPanel.hidden = false;
  },

  hidePseudoClassPanel() {
    this.pseudoClassToggle.setAttribute("aria-pressed", "false");
    this.pseudoClassCheckboxes.forEach(checkbox => {
      checkbox.setAttribute("tabindex", "-1");
    });
    this.pseudoClassPanel.hidden = true;
  },

  /**
   * Called when a pseudo class checkbox is clicked and toggles
   * the pseudo class for the current selected element.
   */
  _onTogglePseudoClass(event) {
    const target = event.target;
    this.inspector.togglePseudoClass(target.value);
  },

  /**
   * Called when the class panel button is clicked and toggles the display of the class
   * panel.
   */
  _onToggleClassPanel() {
    if (this.classPanel.hidden) {
      this.showClassPanel();
    } else {
      this.hideClassPanel();
    }
  },

  showClassPanel() {
    this.hidePseudoClassPanel();

    this.classToggle.setAttribute("aria-pressed", "true");
    this.classPanel.hidden = false;

    this.classListPreviewer.focusAddClassField();
  },

  hideClassPanel() {
    this.classToggle.setAttribute("aria-pressed", "false");
    this.classPanel.hidden = true;
  },

  /**
   * Handle the keypress event in the rule view.
   */
  _onShortcut(name, event) {
    if (!event.target.closest("#sidebar-panel-ruleview")) {
      return;
    }

    if (name === "CmdOrCtrl+F") {
      this.searchField.focus();
      event.preventDefault();
    } else if (
      (name === "Return" || name === "Space") &&
      this.element.classList.contains("non-interactive")
    ) {
      event.preventDefault();
    } else if (
      name === "Escape" &&
      event.target === this.searchField &&
      this._onClearSearch()
    ) {
      // Handle the search box's keypress event. If the escape key is pressed,
      // clear the search box field.
      event.preventDefault();
      event.stopPropagation();
    }
  },

  async _onToggleLightColorSchemeSimulation() {
    const shouldSimulateLightScheme =
      this.colorSchemeLightSimulationButton.getAttribute("aria-pressed") !==
      "true";

    this.colorSchemeLightSimulationButton.setAttribute(
      "aria-pressed",
      shouldSimulateLightScheme
    );

    this.colorSchemeDarkSimulationButton.setAttribute("aria-pressed", "false");

    await this.inspector.commands.targetConfigurationCommand.updateConfiguration(
      {
        colorSchemeSimulation: shouldSimulateLightScheme ? "light" : null,
      }
    );
    // Refresh the current element's rules in the panel.
    this.refreshPanel();
  },

  async _onToggleDarkColorSchemeSimulation() {
    const shouldSimulateDarkScheme =
      this.colorSchemeDarkSimulationButton.getAttribute("aria-pressed") !==
      "true";

    this.colorSchemeDarkSimulationButton.setAttribute(
      "aria-pressed",
      shouldSimulateDarkScheme
    );

    this.colorSchemeLightSimulationButton.setAttribute("aria-pressed", "false");

    await this.inspector.commands.targetConfigurationCommand.updateConfiguration(
      {
        colorSchemeSimulation: shouldSimulateDarkScheme ? "dark" : null,
      }
    );
    // Refresh the current element's rules in the panel.
    this.refreshPanel();
  },

  async _onTogglePrintSimulation() {
    const enabled =
      this.printSimulationButton.getAttribute("aria-pressed") !== "true";
    this.printSimulationButton.setAttribute("aria-pressed", enabled);
    await this.inspector.commands.targetConfigurationCommand.updateConfiguration(
      {
        printSimulationEnabled: enabled,
      }
    );
    // Refresh the current element's rules in the panel.
    this.refreshPanel();
  },

  /**
   * Temporarily flash the given element.
   *
   * @param  {Element} element
   *         The element.
   * @returns {Promise} Promise that resolves after the element was flashed-out
   */
  _flashElement(element) {
    flashElementOn(element, {
      backgroundClass: "theme-bg-contrast",
    });

    if (this._flashMutationCallback) {
      this._flashMutationCallback();
    }

    return new Promise(resolve => {
      this._flashMutationCallback = () => {
        flashElementOff(element, {
          backgroundClass: "theme-bg-contrast",
        });
        this._flashMutationCallback = null;
        resolve();
      };

      setTimeout(this._flashMutationCallback, PROPERTY_FLASHING_DURATION);
    });
  },

  /**
   * Scrolls to the top of either the rule or declaration. The view will try to scroll to
   * the rule if both can fit in the viewport. If not, then scroll to the declaration.
   *
   * @param  {Element} rule
   *         The rule to scroll to.
   * @param  {Element|null} declaration
   *         Optional. The declaration to scroll to.
   * @param  {String} scrollBehavior
   *         Optional. The transition animation when scrolling. If prefers-reduced-motion
   *         system pref is set, then the scroll behavior will be overridden to "auto".
   */
  _scrollToElement(rule, declaration, scrollBehavior = "smooth") {
    let elementToScrollTo = rule;

    if (declaration) {
      const { offsetTop, offsetHeight } = declaration;
      // Get the distance between both the rule and declaration. If the distance is
      // greater than the height of the rule view, then only scroll to the declaration.
      const distance = offsetTop + offsetHeight - rule.offsetTop;

      if (this.element.parentNode.offsetHeight <= distance) {
        elementToScrollTo = declaration;
      }
    }

    // Ensure that smooth scrolling is disabled when the user prefers reduced motion.
    const win = elementToScrollTo.ownerGlobal;
    const reducedMotion = win.matchMedia("(prefers-reduced-motion)").matches;
    scrollBehavior = reducedMotion ? "auto" : scrollBehavior;

    elementToScrollTo.scrollIntoView({ behavior: scrollBehavior });
  },

  /**
   * Toggles the visibility of the pseudo element rule's container.
   */
  _togglePseudoElementRuleContainer() {
    const container = this.styleDocument.getElementById(
      PSEUDO_ELEMENTS_CONTAINER_ID
    );
    const toggle = this.styleDocument.querySelector(
      `[aria-controls="${PSEUDO_ELEMENTS_CONTAINER_ID}"]`
    );
    this._toggleContainerVisibility(toggle, container, true, true);
  },

  /**
   * Finds the specified TextProperty name in the rule view. If found, scroll to and
   * flash the TextProperty.
   *
   * @param  {String} name
   *         The property name to scroll to and highlight.
   * @param  {Object} options
   * @param  {Function|undefined} options.ruleValidator
   *         An optional function that can be used to filter out rules we shouldn't look
   *         into to find the property name. The function is called with a Rule object,
   *         and the rule will be skipped if the function returns a falsy value.
   * @return {Boolean} true if the TextProperty name is found, and false otherwise.
   */
  highlightProperty(name, { ruleValidator } = {}) {
    // First, let's clear any search we might have, as the property could be hidden
    this._onClearSearch();

    let scrollBehavior = "auto";
    const hasRuleValidator = typeof ruleValidator === "function";
    for (const rule of this.rules) {
      if (hasRuleValidator && !ruleValidator(rule)) {
        continue;
      }
      for (const textProp of rule.textProps) {
        if (textProp.overridden || textProp.invisible || !textProp.enabled) {
          continue;
        }

        // First, search for a matching authored property.
        if (textProp.name === name) {
          // If using 2-Pane mode, then switch to the Rules tab first.
          if (!this.inspector.isThreePaneModeEnabled) {
            this.inspector.sidebar.select("ruleview");
          }

          // If the property is being applied by a pseudo element rule, expand the pseudo
          // element list container.
          if (rule.pseudoElement.length && !this.showPseudoElements) {
            // Set the scroll behavior to "auto" to avoid timing issues between toggling
            // the pseudo element container and scrolling smoothly to the rule.
            scrollBehavior = "auto";
            this._togglePseudoElementRuleContainer();
          }

          // If we're jumping to an unused CSS variable, it might not be visible, so show
          // it here.
          if (!textProp.editor && textProp.isUnusedVariable) {
            textProp.rule.editor.showUnusedCssVariable(textProp);
          }

          this._highlightElementInRule(
            rule,
            textProp.editor.element,
            scrollBehavior
          );
          return true;
        }

        // If there is no matching property, then look in computed properties.
        for (const computed of textProp.computed) {
          if (computed.overridden) {
            continue;
          }

          if (computed.name === name) {
            if (!this.inspector.isThreePaneModeEnabled) {
              this.inspector.sidebar.select("ruleview");
            }

            if (
              textProp.rule.pseudoElement.length &&
              !this.showPseudoElements
            ) {
              scrollBehavior = "auto";
              this._togglePseudoElementRuleContainer();
            }

            // Expand the computed list.
            textProp.editor.expandForFilter();

            this._highlightElementInRule(
              rule,
              computed.element,
              scrollBehavior
            );

            return true;
          }
        }
      }
    }
    // If the property is a CSS variable and we didn't find its declaration, it might
    // be a registered property
    if (this._maybeHighlightCssRegisteredProperty(name)) {
      return true;
    }

    return false;
  },

  /**
   * If the passed name matches a registered CSS property highlight it
   *
   * @param {string} name - The name of the registered property to highlight
   * @param {string} scrollBehavior
   * @returns {boolean} Returns true if `name` matched a registered property
   */
  _maybeHighlightCssRegisteredProperty(name, scrollBehavior) {
    if (!name.startsWith("--")) {
      return false;
    }

    // Get a potential @property section
    const propertyContainer = this.styleDocument.getElementById(
      REGISTERED_PROPERTIES_CONTAINER_ID
    );
    if (!propertyContainer) {
      return false;
    }

    const propertyEl = propertyContainer.querySelector(`[data-name="${name}"]`);
    if (!propertyEl) {
      return false;
    }

    const toggle = this.styleDocument.querySelector(
      `[aria-controls="${REGISTERED_PROPERTIES_CONTAINER_ID}"]`
    );
    if (toggle.ariaExpanded === "false") {
      this._toggleContainerVisibility(toggle, propertyContainer);
    }

    this._highlightElementInRule(null, propertyEl, scrollBehavior);
    return true;
  },

  /**
   * Highlight a given element in a rule editor
   *
   * @param {Rule} rule
   * @param {Element} element
   * @param {String} scrollBehavior
   */
  _highlightElementInRule(rule, element, scrollBehavior) {
    if (rule) {
      this._scrollToElement(rule.editor.selectorText, element, scrollBehavior);
    } else {
      this._scrollToElement(element, null, scrollBehavior);
    }
    this._flashElement(element).then(() =>
      this.emitForTests("element-highlighted", element)
    );
  },

  /**
   * Returns a Map (keyed by name) of the registered
   * properties for the currently selected node document.
   *
   * @returns Map<String, Object>|null
   */
  getRegisteredPropertiesForSelectedNodeTarget() {
    return this.cssRegisteredPropertiesByTarget.get(
      this.inspector.selection.nodeFront.targetFront
    );
  },
};

class RuleViewTool {
  constructor(inspector, window) {
    this.inspector = inspector;
    this.document = window.document;

    this.view = new CssRuleView(this.inspector, this.document);

    this.refresh = this.refresh.bind(this);
    this.onDetachedFront = this.onDetachedFront.bind(this);
    this.onPanelSelected = this.onPanelSelected.bind(this);
    this.onDetachedFront = this.onDetachedFront.bind(this);
    this.onSelected = this.onSelected.bind(this);
    this.onViewRefreshed = this.onViewRefreshed.bind(this);

    this.#abortController = new window.AbortController();
    const { signal } = this.#abortController;
    const baseEventConfig = { signal };

    this.view.on("ruleview-refreshed", this.onViewRefreshed, baseEventConfig);
    this.inspector.selection.on(
      "detached-front",
      this.onDetachedFront,
      baseEventConfig
    );
    this.inspector.selection.on(
      "new-node-front",
      this.onSelected,
      baseEventConfig
    );
    this.inspector.selection.on("pseudoclass", this.refresh, baseEventConfig);
    this.inspector.ruleViewSideBar.on(
      "ruleview-selected",
      this.onPanelSelected,
      baseEventConfig
    );
    this.inspector.sidebar.on(
      "ruleview-selected",
      this.onPanelSelected,
      baseEventConfig
    );
    this.inspector.toolbox.on(
      "inspector-selected",
      this.onPanelSelected,
      baseEventConfig
    );
    this.inspector.styleChangeTracker.on(
      "style-changed",
      this.refresh,
      baseEventConfig
    );

    this.inspector.commands.resourceCommand.watchResources(
      [
        this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT,
        this.inspector.commands.resourceCommand.TYPES.STYLESHEET,
      ],
      {
        onAvailable: this.#onResourceAvailable,
        ignoreExistingResources: true,
      }
    );

    // We do want to get already existing registered properties, so we need to watch
    // them separately
    this.inspector.commands.resourceCommand
      .watchResources(
        [
          this.inspector.commands.resourceCommand.TYPES
            .CSS_REGISTERED_PROPERTIES,
        ],
        {
          onAvailable: this.#onResourceAvailable,
          onUpdated: this.#onResourceUpdated,
          onDestroyed: this.#onResourceDestroyed,
          ignoreExistingResources: false,
        }
      )
      .catch(e => {
        // watchResources is async and even making it's resulting promise part of
        // this.readyPromise still causes test failures, so simply ignore the rejection
        // if the view was already destroyed.
        if (!this.view) {
          return;
        }
        throw e;
      });

    // At the moment `readyPromise` is only consumed in tests (see `openRuleView`) to be
    // notified when the ruleview was first populated to match the initial selected node.
    this.readyPromise = this.onSelected();
  }

  #abortController;

  isPanelVisible() {
    if (!this.view) {
      return false;
    }
    return this.view.isPanelVisible();
  }

  onDetachedFront() {
    this.onSelected(false);
  }

  onSelected(selectElement = true) {
    // Ignore the event if the view has been destroyed, or if it's inactive.
    // But only if the current selection isn't null. If it's been set to null,
    // let the update go through as this is needed to empty the view on
    // navigation.
    if (!this.view) {
      return null;
    }

    const isInactive =
      !this.isPanelVisible() && this.inspector.selection.nodeFront;
    if (isInactive) {
      return null;
    }

    if (
      !this.inspector.selection.isConnected() ||
      !this.inspector.selection.isElementNode()
    ) {
      return this.view.selectElement(null);
    }

    if (!selectElement) {
      return null;
    }

    const done = this.inspector.updating("rule-view");
    return this.view
      .selectElement(this.inspector.selection.nodeFront)
      .then(done, done);
  }

  refresh() {
    if (this.isPanelVisible()) {
      this.view.refreshPanel();
    }
  }

  #onResourceAvailable = resources => {
    if (!this.inspector) {
      return;
    }

    let hasNewStylesheet = false;
    const addedRegisteredProperties = [];
    for (const resource of resources) {
      if (
        resource.resourceType ===
          this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT &&
        resource.name === "will-navigate"
      ) {
        this.view.cssRegisteredPropertiesByTarget.delete(resource.targetFront);
        if (resource.targetFront.isTopLevel) {
          this.clearUserProperties();
        }
        continue;
      }

      if (
        resource.resourceType ===
          this.inspector.commands.resourceCommand.TYPES.STYLESHEET &&
        // resource.isNew is only true when the stylesheet was added from DevTools,
        // for example when adding a rule in the rule view. In such cases, we're already
        // updating the rule view, so ignore those.
        !resource.isNew
      ) {
        hasNewStylesheet = true;
      }

      if (
        resource.resourceType ===
        this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES
      ) {
        if (
          !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront)
        ) {
          this.view.cssRegisteredPropertiesByTarget.set(
            resource.targetFront,
            new Map()
          );
        }
        this.view.cssRegisteredPropertiesByTarget
          .get(resource.targetFront)
          .set(resource.name, resource);
        // Only add properties from the same target as the selected node
        if (
          this.view.inspector.selection?.nodeFront?.targetFront ===
          resource.targetFront
        ) {
          addedRegisteredProperties.push(resource);
        }
      }
    }

    if (addedRegisteredProperties.length) {
      // Retrieve @property container
      let registeredPropertiesContainer =
        this.view.styleDocument.getElementById(
          REGISTERED_PROPERTIES_CONTAINER_ID
        );
      // create it if it didn't exist before
      if (!registeredPropertiesContainer) {
        registeredPropertiesContainer =
          this.view.createRegisteredPropertiesExpandableContainer();
      }

      // Then add all new registered properties
      const names = new Set();
      for (const propertyDefinition of addedRegisteredProperties) {
        const editor = new RegisteredPropertyEditor(
          this.view,
          propertyDefinition
        );
        names.add(propertyDefinition.name);

        // We need to insert the element at the right position so we keep the list of
        // properties alphabetically sorted.
        let referenceNode = null;
        for (const child of registeredPropertiesContainer.children) {
          if (child.getAttribute("data-name") > propertyDefinition.name) {
            referenceNode = child;
            break;
          }
        }
        registeredPropertiesContainer.insertBefore(
          editor.element,
          referenceNode
        );
      }

      // Finally, update textProps that might rely on those new properties
      this._updateElementStyleRegisteredProperties(names);
    }

    if (hasNewStylesheet) {
      this.refresh();
    }
  };

  #onResourceUpdated = updates => {
    const updatedProperties = [];
    for (const update of updates) {
      if (
        update.resource.resourceType ===
        this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES
      ) {
        const { resource } = update;
        if (
          !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront)
        ) {
          continue;
        }

        this.view.cssRegisteredPropertiesByTarget
          .get(resource.targetFront)
          .set(resource.name, resource);

        // Only consider properties from the same target as the selected node
        if (
          this.view.inspector.selection?.nodeFront?.targetFront ===
          resource.targetFront
        ) {
          updatedProperties.push(resource);
        }
      }
    }

    const names = new Set();
    if (updatedProperties.length) {
      const registeredPropertiesContainer =
        this.view.styleDocument.getElementById(
          REGISTERED_PROPERTIES_CONTAINER_ID
        );
      for (const resource of updatedProperties) {
        // Replace the existing registered property editor element with a new one,
        // so we don't have to compute which elements should be updated.
        const name = resource.name;
        const el = this.view.getRegisteredPropertyElement(name);
        const editor = new RegisteredPropertyEditor(this.view, resource);
        registeredPropertiesContainer.replaceChild(editor.element, el);

        names.add(resource.name);
      }
      // Finally, update textProps that might rely on those new properties
      this._updateElementStyleRegisteredProperties(names);
    }
  };

  #onResourceDestroyed = resources => {
    const destroyedPropertiesNames = new Set();
    for (const resource of resources) {
      if (
        resource.resourceType ===
        this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES
      ) {
        if (
          !this.view.cssRegisteredPropertiesByTarget.has(resource.targetFront)
        ) {
          continue;
        }

        const targetRegisteredProperties =
          this.view.cssRegisteredPropertiesByTarget.get(resource.targetFront);
        const resourceName = Array.from(
          targetRegisteredProperties.entries()
        ).find(
          ([_, propDef]) => propDef.resourceId === resource.resourceId
        )?.[0];
        if (!resourceName) {
          continue;
        }

        targetRegisteredProperties.delete(resourceName);

        // Only consider properties from the same target as the selected node
        if (
          this.view.inspector.selection?.nodeFront?.targetFront ===
          resource.targetFront
        ) {
          destroyedPropertiesNames.add(resourceName);
        }
      }
    }
    if (destroyedPropertiesNames.size > 0) {
      for (const name of destroyedPropertiesNames) {
        this.view.getRegisteredPropertyElement(name)?.remove();
      }
      // Finally, update textProps that were relying on those removed properties
      this._updateElementStyleRegisteredProperties(destroyedPropertiesNames);
    }
  };

  /**
   * Update rules that reference registered properties whose name is in the passed Set,
   * so the `var()` tooltip has up-to-date information.
   *
   * @param {Set<String>} registeredPropertyNames
   */
  _updateElementStyleRegisteredProperties(registeredPropertyNames) {
    if (!this.view._elementStyle) {
      return;
    }
    this.view._elementStyle.onRegisteredPropertiesChange(
      registeredPropertyNames
    );
  }

  clearUserProperties() {
    if (this.view && this.view.store && this.view.store.userProperties) {
      this.view.store.userProperties.clear();
    }
  }

  onPanelSelected() {
    if (this.inspector.selection.nodeFront === this.view._viewedElement) {
      this.refresh();
    } else {
      this.onSelected();
    }
  }

  onViewRefreshed() {
    this.inspector.emit("rule-view-refreshed");
  }

  destroy() {
    if (this.#abortController) {
      this.#abortController.abort();
    }

    this.inspector.commands.resourceCommand.unwatchResources(
      [
        this.inspector.commands.resourceCommand.TYPES.DOCUMENT_EVENT,
        this.inspector.commands.resourceCommand.TYPES.STYLESHEET,
      ],
      {
        onAvailable: this.#onResourceAvailable,
      }
    );

    this.inspector.commands.resourceCommand.unwatchResources(
      [this.inspector.commands.resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES],
      {
        onAvailable: this.#onResourceAvailable,
        onUpdated: this.#onResourceUpdated,
        onDestroyed: this.#onResourceDestroyed,
      }
    );

    this.view.destroy();

    this.view =
      this.document =
      this.inspector =
      this.readyPromise =
      this.store =
      this.#abortController =
        null;
  }
}

exports.CssRuleView = CssRuleView;
exports.RuleViewTool = RuleViewTool;
