/* 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 ToolDefinitions =
  require("resource://devtools/client/definitions.js").Tools;
const CssLogic = require("resource://devtools/shared/inspector/css-logic.js");
const {
  style: { ELEMENT_STYLE, PRES_HINTS },
} = require("resource://devtools/shared/constants.js");
const OutputParser = require("resource://devtools/client/shared/output-parser.js");
const { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
const {
  createChild,
} = require("resource://devtools/client/inspector/shared/utils.js");
const {
  VIEW_NODE_SELECTOR_TYPE,
  VIEW_NODE_PROPERTY_TYPE,
  VIEW_NODE_VALUE_TYPE,
  VIEW_NODE_IMAGE_URL_TYPE,
  VIEW_NODE_FONT_TYPE,
} = require("resource://devtools/client/inspector/shared/node-types.js");
const TooltipsOverlay = require("resource://devtools/client/inspector/shared/tooltips-overlay.js");

loader.lazyRequireGetter(
  this,
  "StyleInspectorMenu",
  "resource://devtools/client/inspector/shared/style-inspector-menu.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, {
  getMdnLinkParams: "resource://devtools/shared/mdn.mjs",
});

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 L10N_TWISTY_EXPAND_LABEL = STYLE_INSPECTOR_L10N.getStr(
  "rule.twistyExpand.label"
);
const L10N_TWISTY_COLLAPSE_LABEL = STYLE_INSPECTOR_L10N.getStr(
  "rule.twistyCollapse.label"
);
const L10N_EMPTY_VARIABLE = STYLE_INSPECTOR_L10N.getStr("rule.variableEmpty");
const L10N_JUMP_DEFINITION_TITLE = STYLE_INSPECTOR_L10N.getStr(
  "rule.jumpDeclaration.title"
);

const FILTER_CHANGED_TIMEOUT = 150;

/**
 * Helper for long-running processes that should yield occasionally to
 * the mainloop.
 */
class UpdateProcess {
  /**
   * @param {Window} win
   *        Timeouts will be set on this window when appropriate.
   * @param {Array} array
   *        The array of items to process.
   * @param {object} options
   *        Options for the update process:
   *          onItem {function} Will be called with the value of each iteration.
   *          onBatch {function} Will be called after each batch of iterations,
   *            before yielding to the main loop.
   *          onDone {function} Will be called when iteration is complete.
   *          onCancel {function} Will be called if the process is canceled.
   *          threshold {int} How long to process before yielding, in ms.
   */
  constructor(win, array, options) {
    this.win = win;
    this.index = 0;
    this.array = array;

    this.onItem = options.onItem || function () {};
    this.onBatch = options.onBatch || function () {};
    this.onDone = options.onDone || function () {};
    this.onCancel = options.onCancel || function () {};
    this.threshold = options.threshold || 45;
  }

  #canceled = false;
  #timeout = null;

  /**
   * Symbol returned when the array of items to process is empty.
   */
  static ITERATION_DONE = Symbol("UpdateProcess iteration done");

  /**
   * Schedule a new batch on the main loop.
   */
  schedule() {
    if (this.#canceled) {
      return;
    }
    this.#timeout = setTimeout(() => this.#timeoutHandler(), 0);
  }

  /**
   * Cancel the running process.  onItem will not be called again,
   * and onCancel will be called.
   */
  cancel() {
    if (this.#timeout) {
      clearTimeout(this.#timeout);
      this.#timeout = null;
    }
    this.#canceled = true;
    this.onCancel();
  }

  #timeoutHandler() {
    this.#timeout = null;
    if (this.#runBatch() === UpdateProcess.ITERATION_DONE) {
      this.onBatch();
      this.onDone();
      return;
    }
    this.schedule();
  }

  #runBatch() {
    const time = Date.now();
    while (!this.#canceled) {
      const next = this.#next();
      if (next === UpdateProcess.ITERATION_DONE) {
        return next;
      }

      this.onItem(next);
      if (Date.now() - time > this.threshold) {
        this.onBatch();
        return null;
      }
    }
    return null;
  }

  /**
   * Returns the item at the current index and increases the index.
   * If all items have already been processed, will return ITERATION_DONE.
   */
  #next() {
    if (this.index < this.array.length) {
      return this.array[this.index++];
    }
    return UpdateProcess.ITERATION_DONE;
  }
}

/**
 * CssComputedView is a panel that manages the display of a table
 * sorted by style. There should be one instance of CssComputedView
 * per style display (of which there will generally only be one).
 */
class CssComputedView {
  /**
   * @param {Inspector} inspector
   *        Inspector toolbox panel
   * @param {Document} document
   *        The document that will contain the computed view.
   */
  constructor(inspector, document) {
    this.inspector = inspector;
    this.styleDocument = document;
    this.styleWindow = this.styleDocument.defaultView;

    this.propertyViews = [];

    this.#outputParser = new OutputParser(document, inspector.cssProperties);

    // Create bound methods.
    this.focusWindow = this.focusWindow.bind(this);
    this.refreshPanel = this.refreshPanel.bind(this);

    const doc = this.styleDocument;
    this.element = doc.getElementById("computed-property-container");
    this.searchField = doc.getElementById("computed-searchbox");
    this.searchClearButton = doc.getElementById("computed-searchinput-clear");
    this.includeBrowserStylesCheckbox = doc.getElementById(
      "browser-style-checkbox"
    );

    this.#abortController = new AbortController();
    const opts = { signal: this.#abortController.signal };

    this.shortcuts = new KeyShortcuts({ window: this.styleWindow });
    this.shortcuts.on(
      "CmdOrCtrl+F",
      event => this.#onShortcut("CmdOrCtrl+F", event),
      opts
    );
    this.shortcuts.on(
      "Escape",
      event => this.#onShortcut("Escape", event),
      opts
    );
    this.styleDocument.addEventListener("copy", this.#onCopy, opts);
    this.styleDocument.addEventListener("mousedown", this.focusWindow, opts);
    this.element.addEventListener("click", this.#onClick, opts);
    this.element.addEventListener("contextmenu", this.#onContextMenu, opts);
    this.searchField.addEventListener("input", this.#onFilterStyles, opts);
    this.searchClearButton.addEventListener("click", this.#onClearSearch, opts);
    this.includeBrowserStylesCheckbox.addEventListener(
      "input",
      this.#onIncludeBrowserStyles,
      opts
    );

    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, signal: this.#abortController.signal }
      );
    }

    if (!this.inspector.isThreePaneModeEnabled) {
      // When the rules view is added in 3 pane mode, refresh the Computed view whenever
      // the rules are changed.
      this.inspector.once(
        "ruleview-added",
        () => {
          this.ruleView.on("ruleview-changed", this.refreshPanel, opts);
        },
        opts
      );
    }

    if (this.ruleView) {
      this.ruleView.on("ruleview-changed", this.refreshPanel, opts);
    }

    this.searchClearButton.hidden = true;

    // No results text.
    this.noResults = this.styleDocument.getElementById("computed-no-results");

    // Refresh panel when color unit changed or pref for showing
    // original sources changes.
    this.#prefObserver = new PrefObserver("devtools.");
    this.#prefObserver.on(
      "devtools.defaultColorUnit",
      this.#handlePrefChange,
      opts
    );

    // The PageStyle front related to the currently selected element
    this.selectedNodeFrontPageStyle = null;
    // Flag that is set when the selected element style was updated. This will force
    // clearing the page style cssLogic cache the next time we're calling getComputed().
    this.elementStyleUpdated = false;

    this.createStyleViews();

    // Add the tooltips and highlightersoverlay
    this.tooltips = new TooltipsOverlay(this);
  }

  /**
   * Lookup a l10n string in the shared styleinspector string bundle.
   *
   * @param {string} name
   *        The key to lookup.
   * @returns {string} localized version of the given key.
   */
  static l10n(name) {
    try {
      return STYLE_INSPECTOR_L10N.getStr(name);
    } catch (ex) {
      console.log("Error reading '" + name + "'");
      throw new Error("l10n error with " + name);
    }
  }

  #abortController;
  #contextMenu;
  #computed;
  #createViewsProcess;
  #createViewsPromise;
  // Used for cancelling timeouts in the style filter.
  #filterChangedTimeout = null;
  #highlighters;
  #isDestroyed = false;
  // Cache the list of properties that match the selected element.
  #matchedProperties = null;
  #outputParser = null;
  #prefObserver;
  #refreshProcess;
  #sourceFilter;
  // The element that we're inspecting, and the document that it comes from.
  #selectedNodeFront = null;

  // Number of visible properties
  numVisibleProperties = 0;

  get outputParser() {
    return this.#outputParser;
  }

  get computed() {
    return this.#computed;
  }

  get contextMenu() {
    if (!this.#contextMenu) {
      this.#contextMenu = new StyleInspectorMenu(this);
    }

    return this.#contextMenu;
  }

  // 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 includeBrowserStyles() {
    return this.includeBrowserStylesCheckbox.checked;
  }

  get ruleView() {
    return (
      this.inspector.hasPanel("ruleview") &&
      this.inspector.getPanel("ruleview").view
    );
  }

  get selectedNodeFront() {
    return this.#selectedNodeFront;
  }

  #handlePrefChange = () => {
    if (this.#computed) {
      this.refreshPanel();
    }
  };

  /**
   * Update the view with a new selected element. The CssComputedView panel
   * will show the style information for the given element.
   *
   * @param {NodeFront} element
   *        The highlighted node to get styles for.
   * @returns a promise that will be resolved when highlighting is complete.
   */
  selectElement(element) {
    if (!element) {
      if (this.selectedNodeFrontPageStyle) {
        this.selectedNodeFrontPageStyle.off(
          "stylesheet-updated",
          this.refreshPanel
        );
        this.selectedNodeFrontPageStyle = null;
      }
      this.#selectedNodeFront = null;
      this.noResults.hidden = false;

      if (this.#refreshProcess) {
        this.#refreshProcess.cancel();
      }
      // Hiding all properties
      for (const propView of this.propertyViews) {
        propView.refresh();
      }
      return Promise.resolve(undefined);
    }

    if (element === this.#selectedNodeFront) {
      return Promise.resolve(undefined);
    }

    if (this.selectedNodeFrontPageStyle) {
      this.selectedNodeFrontPageStyle.off(
        "stylesheet-updated",
        this.refreshPanel
      );
    }
    this.selectedNodeFrontPageStyle = element.inspectorFront.pageStyle;
    this.selectedNodeFrontPageStyle.on(
      "stylesheet-updated",
      this.refreshPanel,
      {
        signal: this.#abortController.signal,
      }
    );

    this.#selectedNodeFront = element;

    this.refreshSourceFilter();

    return this.refreshPanel();
  }

  /**
   * Get the type of a given node in the computed-view
   *
   * @param {DOMNode} node
   *        The node which we want information about
   * @return {object} The type information object contains the following props:
   * - view {String} Always "computed" to indicate the computed view.
   * - type {String} One of the VIEW_NODE_XXX_TYPE const in
   *   client/inspector/shared/node-types
   * - value {Object} Depends on the type of the node
   * returns null if the node isn't anything we care about
   */
  // eslint-disable-next-line complexity
  getNodeInfo(node) {
    if (!node) {
      return null;
    }

    const classes = node.classList;

    // Check if the node isn't a selector first since this doesn't require
    // walking the DOM
    if (
      classes.contains("matched") ||
      classes.contains("bestmatch") ||
      classes.contains("parentmatch")
    ) {
      let selectorText = "";

      for (const child of node.childNodes[1].childNodes) {
        if (child.nodeType === node.TEXT_NODE) {
          selectorText += child.textContent;
        }
      }
      return {
        type: VIEW_NODE_SELECTOR_TYPE,
        value: selectorText.trim(),
      };
    }

    const propertyView = node.closest(".computed-property-view");
    const propertyMatchedSelectors = node.closest(".matchedselectors");
    const parent = propertyMatchedSelectors || propertyView;

    if (!parent) {
      return null;
    }

    let value, type;

    // Get the property and value for a node that's a property name or value
    const isHref =
      classes.contains("theme-link") && !classes.contains("computed-link");

    if (classes.contains("computed-font-family")) {
      if (propertyMatchedSelectors) {
        const view = propertyMatchedSelectors.closest("li");
        value = {
          property: view.querySelector(".computed-property-name").firstChild
            .textContent,
          value: node.parentNode.textContent,
        };
      } else if (propertyView) {
        value = {
          property: parent.querySelector(".computed-property-name").firstChild
            .textContent,
          value: node.parentNode.textContent,
        };
      } else {
        return null;
      }
    } else if (
      propertyMatchedSelectors &&
      (classes.contains("computed-other-property-value") || isHref)
    ) {
      const view = propertyMatchedSelectors.closest("li");
      value = {
        property: view.querySelector(".computed-property-name").firstChild
          .textContent,
        value: node.textContent,
      };
    } else if (
      propertyView &&
      (classes.contains("computed-property-name") ||
        classes.contains("computed-property-value") ||
        isHref)
    ) {
      value = {
        property: parent.querySelector(".computed-property-name").firstChild
          .textContent,
        value: parent.querySelector(".computed-property-value").textContent,
      };
    }

    // Get the type
    if (classes.contains("computed-property-name")) {
      type = VIEW_NODE_PROPERTY_TYPE;
    } else if (
      classes.contains("computed-property-value") ||
      classes.contains("computed-other-property-value")
    ) {
      type = VIEW_NODE_VALUE_TYPE;
    } else if (classes.contains("computed-font-family")) {
      type = VIEW_NODE_FONT_TYPE;
    } else if (isHref) {
      type = VIEW_NODE_IMAGE_URL_TYPE;
      value.url = node.href;
    } else {
      return null;
    }

    return {
      view: "computed",
      type,
      value,
    };
  }

  #createPropertyViews() {
    if (this.#createViewsPromise) {
      return this.#createViewsPromise;
    }

    this.refreshSourceFilter();
    this.numVisibleProperties = 0;
    const fragment = this.styleDocument.createDocumentFragment();

    this.#createViewsPromise = new Promise((resolve, reject) => {
      this.#createViewsProcess = new UpdateProcess(
        this.styleWindow,
        CssComputedView.propertyNames,
        {
          onItem: propertyName => {
            // Per-item callback.
            const propView = new PropertyView(this, propertyName);
            fragment.append(propView.createListItemElement());

            if (propView.visible) {
              this.numVisibleProperties++;
            }
            this.propertyViews.push(propView);
          },
          onCancel: () => {
            reject("#createPropertyViews cancelled");
          },
          onDone: () => {
            // Completed callback.
            this.element.appendChild(fragment);
            this.noResults.hidden = this.numVisibleProperties > 0;
            resolve(undefined);
          },
        }
      );
    });

    this.#createViewsProcess.schedule();

    return this.#createViewsPromise;
  }

  isPanelVisible() {
    return (
      this.inspector.toolbox &&
      this.inspector.sidebar &&
      this.inspector.toolbox.currentToolId === "inspector" &&
      this.inspector.sidebar.getCurrentTabID() == "computedview"
    );
  }

  /**
   * Refresh the panel content. This could be called by a "ruleview-changed" event, but
   * we avoid the extra processing unless the panel is visible.
   */
  async refreshPanel() {
    if (!this.#selectedNodeFront || !this.isPanelVisible()) {
      return;
    }

    // Capture the current viewed element to return from the promise handler
    // early if it changed
    const selectedNodeFront = this.#selectedNodeFront;

    try {
      // Create the properties views only once for the whole lifecycle of the inspector
      // via `_createPropertyViews`.
      // The properties are created without backend data. This queries typical property
      // names via `DOMWindow.getComputedStyle` on the frontend inspector document.
      // We then have to manually update the list of PropertyView's for custom properties
      // based on backend data (`getComputed()`/`computed`).
      // Also note that PropertyView/PropertyView are refreshed via their refresh method
      // which will ultimately query `CssComputedView._computed`, which we update in this method.
      const [computed] = await Promise.all([
        this.selectedNodeFrontPageStyle.getComputed(this.#selectedNodeFront, {
          filter: this.#sourceFilter,
          onlyMatched: !this.includeBrowserStyles,
          markMatched: true,
          clearCache: !!this.elementStyleUpdated,
        }),
        this.#createPropertyViews(),
      ]);

      this.elementStyleUpdated = false;

      if (selectedNodeFront !== this.#selectedNodeFront) {
        return;
      }

      this.#computed = computed;
      this.#matchedProperties = new Set();
      const customProperties = new Set();

      for (const name in computed) {
        if (computed[name].matched) {
          this.#matchedProperties.add(name);
        }
        if (name.startsWith("--")) {
          customProperties.add(name);
        }
      }

      // Removing custom property PropertyViews which won't be used
      let customPropertiesStartIndex;
      for (let i = this.propertyViews.length - 1; i >= 0; i--) {
        const propView = this.propertyViews[i];

        // custom properties are displayed at the bottom of the list, and we're looping
        // backward through propertyViews, so if the current item does not represent
        // a custom property, we can stop looping.
        if (!propView.isCustomProperty) {
          customPropertiesStartIndex = i + 1;
          break;
        }

        // If the custom property will be used, move to the next item.
        if (customProperties.has(propView.name)) {
          customProperties.delete(propView.name);
          continue;
        }

        // Otherwise remove property view element
        if (propView.element) {
          propView.element.remove();
        }

        propView.destroy();
        this.propertyViews.splice(i, 1);
      }

      // At this point, `customProperties` only contains custom property names for
      // which we don't have a PropertyView yet.
      let insertIndex = customPropertiesStartIndex;
      for (const customPropertyName of Array.from(customProperties).sort()) {
        const propertyView = new PropertyView(
          this,
          customPropertyName,
          // isCustomProperty
          true
        );

        const len = this.propertyViews.length;
        if (insertIndex !== len) {
          for (let i = insertIndex; i <= len; i++) {
            const existingPropView = this.propertyViews[i];
            if (
              !existingPropView ||
              !existingPropView.isCustomProperty ||
              customPropertyName < existingPropView.name
            ) {
              insertIndex = i;
              break;
            }
          }
        }
        this.propertyViews.splice(insertIndex, 0, propertyView);

        // Insert the custom property PropertyView at the right spot so we
        // keep the list ordered.
        const previousSibling = this.element.childNodes[insertIndex - 1];
        previousSibling.insertAdjacentElement(
          "afterend",
          propertyView.createListItemElement()
        );
      }

      if (this.#refreshProcess) {
        this.#refreshProcess.cancel();
      }

      this.noResults.hidden = true;

      // Reset visible property count
      this.numVisibleProperties = 0;

      await new Promise((resolve, reject) => {
        this.#refreshProcess = new UpdateProcess(
          this.styleWindow,
          this.propertyViews,
          {
            onItem: propView => {
              propView.refresh();
            },
            onCancel: () => {
              reject("#refreshProcess of computed view cancelled");
            },
            onDone: () => {
              this.#refreshProcess = null;
              this.noResults.hidden = this.numVisibleProperties > 0;

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

              this.inspector.emit("computed-view-refreshed");
              resolve(undefined);
            },
          }
        );
        this.#refreshProcess.schedule();
      });
    } catch (e) {
      console.error(e);
    }
  }

  /**
   * Handle the shortcut events in the computed view.
   */
  #onShortcut = (name, event) => {
    if (!event.target.closest("#sidebar-panel-computedview")) {
      return;
    }
    // Handle the search box's keypress event. If the escape key is pressed,
    // clear the search box field.
    if (
      name === "Escape" &&
      event.target === this.searchField &&
      this.#onClearSearch()
    ) {
      event.preventDefault();
      event.stopPropagation();
    } else if (name === "CmdOrCtrl+F") {
      this.searchField.focus();
      event.preventDefault();
    }
  };

  /**
   * 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.
   */
  #onFilterStyles = () => {
    if (this.#filterChangedTimeout) {
      clearTimeout(this.#filterChangedTimeout);
    }

    const filterTimeout = this.searchField.value.length
      ? FILTER_CHANGED_TIMEOUT
      : 0;
    this.searchClearButton.hidden = this.searchField.value.length === 0;

    this.#filterChangedTimeout = setTimeout(() => {
      this.refreshPanel();
      this.#filterChangedTimeout = null;
    }, filterTimeout);
  };

  /**
   * 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;
  };

  /**
   * The change event handler for the includeBrowserStyles checkbox.
   */
  #onIncludeBrowserStyles = () => {
    this.refreshSourceFilter();
    this.refreshPanel();
  };

  /**
   * When includeBrowserStylesCheckbox.checked is false we only display
   * properties that have matched selectors and have been included by the
   * document or one of thedocument's stylesheets. If .checked is false we
   * display all properties including those that come from UA stylesheets.
   */
  refreshSourceFilter() {
    this.#matchedProperties = null;
    this.#sourceFilter = this.includeBrowserStyles
      ? CssLogic.FILTER.UA
      : CssLogic.FILTER.USER;
  }

  /**
   * The CSS as displayed by the UI.
   */
  createStyleViews() {
    if (CssComputedView.propertyNames) {
      return;
    }

    CssComputedView.propertyNames = [];

    // Here we build and cache a list of css properties supported by the browser
    // We could use any element but let's use the main document's root element
    const styles = this.styleWindow.getComputedStyle(
      this.styleDocument.documentElement
    );
    const mozProps = [];
    for (let i = 0, numStyles = styles.length; i < numStyles; i++) {
      const prop = styles.item(i);
      if (prop.startsWith("--")) {
        // Skip any CSS variables used inside of browser CSS files
        continue;
      } else if (prop.startsWith("-")) {
        mozProps.push(prop);
      } else {
        CssComputedView.propertyNames.push(prop);
      }
    }

    CssComputedView.propertyNames.sort();
    CssComputedView.propertyNames.push.apply(
      CssComputedView.propertyNames,
      mozProps.sort()
    );

    this.#createPropertyViews().catch(e => {
      if (!this.#isDestroyed) {
        console.warn(
          "The creation of property views was cancelled because " +
            "the computed-view was destroyed before it was done creating views"
        );
      } else {
        console.error(e);
      }
    });
  }

  /**
   * Get a set of properties that have matched selectors.
   *
   * @return {Set} If a property name is in the set, it has matching selectors.
   */
  get matchedProperties() {
    return this.#matchedProperties || new Set();
  }

  /**
   * Focus the window on mousedown.
   */
  focusWindow() {
    this.styleWindow.focus();
  }

  /**
   * Context menu handler.
   */
  #onContextMenu = event => {
    // Call stopPropagation() and preventDefault() here so that avoid to show default
    // context menu in about:devtools-toolbox. See Bug 1515265.
    event.stopPropagation();
    event.preventDefault();
    this.contextMenu.show(event);
  };

  #onClick = event => {
    const target = event.target;

    if (target.nodeName === "a") {
      event.stopPropagation();
      event.preventDefault();
      openContentLink(target.href);
    }
  };

  /**
   * Callback for copy event. Copy selected text.
   *
   * @param {Event} event
   *        copy event object.
   */
  #onCopy = event => {
    const win = this.styleWindow;
    const text = win.getSelection().toString().trim();
    if (text !== "") {
      this.copySelection();
      event.preventDefault();
    }
  };

  /**
   * Copy the current selection to the clipboard
   */
  copySelection() {
    try {
      const win = this.styleWindow;
      const text = win.getSelection().toString().trim();

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

  /**
   * Destructor for CssComputedView.
   */
  destroy() {
    this.#selectedNodeFront = null;
    this.#abortController.abort();
    this.#abortController = null;

    if (this.selectedNodeFrontPageStyle) {
      this.selectedNodeFrontPageStyle = null;
    }
    this.#outputParser = null;

    this.#prefObserver.destroy();

    // Cancel tree construction
    if (this.#createViewsProcess) {
      this.#createViewsProcess.cancel();
    }
    if (this.#refreshProcess) {
      this.#refreshProcess.cancel();
    }

    if (this.#contextMenu) {
      this.#contextMenu.destroy();
      this.#contextMenu = null;
    }

    if (this.#highlighters) {
      this.#highlighters.removeFromView(this);
      this.#highlighters = null;
    }

    this.tooltips.destroy();

    // Nodes used in templating
    this.element = null;
    this.searchField = null;
    this.searchClearButton = null;
    this.includeBrowserStylesCheckbox = null;

    // Property views
    for (const propView of this.propertyViews) {
      propView.destroy();
    }
    this.propertyViews = null;

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

    this.#isDestroyed = true;
  }
}

class PropertyInfo {
  /**
   * @param {CssComputedView} tree
   *        The CssComputedView instance we are working with.
   * @param {string} name
   *        The CSS property name
   */
  constructor(tree, name) {
    this.#tree = tree;
    this.name = name;
  }

  #tree;

  get isSupported() {
    // There can be a mismatch between the list of properties
    // supported on the server and on the client.
    // Ideally we should build PropertyInfo only for property names supported on
    // the server. See Bug 1722348.
    return this.#tree.computed && this.name in this.#tree.computed;
  }

  get value() {
    if (this.isSupported) {
      const value = this.#tree.computed[this.name].value;
      return value;
    }
    return null;
  }
}

/**
 * A container to give easy access to property data from the template engine.
 */
class PropertyView {
  /**
   * @param {CssComputedView} tree
   *        The CssComputedView instance we are working with.
   * @param {string} name
   *        The CSS property name for which this PropertyView
   *        instance will render the rules.
   * @param {boolean} isCustomProperty
   *        Set to true if this will represent a custom property.
   */
  constructor(tree, name, isCustomProperty = false) {
    this.#tree = tree;
    this.name = name;

    this.isCustomProperty = isCustomProperty;

    if (!this.isCustomProperty) {
      this.link = `https://developer.mozilla.org/docs/Web/CSS/Reference/Properties/${name}?${lazy.getMdnLinkParams("computed-panel")}`;
    }

    this.#propertyInfo = new PropertyInfo(tree, name);
    const win = this.#tree.styleWindow;
    this.#abortController = new win.AbortController();
  }

  // The parent element which contains the open attribute
  element = null;

  // Destination for property values
  valueNode = null;

  // Are matched rules expanded?
  matchedExpanded = false;

  // Matched selector container
  matchedSelectorsContainer = null;

  // Result of call to getMatchedSelectors
  #matchedSelectorResponse = null;

  // Matched selector expando
  #matchedExpander = null;

  // AbortController for event listeners
  #abortController = null;

  // Cache for matched selector views
  #matchedSelectorViews = null;

  // The previously selected element used for the selector view caches
  #prevSelectedNodeFront = null;

  // PropertyInfo
  #propertyInfo = null;

  #tree;

  /**
   * Get the computed style for the current property.
   *
   * @return {string} the computed style for the current property of the
   * currently highlighted element.
   */
  get value() {
    return this.propertyInfo.value;
  }

  /**
   * An easy way to access the CssPropertyInfo behind this PropertyView.
   */
  get propertyInfo() {
    return this.#propertyInfo;
  }

  /**
   * Does the property have any matched selectors?
   */
  get hasMatchedSelectors() {
    return this.#tree.matchedProperties.has(this.name);
  }

  /**
   * Should this property be visible?
   */
  get visible() {
    if (!this.#tree.selectedNodeFront) {
      return false;
    }

    if (!this.#tree.includeBrowserStyles && !this.hasMatchedSelectors) {
      return false;
    }

    const searchTerm = this.#tree.searchField.value.toLowerCase();
    const isValidSearchTerm = !!searchTerm.trim().length;
    if (
      isValidSearchTerm &&
      !this.name.toLowerCase().includes(searchTerm) &&
      !this.value.toLowerCase().includes(searchTerm)
    ) {
      return false;
    }

    return this.propertyInfo.isSupported;
  }

  /**
   * Returns the className that should be assigned to the propertyView.
   *
   * @return {string}
   */
  get propertyHeaderClassName() {
    return this.visible ? "computed-property-view" : "computed-property-hidden";
  }

  /**
   * Is the property invalid at computed value time
   *
   * @returns {boolean}
   */
  get invalidAtComputedValueTime() {
    return this.#tree.computed[this.name].invalidAtComputedValueTime;
  }

  /**
   * If this is a registered property, returns its syntax (e.g. "<color>")
   *
   * @returns {Text|undefined}
   */
  get registeredPropertySyntax() {
    return this.#tree.computed[this.name].registeredPropertySyntax;
  }

  /**
   * If this is a registered property, return its initial-value
   *
   * @returns {Text|undefined}
   */
  get registeredPropertyInitialValue() {
    return this.#tree.computed[this.name].registeredPropertyInitialValue;
  }

  /**
   * Create DOM elements for a property
   *
   * @return {Element} The <li> element
   */
  createListItemElement() {
    const doc = this.#tree.styleDocument;
    const baseEventListenerConfig = { signal: this.#abortController.signal };

    // Build the container element
    this.onMatchedToggle = this.onMatchedToggle.bind(this);
    this.element = doc.createElement("li");
    this.element.className = this.propertyHeaderClassName;
    this.element.addEventListener(
      "dblclick",
      this.onMatchedToggle,
      baseEventListenerConfig
    );

    // Make it keyboard navigable
    this.element.setAttribute("tabindex", "0");
    this.shortcuts = new KeyShortcuts({
      window: this.#tree.styleWindow,
      target: this.element,
    });
    this.shortcuts.on("F1", event => {
      this.mdnLinkClick(event);
      // Prevent opening the options panel
      event.preventDefault();
      event.stopPropagation();
    });
    this.shortcuts.on("Return", this.onMatchedToggle);
    this.shortcuts.on("Space", this.onMatchedToggle);

    const nameContainer = doc.createElement("span");
    nameContainer.className = "computed-property-name-container";

    // Build the twisty expand/collapse
    this.#matchedExpander = doc.createElement("div");
    this.#matchedExpander.className = "computed-expander theme-twisty";
    this.#matchedExpander.setAttribute("role", "button");
    this.#matchedExpander.setAttribute("aria-label", L10N_TWISTY_EXPAND_LABEL);
    this.#matchedExpander.addEventListener(
      "click",
      this.onMatchedToggle,
      baseEventListenerConfig
    );

    // Build the style name element
    const nameNode = doc.createElement("span");
    nameNode.classList.add("computed-property-name", "theme-fg-color3");

    // Give it a heading role for screen readers.
    nameNode.setAttribute("role", "heading");

    // Reset its tabindex attribute otherwise, if an ellipsis is applied
    // it will be reachable via TABing
    nameNode.setAttribute("tabindex", "");
    // Avoid english text (css properties) from being altered
    // by RTL mode
    nameNode.setAttribute("dir", "ltr");
    nameNode.textContent = nameNode.title = this.name;
    // Make it hand over the focus to the container
    const focusElement = () => this.element.focus();
    nameNode.addEventListener("click", focusElement, baseEventListenerConfig);

    // Build the style name ":" separator
    const nameSeparator = doc.createElement("span");
    nameSeparator.classList.add("visually-hidden");
    nameSeparator.textContent = ": ";
    nameNode.appendChild(nameSeparator);

    nameContainer.appendChild(nameNode);

    const valueContainer = doc.createElement("span");
    valueContainer.className = "computed-property-value-container";

    // Build the style value element
    this.valueNode = doc.createElement("span");
    this.valueNode.classList.add("computed-property-value", "theme-fg-color1");
    // Reset its tabindex attribute otherwise, if an ellipsis is applied
    // it will be reachable via TABing
    this.valueNode.setAttribute("tabindex", "");
    this.valueNode.setAttribute("dir", "ltr");
    // Make it hand over the focus to the container
    this.valueNode.addEventListener(
      "click",
      focusElement,
      baseEventListenerConfig
    );

    // Build the style value ";" separator
    const valueSeparator = doc.createElement("span");
    valueSeparator.classList.add("visually-hidden");
    valueSeparator.textContent = ";";

    valueContainer.append(this.valueNode, valueSeparator);

    // If the value is invalid at computed value time (IACVT), display the same
    // warning icon that we have in the rules view for IACVT declarations.
    if (this.isCustomProperty) {
      this.invalidAtComputedValueTimeNode = doc.createElement("div");
      this.invalidAtComputedValueTimeNode.classList.add(
        "invalid-at-computed-value-time-warning"
      );
      this.refreshInvalidAtComputedValueTime();
      valueContainer.append(this.invalidAtComputedValueTimeNode);
    }

    // Build the matched selectors container
    this.matchedSelectorsContainer = doc.createElement("div");
    this.matchedSelectorsContainer.classList.add("matchedselectors");

    this.element.append(
      this.#matchedExpander,
      nameContainer,
      valueContainer,
      this.matchedSelectorsContainer
    );

    return this.element;
  }

  /**
   * Refresh the panel's CSS property value.
   */
  refresh() {
    const className = this.propertyHeaderClassName;
    if (this.element.className !== className) {
      this.element.className = className;
    }

    if (this.#prevSelectedNodeFront !== this.#tree.selectedNodeFront) {
      this.#matchedSelectorViews = null;
      this.#prevSelectedNodeFront = this.#tree.selectedNodeFront;
    }

    if (!this.#tree.selectedNodeFront || !this.visible) {
      this.valueNode.textContent = this.valueNode.title = "";
      this.matchedSelectorsContainer.parentNode.hidden = true;
      this.matchedSelectorsContainer.textContent = "";
      this.#matchedExpander.removeAttribute("open");
      this.#matchedExpander.setAttribute(
        "aria-label",
        L10N_TWISTY_EXPAND_LABEL
      );
      return;
    }

    this.#tree.numVisibleProperties++;

    this.valueNode.innerHTML = "";
    // No need to pass the baseURI argument here as computed URIs are never relative.
    this.valueNode.appendChild(this.#parseValue(this.propertyInfo.value));

    this.refreshInvalidAtComputedValueTime();
    this.refreshMatchedSelectors();
  }

  /**
   * Refresh the panel matched rules.
   */
  refreshMatchedSelectors() {
    const hasMatchedSelectors = this.hasMatchedSelectors;
    this.matchedSelectorsContainer.parentNode.hidden = !hasMatchedSelectors;

    if (hasMatchedSelectors) {
      this.#matchedExpander.classList.add("computed-expandable");
    } else {
      this.#matchedExpander.classList.remove("computed-expandable");
    }

    if (this.matchedExpanded && hasMatchedSelectors) {
      return this.#tree.selectedNodeFrontPageStyle
        .getMatchedSelectors(this.#tree.selectedNodeFront, this.name)
        .then(matched => {
          if (!this.matchedExpanded) {
            return;
          }

          this.#matchedSelectorResponse = matched;

          this.#buildMatchedSelectors();
          this.#matchedExpander.setAttribute("open", "");
          this.#matchedExpander.setAttribute(
            "aria-label",
            L10N_TWISTY_COLLAPSE_LABEL
          );
          this.#tree.inspector.emit("computed-view-property-expanded");
        })
        .catch(console.error);
    }

    this.matchedSelectorsContainer.innerHTML = "";
    this.#matchedExpander.removeAttribute("open");
    this.#matchedExpander.setAttribute("aria-label", L10N_TWISTY_EXPAND_LABEL);
    this.#tree.inspector.emit("computed-view-property-collapsed");
    return Promise.resolve(undefined);
  }

  /**
   * Show/Hide IACVT icon and sets its title attribute
   */
  refreshInvalidAtComputedValueTime() {
    if (!this.isCustomProperty) {
      return;
    }

    if (!this.invalidAtComputedValueTime) {
      this.invalidAtComputedValueTimeNode.setAttribute("hidden", "");
      this.invalidAtComputedValueTimeNode.removeAttribute("title");
    } else {
      this.invalidAtComputedValueTimeNode.removeAttribute("hidden", "");
      this.invalidAtComputedValueTimeNode.setAttribute(
        "title",
        STYLE_INSPECTOR_L10N.getFormatStr(
          "rule.warningInvalidAtComputedValueTime.title",
          `"${this.registeredPropertySyntax}"`
        )
      );
    }
  }

  get matchedSelectors() {
    return this.#matchedSelectorResponse;
  }

  #buildMatchedSelectors() {
    const frag = this.element.ownerDocument.createDocumentFragment();

    for (const selector of this.matchedSelectorViews) {
      const p = createChild(frag, "p");
      const span = createChild(p, "span", {
        class: "rule-link",
      });

      if (selector.source) {
        const link = createChild(span, "a", {
          target: "_blank",
          class: "computed-link theme-link",
          title: selector.longSource,
          sourcelocation: selector.source,
          tabindex: "0",
          textContent: selector.source,
        });
        link.addEventListener("click", selector.openStyleEditor);
        const shortcuts = new KeyShortcuts({
          window: this.#tree.styleWindow,
          target: link,
        });
        shortcuts.on("Return", () => selector.openStyleEditor());
      }

      const status = createChild(p, "span", {
        dir: "ltr",
        class: "rule-text theme-fg-color3 " + selector.statusClass,
        title: selector.statusText,
      });

      // Add an explicit status text span for screen readers.
      // They won't pick up the title from the status span.
      createChild(status, "span", {
        dir: "ltr",
        class: "visually-hidden",
        textContent: selector.statusText + " ",
      });

      const selectorEl = createChild(status, "div", {
        class: "fix-get-selection computed-other-property-selector",
        textContent: selector.sourceText,
      });
      if (
        selector.selectorInfo.rule.type === ELEMENT_STYLE ||
        selector.selectorInfo.rule.type === PRES_HINTS
      ) {
        selectorEl.classList.add("alternative-selector");
      }

      const valueDiv = createChild(status, "div", {
        class:
          "fix-get-selection computed-other-property-value theme-fg-color1",
      });
      valueDiv.appendChild(
        this.#parseValue(
          selector.selectorInfo.value,
          selector.selectorInfo.rule.href || selector.selectorInfo.rule.nodeHref
        )
      );

      // If the value is invalid at computed value time (IACVT), display the same
      // warning icon that we have in the rules view for IACVT declarations.
      if (selector.selectorInfo.invalidAtComputedValueTime) {
        createChild(status, "div", {
          class: "invalid-at-computed-value-time-warning",
          title: STYLE_INSPECTOR_L10N.getFormatStr(
            "rule.warningInvalidAtComputedValueTime.title",
            `"${selector.selectorInfo.registeredPropertySyntax}"`
          ),
        });
      }

      // We only want to show the "Jump to definition" icon if the declaration is in
      // the RuleView
      if (selector.isInRuleView()) {
        const ruleLink = createChild(status, "button", {
          class: "computed-other-property-ruleview-link jump-definition",
          title: L10N_JUMP_DEFINITION_TITLE,
        });
        ruleLink.addEventListener("click", selector.focusPropertyInRuleView);
      }
    }

    if (this.registeredPropertyInitialValue !== undefined) {
      const p = createChild(frag, "p");
      const status = createChild(p, "span", {
        dir: "ltr",
        class: "rule-text theme-fg-color3",
      });

      createChild(status, "div", {
        class: "fix-get-selection",
        textContent: "initial-value",
      });

      const valueDiv = createChild(status, "div", {
        class:
          "fix-get-selection computed-other-property-value theme-fg-color1",
      });
      valueDiv.appendChild(
        this.#parseValue(this.registeredPropertyInitialValue)
      );
    }

    this.matchedSelectorsContainer.innerHTML = "";
    this.matchedSelectorsContainer.appendChild(frag);
  }

  /**
   * Parse a property value using the OutputParser.
   *
   * @param {string} value
   * @param {string} baseURI
   * @returns {DocumentFragment|Element}
   */
  #parseValue(value, baseURI) {
    if (this.isCustomProperty && value === "") {
      const doc = this.#tree.styleDocument;
      const el = doc.createElement("span");
      el.classList.add("empty-css-variable");
      el.append(doc.createTextNode(`<${L10N_EMPTY_VARIABLE}>`));
      return el;
    }

    // Sadly, because this fragment is added to the template by DOM Templater
    // we lose any events that are attached. This means that URLs will open in a
    // new window. At some point we should fix this by stopping using the
    // templater.
    return this.#tree.outputParser.parseCssProperty(this.name, value, {
      colorSwatchClass: "inspector-swatch inspector-colorswatch",
      colorSwatchReadOnly: true,
      colorClass: "computed-color",
      urlClass: "theme-link",
      fontFamilyClass: "computed-font-family",
      baseURI,
    });
  }

  /**
   * Provide access to the matched SelectorViews that we are currently
   * displaying.
   */
  get matchedSelectorViews() {
    if (!this.#matchedSelectorViews) {
      this.#matchedSelectorViews = [];
      this.#matchedSelectorResponse.forEach(selectorInfo => {
        const selectorView = new SelectorView(this.#tree, selectorInfo);
        this.#matchedSelectorViews.push(selectorView);
      }, this);
    }
    return this.#matchedSelectorViews;
  }

  /**
   * The action when a user expands matched selectors.
   *
   * @param {Event} event
   *        Used to determine the class name of the targets click
   *        event.
   */
  onMatchedToggle(event) {
    if (event.shiftKey) {
      return;
    }
    this.matchedExpanded = !this.matchedExpanded;
    this.refreshMatchedSelectors();
    event.preventDefault();
  }

  /**
   * The action when a user clicks on the MDN help link for a property.
   */
  mdnLinkClick() {
    if (!this.link) {
      return;
    }
    openContentLink(this.link);
  }

  /**
   * Destroy this property view, removing event listeners
   */
  destroy() {
    if (this.#matchedSelectorViews) {
      for (const view of this.#matchedSelectorViews) {
        view.destroy();
      }
    }

    if (this.#abortController) {
      this.#abortController.abort();
      this.#abortController = null;
    }

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

    this.shortcuts = null;
    this.element = null;
    this.#matchedExpander = null;
    this.valueNode = null;
  }
}

/**
 * A container to give us easy access to display data from a CssRule
 */
class SelectorView {
  /**
   * @param CssComputedView tree
   *        the owning CssComputedView
   * @param selectorInfo
   */
  constructor(tree, selectorInfo) {
    this.#tree = tree;
    this.selectorInfo = selectorInfo;
    this.#cacheStatusNames();

    this.openStyleEditor = this.openStyleEditor.bind(this);
    this.focusPropertyInRuleView = this.focusPropertyInRuleView.bind(this);

    const rule = this.selectorInfo.rule;
    if (rule?.parentStyleSheet) {
      // This always refers to the generated location.
      const sheet = rule.parentStyleSheet;
      const sourceSuffix = rule.line > 0 ? ":" + rule.line : "";
      this.source = CssLogic.shortSource(sheet) + sourceSuffix;
      this.longSource = CssLogic.longSource(sheet) + sourceSuffix;

      this.#generatedLocation = {
        sheet,
        href: sheet.href || sheet.nodeHref,
        line: rule.line,
        column: rule.column,
      };
      this.#unsubscribeCallback =
        this.#tree.inspector.toolbox.sourceMapURLService.subscribeByID(
          this.#generatedLocation.sheet.resourceId,
          this.#generatedLocation.line,
          this.#generatedLocation.column,
          this.#updateLocation
        );
    }
  }

  #generatedLocation;
  #href;
  #tree;
  #unsubscribeCallback;

  /**
   * Decode for cssInfo.rule.status
   *
   * @see SelectorView.prototype.#cacheStatusNames
   * @see CssLogic.STATUS
   */
  static STATUS_NAMES = [
    // "Parent Match", "Matched", "Best Match"
  ];

  static CLASS_NAMES = ["parentmatch", "matched", "bestmatch"];

  /**
   * Cache localized status names.
   *
   * These statuses are localized inside the styleinspector.properties string
   * bundle.
   *
   * @see css-logic.js - the CssLogic.STATUS array.
   */
  #cacheStatusNames() {
    if (SelectorView.STATUS_NAMES.length) {
      return;
    }

    for (const status in CssLogic.STATUS) {
      const i = CssLogic.STATUS[status];
      if (i > CssLogic.STATUS.UNMATCHED) {
        const value = CssComputedView.l10n("rule.status." + status);
        // Replace normal spaces with non-breaking spaces
        SelectorView.STATUS_NAMES[i] = value.replace(/ /g, "\u00A0");
      }
    }
  }

  /**
   * A localized version of cssRule.status
   */
  get statusText() {
    return SelectorView.STATUS_NAMES[this.selectorInfo.status];
  }

  /**
   * Get class name for selector depending on status
   */
  get statusClass() {
    return SelectorView.CLASS_NAMES[this.selectorInfo.status - 1];
  }

  get href() {
    if (this.#href) {
      return this.#href;
    }
    const sheet = this.selectorInfo.rule.parentStyleSheet;
    this.#href = sheet ? sheet.href : "#";
    return this.#href;
  }

  get sourceText() {
    return this.selectorInfo.sourceText;
  }

  get value() {
    return this.selectorInfo.value;
  }

  /**
   * Update the text of the source link to reflect whether we're showing
   * original sources or not.  This is a callback for
   * SourceMapURLService.subscribe, which see.
   *
   * @param {object | null} originalLocation
   *        The original position object (url/line/column) or null.
   */
  #updateLocation = originalLocation => {
    if (!this.#tree.element) {
      return;
    }

    // Update |currentLocation| to be whichever location is being
    // displayed at the moment.
    let currentLocation = this.#generatedLocation;
    if (originalLocation) {
      const { url, line, column } = originalLocation;
      currentLocation = { href: url, line, column };
    }

    const selector = '[sourcelocation="' + this.source + '"]';
    const link = this.#tree.element.querySelector(selector);
    if (link) {
      const text =
        CssLogic.shortSource(currentLocation) + ":" + currentLocation.line;
      link.textContent = text;
    }

    this.#tree.inspector.emit("computed-view-sourcelinks-updated");
  };

  /**
   * When a css link is clicked this method is called in order to either:
   *   1. Open the link in view source (for chrome stylesheets).
   *   2. Open the link in the style editor.
   *
   *   We can only view stylesheets contained in document.styleSheets inside the
   *   style editor.
   */
  openStyleEditor() {
    const inspector = this.#tree.inspector;
    const rule = this.selectorInfo.rule;

    // The style editor can only display stylesheets coming from content because
    // chrome stylesheets are not listed in the editor's stylesheet selector.
    //
    // If the stylesheet is a content stylesheet we send it to the style
    // editor else we display it in the view source window.
    const parentStyleSheet = rule.parentStyleSheet;
    if (!parentStyleSheet || parentStyleSheet.isSystem) {
      inspector.toolbox.viewSource(rule.href, rule.line);
      return;
    }

    const { sheet, line, column } = this.#generatedLocation;
    if (ToolDefinitions.styleEditor.isToolSupported(inspector.toolbox)) {
      inspector.toolbox.viewStyleSourceByResource(sheet, line, column);
    }
  }

  /**
   * Returns whether or not the underlying rule is in the rules view
   *
   * @returns {boolean}
   */
  isInRuleView() {
    const rule = this.selectorInfo.rule;
    if (!rule) {
      return false;
    }

    return this.#tree.ruleView.hasRule(rule);
  }

  /**
   * Open the RuleView and highlight the property represented by this SelectorView
   */
  focusPropertyInRuleView() {
    const ruleFront = this.selectorInfo.rule;
    this.#tree.ruleView.highlightProperty(this.selectorInfo.name, {
      ruleFront,
      focusValue: true,
    });
  }

  /**
   * Destroy this selector view, removing event listeners
   */
  destroy() {
    if (this.#unsubscribeCallback) {
      this.#unsubscribeCallback();
    }
  }
}

class ComputedViewTool {
  /**
   * @param {Inspector} inspector
   * @param {Window} window
   */
  constructor(inspector, window) {
    this.inspector = inspector;
    this.document = window.document;

    this.computedView = new CssComputedView(this.inspector, this.document);

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

    this.#abortController = new AbortController();
    const opts = { signal: this.#abortController.signal };
    this.inspector.selection.on("detached-front", this.onDetachedFront, opts);
    this.inspector.selection.on("new-node-front", this.onSelected, opts);
    this.inspector.selection.on("pseudoclass", this.refresh, opts);
    this.inspector.sidebar.on(
      "computedview-selected",
      this.onPanelSelected,
      opts
    );
    this.inspector.styleChangeTracker.on(
      "style-changed",
      () => {
        // `refresh` may not actually update the styles if the computed panel is hidden
        // so use a flag to force updating the element styles the next time the computed
        // panel refreshes.
        this.computedView.elementStyleUpdated = true;
        this.refresh();
      },
      opts
    );

    this.computedView.selectElement(null);

    this.onSelected();
  }

  #abortController;

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

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

  async 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.computedView) {
      return;
    }

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

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

    if (selectElement) {
      const done = this.inspector.updating("computed-view");
      await this.computedView.selectElement(this.inspector.selection.nodeFront);
      done();
    }
  }

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

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

  destroy() {
    this.#abortController.abort();
    this.computedView.destroy();

    this.computedView =
      this.document =
      this.inspector =
      this.#abortController =
        null;
  }
}

exports.CssComputedView = CssComputedView;
exports.ComputedViewTool = ComputedViewTool;
exports.PropertyView = PropertyView;
