/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
const flags = require("resource://devtools/shared/flags.js");
const { executeSoon } = require("resource://devtools/shared/DevToolsUtils.js");
const { Toolbox } = require("resource://devtools/client/framework/toolbox.js");
const createStore = require("resource://devtools/client/inspector/store.js");
const InspectorStyleChangeTracker = require("resource://devtools/client/inspector/shared/style-change-tracker.js");
const { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
const {
  START_IGNORE_ACTION,
} = require("resource://devtools/client/shared/redux/middleware/ignore.js");

// Use privileged promise in panel documents to prevent having them to freeze
// during toolbox destruction. See bug 1402779.
const Promise = require("Promise");
const osString = Services.appinfo.OS;

loader.lazyRequireGetter(
  this,
  "HTMLBreadcrumbs",
  "resource://devtools/client/inspector/breadcrumbs.js",
  true
);
loader.lazyRequireGetter(
  this,
  "KeyShortcuts",
  "resource://devtools/client/shared/key-shortcuts.js"
);
loader.lazyRequireGetter(
  this,
  "InspectorSearch",
  "resource://devtools/client/inspector/inspector-search.js",
  true
);
loader.lazyRequireGetter(
  this,
  "ToolSidebar",
  "resource://devtools/client/inspector/toolsidebar.js",
  true
);
loader.lazyRequireGetter(
  this,
  "MarkupView",
  "resource://devtools/client/inspector/markup/markup.js"
);
loader.lazyRequireGetter(
  this,
  "HighlightersOverlay",
  "resource://devtools/client/inspector/shared/highlighters-overlay.js"
);
loader.lazyRequireGetter(
  this,
  "PICKER_TYPES",
  "resource://devtools/shared/picker-constants.js"
);
loader.lazyRequireGetter(
  this,
  "captureAndSaveScreenshot",
  "resource://devtools/client/shared/screenshot.js",
  true
);
loader.lazyRequireGetter(
  this,
  "debounce",
  "resource://devtools/shared/debounce.js",
  true
);

const {
  LocalizationHelper,
  localizeMarkup,
} = require("resource://devtools/shared/l10n.js");
const INSPECTOR_L10N = new LocalizationHelper(
  "devtools/client/locales/inspector.properties"
);
const {
  FluentL10n,
} = require("resource://devtools/client/shared/fluent-l10n/fluent-l10n.js");

// Sidebar dimensions
const INITIAL_SIDEBAR_SIZE = 350;

// How long we wait to debounce resize events
const LAZY_RESIZE_INTERVAL_MS = 200;

// If the toolbox's width is smaller than the given amount of pixels, the sidebar
// automatically switches from 'landscape/horizontal' to 'portrait/vertical' mode.
const PORTRAIT_MODE_WIDTH_THRESHOLD = 700;
// If the toolbox's width docked to the side is smaller than the given amount of pixels,
// the sidebar automatically switches from 'landscape/horizontal' to 'portrait/vertical'
// mode.
const SIDE_PORTAIT_MODE_WIDTH_THRESHOLD = 1000;

const THREE_PANE_ENABLED_PREF = "devtools.inspector.three-pane-enabled";
const THREE_PANE_CHROME_ENABLED_PREF =
  "devtools.inspector.chrome.three-pane-enabled";
const DEFAULT_COLOR_UNIT_PREF = "devtools.defaultColorUnit";

/**
 * Represents an open instance of the Inspector for a tab.
 * The inspector controls the breadcrumbs, the markup view, and the sidebar
 * (computed view, rule view, font view and animation inspector).
 *
 * Events:
 * - ready
 *      Fired when the inspector panel is opened for the first time and ready to
 *      use
 * - new-root
 *      Fired after a new root (navigation to a new page) event was fired by
 *      the walker, and taken into account by the inspector (after the markup
 *      view has been reloaded)
 * - markuploaded
 *      Fired when the markup-view frame has loaded
 * - breadcrumbs-updated
 *      Fired when the breadcrumb widget updates to a new node
 * - boxmodel-view-updated
 *      Fired when the box model updates to a new node
 * - markupmutation
 *      Fired after markup mutations have been processed by the markup-view
 * - computed-view-refreshed
 *      Fired when the computed rules view updates to a new node
 * - computed-view-property-expanded
 *      Fired when a property is expanded in the computed rules view
 * - computed-view-property-collapsed
 *      Fired when a property is collapsed in the computed rules view
 * - computed-view-sourcelinks-updated
 *      Fired when the stylesheet source links have been updated (when switching
 *      to source-mapped files)
 * - rule-view-refreshed
 *      Fired when the rule view updates to a new node
 * - rule-view-sourcelinks-updated
 *      Fired when the stylesheet source links have been updated (when switching
 *      to source-mapped files)
 */
class Inspector extends EventEmitter {
  constructor(toolbox, commands, win) {
    super();

    this.#toolbox = toolbox;
    this.#commands = commands;
    this.panelDoc = win.document;
    this.panelWin = win;
    this.panelWin.inspector = this;
    this.telemetry = toolbox.telemetry;
    this.store = createStore(this);

    this.onResourceAvailable = this.onResourceAvailable.bind(this);
    this.onRootNodeAvailable = this.onRootNodeAvailable.bind(this);
    this.onPickerCanceled = this.onPickerCanceled.bind(this);
    this.onPickerHovered = this.onPickerHovered.bind(this);
    this.onPickerPicked = this.onPickerPicked.bind(this);
    this.onSidebarHidden = this.onSidebarHidden.bind(this);
    this.onSidebarResized = this.onSidebarResized.bind(this);
    this.onSidebarSelect = this.onSidebarSelect.bind(this);
    this.onSidebarShown = this.onSidebarShown.bind(this);
    this.onSidebarToggle = this.onSidebarToggle.bind(this);
    this.addNode = this.addNode.bind(this);
    this.onEyeDropperDone = this.onEyeDropperDone.bind(this);
    this.onEyeDropperButtonClicked = this.onEyeDropperButtonClicked.bind(this);

    this.prefObserver = new PrefObserver("devtools.");
    this.prefObserver.on(
      DEFAULT_COLOR_UNIT_PREF,
      this.#handleDefaultColorUnitPrefChange
    );
    this.defaultColorUnit = Services.prefs.getStringPref(
      DEFAULT_COLOR_UNIT_PREF
    );
  }

  #toolbox;
  #commands;
  // Map [panel id => panel instance]
  // Stores all the instances of sidebar panels like rule view, computed view, ...
  #panels = new Map();
  #fluentL10n;
  #defaultStartupNode;
  #defaultStartupNodeDomReference;
  #defaultStartupNodeSelectionReason;
  #defaultNode;
  #watchedResources;
  #highlighters;
  #newRootStart;
  #markupFrame;
  #markupBox;
  #isThreePaneModeEnabled;
  #search;
  #cssProperties;
  #destroyed;
  #pendingSelectionUnique;
  #InspectorTabPanel;
  #InspectorSplitBox;
  #TabBar;
  #updateProgress;

  /**
   * InspectorPanel.open() is effectively an asynchronous constructor.
   * Set any attributes or listeners that rely on the document being loaded or fronts
   * from the InspectorFront and Target here.
   *
   * @param {object} options
   * @param {NodeFront|undefined} options.defaultStartupNode: Optional node front that
   *        will be selected when the first root node is available.
   * @param {ElementIdentifier|undefined} options.defaultStartupNodeDomReference: Optional
   *        element identifier whose matching node front will be selected when the first
   *        root node is available.
   *        Will be ignored if defaultStartupNode is passed.
   * @param {string | undefined} options.defaultStartupNodeSelectionReason: Optional string
   *        that will be used as a reason for the node selection when either
   *        defaultStartupNode or defaultStartupNodeDomReference is passed
   * @returns {Inspector}
   */
  async init(options = {}) {
    // Localize all the nodes containing a data-localization attribute.
    localizeMarkup(this.panelDoc);

    this.#fluentL10n = new FluentL10n();
    await this.#fluentL10n.init(["devtools/client/compatibility.ftl"]);

    // Add the class that will display the main inspector panel with: search input,
    // markup view and breadcrumbs.
    this.panelDoc
      .getElementById("inspector-main-content")
      .classList.add("initialized");

    // Setup the splitter before watching targets & resources.
    // The markup view will be initialized after we get the first root-node
    // resource, and the splitter should be initialized before that.
    // The markup view is rendered in an iframe and the splitter will move the
    // parent of the iframe in the DOM tree which would reset the state of the
    // iframe if it had already been initialized.
    this.#setupSplitter();

    // Optional NodeFront/ElementIdentifier set on inspector startup, to be selected once the first root
    // node is available.
    this.#defaultStartupNode = options.defaultStartupNode;
    this.#defaultStartupNodeDomReference =
      options.defaultStartupNodeDomReference;
    this.#defaultStartupNodeSelectionReason =
      options.defaultStartupNodeSelectionReason;

    // NodeFront for the DOM Element selected when opening the inspector, or after each
    // navigation (i.e. each time a new Root Node is available)
    // This is used as a fallback if the currently selected node is removed.
    this.#defaultNode = null;

    await this.commands.targetCommand.watchTargets({
      types: [this.commands.targetCommand.TYPES.FRAME],
      onAvailable: this.#onTargetAvailable,
      onSelected: this.#onTargetSelected,
      onDestroyed: this.#onTargetDestroyed,
    });

    const { TYPES } = this.commands.resourceCommand;
    this.#watchedResources = [
      // To observe CSS change before opening changes view.
      TYPES.CSS_CHANGE,
      TYPES.DOCUMENT_EVENT,
      TYPES.REFLOW,
    ];
    // The root node is retrieved from onTargetSelected which is now called
    // on startup as well as on any navigation (= new top level target).
    //
    // We only listen to new root node in the browser toolbox, which is the last
    // configuration to use one target for multiple window global.
    const isBrowserToolbox =
      this.commands.descriptorFront.isBrowserProcessDescriptor;
    if (isBrowserToolbox) {
      this.#watchedResources.push(TYPES.ROOT_NODE);
    }

    await this.commands.resourceCommand.watchResources(this.#watchedResources, {
      onAvailable: this.onResourceAvailable,
    });

    // Store the URL of the target page prior to navigation in order to ensure
    // telemetry counts in the Grid Inspector are not double counted on reload.
    this.previousURL = this.currentTarget.url;

    // Note: setupSidebar() really has to be called after the first target has
    // been processed, so that the cssProperties getter works.
    // But the rest could be moved before the watch* calls.
    this.styleChangeTracker = new InspectorStyleChangeTracker(this);
    this.#setupSidebar();
    this.breadcrumbs = new HTMLBreadcrumbs(this);
    this.#setupExtensionSidebars();
    this.#setupSearchBox();
    this.#createInspectorShortcuts();

    this.#onNewSelection();

    this.toolbox.on("host-changed", this.#onHostChanged);
    this.toolbox.nodePicker.on("picker-node-hovered", this.onPickerHovered);
    this.toolbox.nodePicker.on("picker-node-canceled", this.onPickerCanceled);
    this.toolbox.nodePicker.on("picker-node-picked", this.onPickerPicked);
    this.selection.on("new-node-front", this.#onNewSelection);
    this.selection.on("detached-front", this.#onDetached);

    // Log the 3 pane inspector setting on inspector open. The question we want to answer
    // is:
    // "What proportion of users use the 3 pane vs 2 pane inspector on inspector open?"
    Glean.devtoolsInspector.threePaneEnabled[this.isThreePaneModeEnabled].add(
      1
    );

    return this;
  }

  // The onTargetAvailable argument is mandatory for TargetCommand.watchTargets.
  // The inspector ignore all targets but the currently selected one,
  // so all the target work is done from onTargetSelected.
  #onTargetAvailable = async ({ targetFront }) => {
    if (!targetFront.isTopLevel) {
      return;
    }

    // Fetch data and fronts which aren't WindowGlobal specific
    // and can be fetched once from the top level target.
    await Promise.all([
      this.#getCssProperties(targetFront),
      this.#getAccessibilityFront(targetFront),
    ]);
  };

  #onTargetSelected = async ({ targetFront }) => {
    // We don't use this.highlighters since it creates a HighlightersOverlay if it wasn't
    // the case yet.
    if (this.#highlighters) {
      this.#highlighters.hideAllHighlighters();
    }
    if (targetFront.isDestroyed()) {
      return;
    }

    await this.#initInspectorFront(targetFront);

    // the target might have been destroyed when reloading quickly,
    // while waiting for inspector front initialization
    if (targetFront.isDestroyed()) {
      return;
    }

    const { walker } = await targetFront.getFront("inspector");
    const rootNodeFront = await walker.getRootNode();

    // onRootNodeAvailable will take care of populating the markup view
    await this.onRootNodeAvailable(rootNodeFront);
  };

  #onTargetDestroyed = ({ targetFront }) => {
    // Ignore all targets but the top level one
    if (!targetFront.isTopLevel) {
      return;
    }

    this.#defaultNode = null;
    this.selection.setNodeFront(null);
  };

  onResourceAvailable(resources) {
    // Store all onRootNodeAvailable calls which are asynchronous.
    const rootNodeAvailablePromises = [];

    for (const resource of resources) {
      const isTopLevelTarget = !!resource.targetFront?.isTopLevel;
      const isTopLevelDocument = !!resource.isTopLevelDocument;

      if (
        resource.resourceType ===
          this.commands.resourceCommand.TYPES.ROOT_NODE &&
        // It might happen that the ROOT_NODE resource (which is a Front) is already
        // destroyed, and in such case we want to ignore it.
        !resource.isDestroyed() &&
        isTopLevelTarget &&
        isTopLevelDocument
      ) {
        rootNodeAvailablePromises.push(this.onRootNodeAvailable(resource));
      }

      // Only consider top level document, and ignore remote iframes top document
      if (
        resource.resourceType ===
          this.commands.resourceCommand.TYPES.DOCUMENT_EVENT &&
        resource.name === "will-navigate" &&
        isTopLevelTarget
      ) {
        this.#onWillNavigate();
      }

      if (
        resource.resourceType === this.commands.resourceCommand.TYPES.REFLOW
      ) {
        this.emit("reflow");
        if (resource.targetFront === this.selection?.nodeFront?.targetFront) {
          // This event will be fired whenever a reflow is detected in the target front of the
          // selected node front (so when a reflow is detected inside any of the windows that
          // belong to the BrowsingContext where the currently selected node lives).
          this.emit("reflow-in-selected-target");
        }
      }
    }

    return Promise.all(rootNodeAvailablePromises);
  }

  /**
   * Reset the inspector on new root mutation.
   */
  async onRootNodeAvailable(rootNodeFront) {
    // Record new-root timing for telemetry
    this.#newRootStart = this.panelWin.performance.now();

    this.selection.setNodeFront(null);
    this.#destroyMarkup();

    try {
      const defaultNode = await this.#getDefaultNodeForSelection(rootNodeFront);
      if (!defaultNode) {
        return;
      }

      this.selection.setNodeFront(defaultNode, {
        reason:
          this.#defaultStartupNodeSelectionReason ??
          "inspector-default-selection",
      });
      this.#defaultStartupNodeSelectionReason = null;

      await this.#initMarkupView();

      // Setup the toolbar again, since its content may depend on the current document.
      this.#setupToolbar();
    } catch (e) {
      this.#handleRejectionIfNotDestroyed(e);
    }
  }

  async #initMarkupView() {
    if (!this.#markupFrame) {
      this.#markupFrame = this.panelDoc.createElement("iframe");
      this.#markupFrame.setAttribute(
        "aria-label",
        INSPECTOR_L10N.getStr("inspector.panelLabel.markupView")
      );
      this.#markupFrame.setAttribute("flex", "1");
      // This is needed to enable tooltips inside the iframe document.
      this.#markupFrame.setAttribute("tooltip", "aHTMLTooltip");

      this.#markupBox = this.panelDoc.getElementById("markup-box");
      this.#markupBox.style.visibility = "hidden";
      this.#markupBox.appendChild(this.#markupFrame);

      const onMarkupFrameLoaded = new Promise(r =>
        this.#markupFrame.addEventListener("load", r, {
          capture: true,
          once: true,
        })
      );

      this.#markupFrame.setAttribute("src", "markup/markup.xhtml");

      await onMarkupFrameLoaded;
    }

    this.#markupFrame.contentWindow.focus();
    this.#markupBox.style.visibility = "visible";
    this.markup = new MarkupView(this, this.#markupFrame, this.#toolbox.win);
    // TODO: We might be able to merge markuploaded, new-root and reloaded.
    this.emitForTests("markuploaded");

    const onExpand = this.markup.expandNode(this.selection.nodeFront);

    // Restore the highlighter states prior to emitting "new-root".
    if (this.#highlighters) {
      await Promise.all([
        this.highlighters.restoreFlexboxState(),
        this.highlighters.restoreGridState(),
      ]);
    }
    this.emit("new-root");

    // Wait for full expand of the selected node in order to ensure
    // the markup view is fully emitted before firing 'reloaded'.
    // 'reloaded' is used to know when the panel is fully updated
    // after a page reload.
    await onExpand;

    this.emit("reloaded");

    // Record the time between new-root event and inspector fully loaded.
    if (this.#newRootStart) {
      // Only log the timing when inspector is not destroyed and is in foreground.
      if (this.toolbox && this.toolbox.currentToolId == "inspector") {
        const delay = this.panelWin.performance.now() - this.#newRootStart;
        Glean.devtoolsInspector.newRootToReloadDelay.accumulateSingleSample(
          delay
        );
      }
      this.#newRootStart = null;
    }
  }

  async #initInspectorFront(targetFront) {
    this.inspectorFront = await targetFront.getFront("inspector");
    this.walker = this.inspectorFront.walker;
  }

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

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

  /**
   * Get the list of InspectorFront instances that correspond to all of the inspectable
   * targets in remote frames nested within the document inspected here, as well as the
   * current InspectorFront instance.
   *
   * @return {Array} The list of InspectorFront instances.
   */
  async getAllInspectorFronts() {
    return this.commands.targetCommand.getAllFronts(
      [this.commands.targetCommand.TYPES.FRAME],
      "inspector"
    );
  }

  get highlighters() {
    if (!this.#highlighters) {
      this.#highlighters = new HighlightersOverlay(this);
    }

    return this.#highlighters;
  }

  get #threePanePrefName() {
    // All other contexts: webextension and browser toolbox
    // are considered as "chrome"
    return this.commands.descriptorFront.isTabDescriptor
      ? THREE_PANE_ENABLED_PREF
      : THREE_PANE_CHROME_ENABLED_PREF;
  }

  get isThreePaneModeEnabled() {
    if (!this.#isThreePaneModeEnabled) {
      this.#isThreePaneModeEnabled = Services.prefs.getBoolPref(
        this.#threePanePrefName
      );
    }
    return this.#isThreePaneModeEnabled;
  }

  set isThreePaneModeEnabled(value) {
    this.#isThreePaneModeEnabled = value;
    Services.prefs.setBoolPref(
      this.#threePanePrefName,
      this.#isThreePaneModeEnabled
    );
  }

  get search() {
    if (!this.#search) {
      this.#search = new InspectorSearch(
        this,
        this.searchBox,
        this.searchClearButton,
        this.searchPrevButton,
        this.searchNextButton
      );
    }

    return this.#search;
  }

  get selection() {
    return this.toolbox.selection;
  }

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

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

  // Duration in milliseconds after which to hide the highlighter for the picked node.
  // While testing, disable auto hiding to prevent intermittent test failures.
  // Some tests are very slow. If the highlighter is hidden after a delay, the test may
  // find itself midway through without a highlighter to test.
  // This value is exposed on Inspector so individual tests can restore it when needed.
  HIGHLIGHTER_AUTOHIDE_TIMER = flags.testing ? 0 : 1000;

  #handleDefaultColorUnitPrefChange = () => {
    this.defaultColorUnit = Services.prefs.getStringPref(
      DEFAULT_COLOR_UNIT_PREF
    );
  };

  /**
   * Handle promise rejections for various asynchronous actions, and only log errors if
   * the inspector panel still exists.
   * This is useful to silence useless errors that happen when the inspector is closed
   * while still initializing (and making protocol requests).
   */
  #handleRejectionIfNotDestroyed = e => {
    if (!this.#destroyed) {
      console.error(e);
    }
  };

  #onWillNavigate = () => {
    this.#defaultNode = null;
    this.selection.setNodeFront(null);
    if (this.#highlighters) {
      this.#highlighters.hideAllHighlighters();
    }
    this.#destroyMarkup();
    this.#pendingSelectionUnique = null;
  };

  async #getCssProperties(targetFront) {
    this.#cssProperties = await targetFront.getFront("cssProperties");
  }

  async #getAccessibilityFront(targetFront) {
    this.accessibilityFront = await targetFront.getFront("accessibility");
    return this.accessibilityFront;
  }

  /**
   * Return a promise that will resolve to the default node for selection.
   *
   * @param {NodeFront} rootNodeFront
   *        The current root node front for the top walker.
   */
  async #getDefaultNodeForSelection(rootNodeFront) {
    let node;
    if (this.#defaultStartupNode) {
      node = this.#defaultStartupNode;
      this.#defaultStartupNode = null;
      this.#defaultStartupNodeDomReference = null;
      return node;
    }

    // Save the _pendingSelectionUnique on the current inspector instance.
    const pendingSelectionUnique = Symbol("pending-selection");
    this.#pendingSelectionUnique = pendingSelectionUnique;

    if (this.#defaultStartupNodeDomReference) {
      const domReference = this.#defaultStartupNodeDomReference;
      // nullify before calling the async getNodeActorFromContentDomReference so calls
      // made to getDefaultNodeForSelection while the promise is pending will be properly
      // ignored with the check on pendingSelectionUnique
      this.#defaultStartupNode = null;
      this.#defaultStartupNodeDomReference = null;

      try {
        node =
          await this.inspectorFront.getNodeActorFromContentDomReference(
            domReference
          );
      } catch (e) {
        console.warn(
          "Couldn't retrieve node front from dom reference",
          domReference
        );
      }
    }

    if (this.#pendingSelectionUnique !== pendingSelectionUnique) {
      // If this method was called again while waiting, bail out.
      return null;
    }

    if (node) {
      return node;
    }

    const walker = rootNodeFront.walkerFront;
    const cssSelectors = this.selectionCssSelectors;
    // Try to find a default node using three strategies:
    const defaultNodeSelectors = [
      // - first try to match css selectors for the selection
      () =>
        cssSelectors.length
          ? this.commands.inspectorCommand.findNodeFrontFromSelectors(
              cssSelectors
            )
          : null,
      // - otherwise try to get the "body" element
      () => walker.querySelector(rootNodeFront, "body"),
      // - finally get the documentElement element if nothing else worked.
      () => walker.documentElement(),
    ];

    // Try all default node selectors until a valid node is found.
    for (const selector of defaultNodeSelectors) {
      node = await selector();
      if (this.#pendingSelectionUnique !== pendingSelectionUnique) {
        // If this method was called again while waiting, bail out.
        return null;
      }

      if (node) {
        this.#defaultNode = node;
        return node;
      }
    }

    return null;
  }

  /**
   * Top level target front getter.
   */
  get currentTarget() {
    return this.commands.targetCommand.selectedTargetFront;
  }

  /**
   * Hooks the searchbar to show result and auto completion suggestions.
   */
  #setupSearchBox() {
    this.searchBox = this.panelDoc.getElementById("inspector-searchbox");
    this.searchClearButton = this.panelDoc.getElementById(
      "inspector-searchinput-clear"
    );
    this.searchResultsContainer = this.panelDoc.getElementById(
      "inspector-searchlabel-container"
    );
    this.searchNavigationContainer = this.panelDoc.getElementById(
      "inspector-searchnavigation-container"
    );
    this.searchPrevButton = this.panelDoc.getElementById(
      "inspector-searchnavigation-button-prev"
    );
    this.searchNextButton = this.panelDoc.getElementById(
      "inspector-searchnavigation-button-next"
    );
    this.searchResultsLabel = this.panelDoc.getElementById(
      "inspector-searchlabel"
    );

    this.searchResultsLabel.addEventListener("click", this.#onSearchLabelClick);

    this.searchBox.addEventListener("focus", this.#listenForSearchEvents, {
      once: true,
    });
  }

  #onSearchLabelClick = () => {
    // Focus on the search box as the search label
    // appears to be "inside" input
    this.searchBox.focus();
  };

  #listenForSearchEvents = () => {
    this.search.on("search-cleared", this.#clearSearchResultsLabel);
    this.search.on("search-result", this.#updateSearchResultsLabel);
  };

  #isFromInspectorWindow = event => {
    const win = event.originalTarget.ownerGlobal;
    return win === this.panelWin || win.parent === this.panelWin;
  };

  #createInspectorShortcuts = () => {
    this.inspectorShortcuts = new KeyShortcuts({
      window: this.panelDoc.defaultView,
      // The inspector search shortcuts need to be available from everywhere in the
      // inspector, and the inspector uses iframes (markupview, sidepanel webextensions).
      // Use the chromeEventHandler as the target to catch events from all frames.
      target: this.toolbox.getChromeEventHandler(),
    });

    const searchboxKey = INSPECTOR_L10N.getStr("inspector.searchHTML.key");
    this.inspectorShortcuts.on(searchboxKey, event => {
      // Prevent overriding same shortcut from the computed/rule views
      if (
        event.originalTarget.closest("#sidebar-panel-ruleview") ||
        event.originalTarget.closest("#sidebar-panel-computedview") ||
        !this.#isFromInspectorWindow(event)
      ) {
        return;
      }
      event.preventDefault();
      this.searchBox.focus();
    });
    const eyedropperKey = INSPECTOR_L10N.getStr("inspector.eyedropper.key");
    this.inspectorShortcuts.on(eyedropperKey, event => {
      if (!this.#isFromInspectorWindow(event)) {
        return;
      }
      event.preventDefault();
      this.onEyeDropperButtonClicked();
    });
  };

  get searchSuggestions() {
    return this.search.autocompleter;
  }

  #clearSearchResultsLabel = result => {
    // Pipe the search-cleared event as this.search is a getter that will create
    // the InspectorSearch instance, which we don't really need/want when a callsite
    // only want to react to the search being cleared.
    this.emit("search-cleared");
    return this.#updateSearchResultsLabel(result, true);
  };

  #updateSearchResultsLabel = (result, clear = false) => {
    let str = "";
    if (!clear) {
      if (result) {
        str = INSPECTOR_L10N.getFormatStr(
          "inspector.searchResultsCount2",
          result.resultsIndex + 1,
          result.resultsLength
        );
        this.searchNavigationContainer.hidden = false;
      } else {
        str = INSPECTOR_L10N.getStr("inspector.searchResultsNone");
        this.searchNavigationContainer.hidden = true;
      }

      this.searchResultsContainer.hidden = false;
    } else {
      this.searchResultsContainer.hidden = true;
    }

    this.searchResultsLabel.textContent = str;
  };

  get React() {
    return this.#toolbox.React;
  }

  get ReactDOM() {
    return this.#toolbox.ReactDOM;
  }

  get ReactRedux() {
    return this.#toolbox.ReactRedux;
  }

  get browserRequire() {
    return this.#toolbox.browserRequire;
  }

  get InspectorTabPanel() {
    if (!this.#InspectorTabPanel) {
      this.#InspectorTabPanel = this.React.createFactory(
        this.browserRequire(
          "devtools/client/inspector/components/InspectorTabPanel"
        )
      );
    }
    return this.#InspectorTabPanel;
  }

  get InspectorSplitBox() {
    if (!this.#InspectorSplitBox) {
      this.#InspectorSplitBox = this.React.createFactory(
        this.browserRequire(
          "devtools/client/shared/components/splitter/SplitBox"
        )
      );
    }
    return this.#InspectorSplitBox;
  }

  get TabBar() {
    if (!this.#TabBar) {
      this.#TabBar = this.React.createFactory(
        this.browserRequire("devtools/client/shared/components/tabs/TabBar")
      );
    }
    return this.#TabBar;
  }

  /**
   * Check if the inspector should use the landscape mode.
   *
   * @return {boolean} true if the inspector should be in landscape mode.
   */
  #useLandscapeMode() {
    if (!this.panelDoc) {
      return true;
    }

    const splitterBox = this.panelDoc.getElementById("inspector-splitter-box");
    const width = splitterBox.clientWidth;

    return this.isThreePaneModeEnabled &&
      (this.toolbox.hostType == Toolbox.HostType.LEFT ||
        this.toolbox.hostType == Toolbox.HostType.RIGHT)
      ? width > SIDE_PORTAIT_MODE_WIDTH_THRESHOLD
      : width > PORTRAIT_MODE_WIDTH_THRESHOLD;
  }

  /**
   * Build Splitter located between the main and side area of
   * the Inspector panel.
   */
  #setupSplitter() {
    const { width, height, splitSidebarWidth } = this.getSidebarSize();

    this.sidebarSplitBoxRef = this.React.createRef();

    const splitter = this.InspectorSplitBox({
      className: "inspector-sidebar-splitter",
      initialWidth: width,
      initialHeight: height,
      minSize: "10%",
      maxSize: "80%",
      splitterSize: 1,
      endPanelControl: true,
      startPanel: this.InspectorTabPanel({
        id: "inspector-main-content",
      }),
      endPanel: this.InspectorSplitBox({
        initialWidth: splitSidebarWidth,
        minSize: "225px",
        maxSize: "80%",
        splitterSize: this.isThreePaneModeEnabled ? 1 : 0,
        endPanelControl: this.isThreePaneModeEnabled,
        startPanel: this.InspectorTabPanel({
          id: "inspector-rules-container",
        }),
        endPanel: this.InspectorTabPanel({
          id: "inspector-sidebar-container",
        }),
        ref: this.sidebarSplitBoxRef,
      }),
      vert: this.#useLandscapeMode(),
      onControlledPanelResized: this.onSidebarResized,
    });

    this.splitBox = this.ReactDOM.render(
      splitter,
      this.panelDoc.getElementById("inspector-splitter-box")
    );

    this.panelWin.addEventListener("resize", this.#onLazyPanelResize, true);
  }

  #onLazyPanelResize = debounce(
    () => {
      // We can be called on a closed window or destroyed toolbox because of the deferred task.
      if (
        this.panelWin?.closed ||
        this.#destroyed ||
        this.#toolbox.currentToolId !== "inspector"
      ) {
        return;
      }

      this.splitBox.setState({ vert: this.#useLandscapeMode() });
      this.emit("inspector-resize");
    },
    LAZY_RESIZE_INTERVAL_MS,
    this
  );

  getSidebarSize() {
    let width;
    let height;
    let splitSidebarWidth;

    // Initialize splitter size from preferences.
    try {
      width = Services.prefs.getIntPref("devtools.toolsidebar-width.inspector");
      height = Services.prefs.getIntPref(
        "devtools.toolsidebar-height.inspector"
      );
      splitSidebarWidth = Services.prefs.getIntPref(
        "devtools.toolsidebar-width.inspector.splitsidebar"
      );
    } catch (e) {
      // Set width and height of the splitter. Only one
      // value is really useful at a time depending on the current
      // orientation (vertical/horizontal).
      // Having both is supported by the splitter component.
      width = this.isThreePaneModeEnabled
        ? INITIAL_SIDEBAR_SIZE * 2
        : INITIAL_SIDEBAR_SIZE;
      height = INITIAL_SIDEBAR_SIZE;
      splitSidebarWidth = INITIAL_SIDEBAR_SIZE;
    }

    return { width, height, splitSidebarWidth };
  }

  onSidebarHidden() {
    // Store the current splitter size to preferences.
    const state = this.splitBox.state;
    Services.prefs.setIntPref(
      "devtools.toolsidebar-width.inspector",
      state.width
    );
    Services.prefs.setIntPref(
      "devtools.toolsidebar-height.inspector",
      state.height
    );
    Services.prefs.setIntPref(
      "devtools.toolsidebar-width.inspector.splitsidebar",
      this.sidebarSplitBoxRef.current.state.width
    );
  }

  onSidebarResized(width, height) {
    this.toolbox.emit("inspector-sidebar-resized", { width, height });
  }

  /**
   * Returns inspector tab that is active.
   */
  getActiveSidebar() {
    return Services.prefs.getCharPref("devtools.inspector.activeSidebar");
  }

  setActiveSidebar(toolId) {
    Services.prefs.setCharPref("devtools.inspector.activeSidebar", toolId);
  }

  /**
   * Returns tab that is explicitly selected by user.
   */
  getSelectedSidebar() {
    return Services.prefs.getCharPref("devtools.inspector.selectedSidebar");
  }

  setSelectedSidebar(toolId) {
    Services.prefs.setCharPref("devtools.inspector.selectedSidebar", toolId);
  }

  onSidebarSelect(toolId) {
    // Save the currently selected sidebar panel
    this.setSelectedSidebar(toolId);
    this.setActiveSidebar(toolId);

    // Then forces the panel creation by calling getPanel
    // (This allows lazy loading the panels only once we select them)
    this.getPanel(toolId);

    this.toolbox.emit("inspector-sidebar-select", toolId);
  }

  onSidebarShown() {
    const { width, height, splitSidebarWidth } = this.getSidebarSize();
    this.splitBox.setState({ width, height });
    this.sidebarSplitBoxRef.current.setState({ width: splitSidebarWidth });
  }

  async onSidebarToggle() {
    this.isThreePaneModeEnabled = !this.isThreePaneModeEnabled;
    await this.#setupToolbar();
    this.#addRuleView({ skipQueue: true });
  }

  /**
   * Sets the inspector sidebar split box state. Shows the splitter inside the sidebar
   * split box, specifies the end panel control and resizes the split box width depending
   * on the width of the toolbox.
   */
  #setSidebarSplitBoxState() {
    const toolboxWidth = this.panelDoc.getElementById(
      "inspector-splitter-box"
    ).clientWidth;

    // Get the inspector sidebar's (right panel in horizontal mode or bottom panel in
    // vertical mode) width.
    const sidebarWidth = this.splitBox.state.width;
    // This variable represents the width of the right panel in horizontal mode or
    // bottom-right panel in vertical mode width in 3 pane mode.
    let sidebarSplitboxWidth;

    if (this.#useLandscapeMode()) {
      // Whether or not doubling the inspector sidebar's (right panel in horizontal mode
      // or bottom panel in vertical mode) width will be bigger than half of the
      // toolbox's width.
      const canDoubleSidebarWidth = sidebarWidth * 2 < toolboxWidth / 2;

      // Resize the main split box's end panel that contains the middle and right panel.
      // Attempts to resize the main split box's end panel to be double the size of the
      // existing sidebar's width when switching to 3 pane mode. However, if the middle
      // and right panel's width together is greater than half of the toolbox's width,
      // split all 3 panels to be equally sized by resizing the end panel to be 2/3 of
      // the current toolbox's width.
      this.splitBox.setState({
        width: canDoubleSidebarWidth
          ? sidebarWidth * 2
          : (toolboxWidth * 2) / 3,
      });

      // In landscape/horizontal mode, set the right panel back to its original
      // inspector sidebar width if we can double the sidebar width. Otherwise, set
      // the width of the right panel to be 1/3 of the toolbox's width since all 3
      // panels will be equally sized.
      sidebarSplitboxWidth = canDoubleSidebarWidth
        ? sidebarWidth
        : toolboxWidth / 3;
    } else {
      // In portrait/vertical mode, set the bottom-right panel to be 1/2 of the
      // toolbox's width.
      sidebarSplitboxWidth = toolboxWidth / 2;
    }

    // Show the splitter inside the sidebar split box. Sets the width of the inspector
    // sidebar and specify that the end (right in horizontal or bottom-right in
    // vertical) panel of the sidebar split box should be controlled when resizing.
    this.sidebarSplitBoxRef.current.setState({
      endPanelControl: true,
      splitterSize: 1,
      width: sidebarSplitboxWidth,
    });
  }

  /**
   * Adds the rule view to the middle (in landscape/horizontal mode) or bottom-left panel
   * (in portrait/vertical mode) or inspector sidebar depending on whether or not it is 3
   * pane mode. Rule view is selected when switching to 2 pane mode. Selected sidebar pref
   * is used otherwise.
   */
  #addRuleView({ skipQueue = false } = {}) {
    const selectedSidebar = this.getSelectedSidebar();
    const ruleViewSidebar = this.sidebarSplitBoxRef.current.startPanelContainer;

    if (this.isThreePaneModeEnabled) {
      // Convert to 3 pane mode by removing the rule view from the inspector sidebar
      // and adding the rule view to the middle (in landscape/horizontal mode) or
      // bottom-left (in portrait/vertical mode) panel.
      ruleViewSidebar.style.display = "block";

      this.#setSidebarSplitBoxState();

      // Force the rule view panel creation by calling getPanel
      this.getPanel("ruleview");

      this.sidebar.removeTab("ruleview");
      this.sidebar.select(selectedSidebar);

      this.ruleViewSideBar.addExistingTab(
        "ruleview",
        INSPECTOR_L10N.getStr("inspector.sidebar.ruleViewTitle"),
        true
      );

      this.ruleViewSideBar.show();
    } else {
      // When switching to 2 pane view, always set rule view as the active sidebar.
      this.setActiveSidebar("ruleview");
      // Removes the rule view from the 3 pane mode and adds the rule view to the main
      // inspector sidebar.
      ruleViewSidebar.style.display = "none";

      // Set the width of the split box (right panel in horziontal mode and bottom panel
      // in vertical mode) to be the width of the inspector sidebar.
      const splitterBox = this.panelDoc.getElementById(
        "inspector-splitter-box"
      );
      this.splitBox.setState({
        width: this.#useLandscapeMode()
          ? this.sidebarSplitBoxRef.current.state.width
          : splitterBox.clientWidth,
      });

      // Hide the splitter to prevent any drag events in the sidebar split box and
      // specify that the end (right panel in horziontal mode or bottom panel in vertical
      // mode) panel should be uncontrolled when resizing.
      this.sidebarSplitBoxRef.current.setState({
        endPanelControl: false,
        splitterSize: 0,
      });

      this.ruleViewSideBar.hide();
      this.ruleViewSideBar.removeTab("ruleview");

      if (skipQueue) {
        this.sidebar.addExistingTab(
          "ruleview",
          INSPECTOR_L10N.getStr("inspector.sidebar.ruleViewTitle"),
          true,
          0
        );
      } else {
        this.sidebar.queueExistingTab(
          "ruleview",
          INSPECTOR_L10N.getStr("inspector.sidebar.ruleViewTitle"),
          true,
          0
        );
      }
    }

    // Adding or removing a tab from sidebar sets selectedSidebar by the active tab,
    // which we should revert.
    this.setSelectedSidebar(selectedSidebar);

    this.emit("ruleview-added");
  }

  /**
   * Returns a boolean indicating whether a sidebar panel instance exists.
   */
  hasPanel(id) {
    return this.#panels.has(id);
  }

  /**
   * Lazily get and create panel instances displayed in the sidebar
   */
  getPanel(id) {
    if (this.#panels.has(id)) {
      return this.#panels.get(id);
    }

    let panel;
    switch (id) {
      case "animationinspector": {
        const AnimationInspector = this.browserRequire(
          "devtools/client/inspector/animation/animation"
        );
        panel = new AnimationInspector(this, this.panelWin);
        break;
      }
      case "boxmodel": {
        // box-model isn't a panel on its own, it used to, now it is being used by
        // the layout view which retrieves an instance via getPanel.
        const BoxModel = require("resource://devtools/client/inspector/boxmodel/box-model.js");
        panel = new BoxModel(this, this.panelWin);
        break;
      }
      case "changesview": {
        const ChangesView = this.browserRequire(
          "devtools/client/inspector/changes/ChangesView"
        );
        panel = new ChangesView(this, this.panelWin);
        break;
      }
      case "compatibilityview": {
        const CompatibilityView = this.browserRequire(
          "devtools/client/inspector/compatibility/CompatibilityView"
        );
        panel = new CompatibilityView(this, this.panelWin);
        break;
      }
      case "computedview": {
        const { ComputedViewTool } = this.browserRequire(
          "devtools/client/inspector/computed/computed"
        );
        panel = new ComputedViewTool(this, this.panelWin);
        break;
      }
      case "fontinspector": {
        const FontInspector = this.browserRequire(
          "devtools/client/inspector/fonts/fonts"
        );
        panel = new FontInspector(this, this.panelWin);
        break;
      }
      case "layoutview": {
        const LayoutView = this.browserRequire(
          "devtools/client/inspector/layout/layout"
        );
        panel = new LayoutView(this, this.panelWin);
        break;
      }
      case "ruleview": {
        const {
          RuleViewTool,
        } = require("resource://devtools/client/inspector/rules/rules.js");
        panel = new RuleViewTool(this, this.panelWin);
        break;
      }
      default:
        // This is a custom panel or a non lazy-loaded one.
        return null;
    }

    if (panel) {
      this.#panels.set(id, panel);
    }

    return panel;
  }

  /**
   * Build the sidebar.
   */
  #setupSidebar() {
    const sidebar = this.panelDoc.getElementById("inspector-sidebar");
    const options = {
      showAllTabsMenu: true,
      allTabsMenuButtonTooltip: INSPECTOR_L10N.getStr(
        "allTabsMenuButton.tooltip"
      ),
      sidebarToggleButton: {
        collapsed: !this.isThreePaneModeEnabled,
        collapsePaneTitle: INSPECTOR_L10N.getStr("inspector.hideThreePaneMode"),
        expandPaneTitle: INSPECTOR_L10N.getStr("inspector.showThreePaneMode"),
        onClick: this.onSidebarToggle,
      },
    };

    this.sidebar = new ToolSidebar(sidebar, this, options);
    this.sidebar.on("select", this.onSidebarSelect);

    const ruleSideBar = this.panelDoc.getElementById("inspector-rules-sidebar");
    this.ruleViewSideBar = new ToolSidebar(ruleSideBar, this, {
      hideTabstripe: true,
    });

    // Append all side panels
    this.#addRuleView();

    // Inspector sidebar panels in order of appearance.
    const sidebarPanels = [];
    sidebarPanels.push({
      id: "layoutview",
      title: INSPECTOR_L10N.getStr("inspector.sidebar.layoutViewTitle2"),
    });

    sidebarPanels.push({
      id: "computedview",
      title: INSPECTOR_L10N.getStr("inspector.sidebar.computedViewTitle"),
    });

    sidebarPanels.push({
      id: "changesview",
      title: INSPECTOR_L10N.getStr("inspector.sidebar.changesViewTitle"),
    });

    sidebarPanels.push({
      id: "compatibilityview",
      title: INSPECTOR_L10N.getStr("inspector.sidebar.compatibilityViewTitle"),
    });

    sidebarPanels.push({
      id: "fontinspector",
      title: INSPECTOR_L10N.getStr("inspector.sidebar.fontInspectorTitle"),
    });

    sidebarPanels.push({
      id: "animationinspector",
      title: INSPECTOR_L10N.getStr("inspector.sidebar.animationInspectorTitle"),
    });

    const defaultTab = this.getActiveSidebar();

    for (const { id, title } of sidebarPanels) {
      // The Computed panel is not a React-based panel. We pick its element container from
      // the DOM and wrap it in a React component (InspectorTabPanel) so it behaves like
      // other panels when using the Inspector's tool sidebar.
      if (id === "computedview") {
        this.sidebar.queueExistingTab(id, title, defaultTab === id);
      } else {
        // When `panel` is a function, it is called when the tab should render. It is
        // expected to return a React component to populate the tab's content area.
        // Calling this method on-demand allows us to lazy-load the requested panel.
        this.sidebar.queueTab(
          id,
          title,
          {
            props: {
              id,
              title,
            },
            panel: () => {
              return this.getPanel(id).provider;
            },
          },
          defaultTab === id
        );
      }
    }

    this.sidebar.addAllQueuedTabs();

    // Persist splitter state in preferences.
    this.sidebar.on("show", this.onSidebarShown);
    this.sidebar.on("hide", this.onSidebarHidden);
    this.sidebar.on("destroy", this.onSidebarHidden);

    this.sidebar.show();
  }

  /**
   * Setup any extension sidebar already registered to the toolbox when the inspector.
   * has been created for the first time.
   */
  #setupExtensionSidebars() {
    for (const [sidebarId, { title }] of this.toolbox
      .inspectorExtensionSidebars) {
      this.addExtensionSidebar(sidebarId, { title });
    }
  }

  /**
   * Create a side-panel tab controlled by an extension
   * using the devtools.panels.elements.createSidebarPane and sidebar object API
   *
   * @param {string} id
   *        An unique id for the sidebar tab.
   * @param {object} options
   * @param {string} options.title
   *        The tab title
   */
  addExtensionSidebar(id, { title }) {
    if (this.#panels.has(id)) {
      throw new Error(
        `Cannot create an extension sidebar for the existent id: ${id}`
      );
    }

    // Load the ExtensionSidebar component via the Browser Loader as it ultimately loads Reps and Object Inspector,
    // which are expected to be loaded in a document scope.
    const ExtensionSidebar = this.browserRequire(
      "resource://devtools/client/inspector/extensions/extension-sidebar.js"
    );
    const extensionSidebar = new ExtensionSidebar(this, { id, title });

    // TODO(rpl): pass some extension metadata (e.g. extension name and icon) to customize
    // the render of the extension title (e.g. use the icon in the sidebar and show the
    // extension name in a tooltip).
    this.addSidebarTab(id, title, extensionSidebar.provider, false);

    this.#panels.set(id, extensionSidebar);

    // Emit the created ExtensionSidebar instance to the listeners registered
    // on the toolbox by the "devtools.panels.elements" WebExtensions API.
    this.toolbox.emit(`extension-sidebar-created-${id}`, extensionSidebar);
  }

  /**
   * Remove and destroy a side-panel tab controlled by an extension (e.g. when the
   * extension has been disable/uninstalled while the toolbox and inspector were
   * still open).
   *
   * @param {string} id
   *        The id of the sidebar tab to destroy.
   */
  removeExtensionSidebar(id) {
    if (!this.#panels.has(id)) {
      throw new Error(`Unable to find a sidebar panel with id "${id}"`);
    }

    const panel = this.#panels.get(id);

    const ExtensionSidebar = this.browserRequire(
      "resource://devtools/client/inspector/extensions/extension-sidebar.js"
    );
    if (!(panel instanceof ExtensionSidebar)) {
      throw new Error(
        `The sidebar panel with id "${id}" is not an ExtensionSidebar`
      );
    }

    this.#panels.delete(id);
    this.sidebar.removeTab(id);
    panel.destroy();
  }

  /**
   * Register a side-panel tab. This API can be used outside of
   * DevTools (e.g. from an extension) as well as by DevTools
   * code base.
   *
   * @param {string} tab uniq id
   * @param {string} title tab title
   * @param {React.Component} panel component. See `InspectorPanelTab` as an example.
   * @param {boolean} selected true if the panel should be selected
   */
  addSidebarTab(id, title, panel, selected) {
    this.sidebar.addTab(id, title, panel, selected);
  }

  /**
   * Method to check whether the document is a HTML document and
   * pickColorFromPage method is available or not.
   *
   * @return {boolean} true if the eyedropper highlighter is supported by the current
   *         document.
   */
  async supportsEyeDropper() {
    try {
      return await this.inspectorFront.supportsHighlighters();
    } catch (e) {
      console.error(e);
      return false;
    }
  }

  async #setupToolbar() {
    this.#teardownToolbar();

    // Setup the add-node button.
    this.addNodeButton = this.panelDoc.getElementById(
      "inspector-element-add-button"
    );
    this.addNodeButton.addEventListener("click", this.addNode);

    // Setup the eye-dropper icon if we're in an HTML document and we have actor support.
    const canShowEyeDropper = await this.supportsEyeDropper();

    // Bail out if the inspector was destroyed in the meantime and panelDoc is no longer
    // available.
    if (!this.panelDoc) {
      return;
    }

    if (canShowEyeDropper) {
      this.eyeDropperButton = this.panelDoc.getElementById(
        "inspector-eyedropper-toggle"
      );
      this.eyeDropperButton.disabled = false;
      const shortcutKey = INSPECTOR_L10N.getStr(
        "inspector.eyedropper.key"
      ).replace("CmdOrCtrl", osString == "Darwin" ? "Cmd" : "Ctrl");

      this.eyeDropperButton.title = INSPECTOR_L10N.getFormatStr(
        "inspector.eyedropper.label2",
        shortcutKey
      );
      this.eyeDropperButton.addEventListener(
        "click",
        this.onEyeDropperButtonClicked
      );
    } else {
      const eyeDropperButton = this.panelDoc.getElementById(
        "inspector-eyedropper-toggle"
      );
      eyeDropperButton.disabled = true;
      eyeDropperButton.title = INSPECTOR_L10N.getStr(
        "eyedropper.disabled.title"
      );
    }

    this.emit("inspector-toolbar-updated");
  }

  #teardownToolbar() {
    if (this.addNodeButton) {
      this.addNodeButton.removeEventListener("click", this.addNode);
      this.addNodeButton = null;
    }

    if (this.eyeDropperButton) {
      this.eyeDropperButton.removeEventListener(
        "click",
        this.onEyeDropperButtonClicked
      );
      this.eyeDropperButton = null;
    }
  }

  #selectionCssSelectors = null;

  /**
   * Set the array of CSS selectors for the currently selected node.
   * We use an array of selectors in case the element is in iframes.
   * Will store the current target url along with it to allow pre-selection at
   * reload
   */
  set selectionCssSelectors(cssSelectors = []) {
    if (this.#destroyed) {
      return;
    }

    this.#selectionCssSelectors = {
      selectors: cssSelectors,
      url: this.currentTarget.url,
    };
  }

  /**
   * Get the CSS selectors for the current selection if any, that is, if a node
   * is actually selected and that node has been selected while on the same url
   */
  get selectionCssSelectors() {
    if (
      this.#selectionCssSelectors &&
      this.#selectionCssSelectors.url === this.currentTarget.url
    ) {
      return this.#selectionCssSelectors.selectors;
    }
    return [];
  }

  /**
   * On any new selection made by the user, store the array of css selectors
   * of the selected node so it can be restored after reload of the same page
   */
  #updateSelectionCssSelectors() {
    if (!this.selection.isElementNode()) {
      return;
    }

    this.commands.inspectorCommand
      .getNodeFrontSelectorsFromTopDocument(this.selection.nodeFront)
      .then(selectors => {
        this.selectionCssSelectors = selectors;
        // emit an event so tests relying on the property being set can properly wait
        // for it.
        this.emitForTests("selection-css-selectors-updated", selectors);
      }, this.#handleRejectionIfNotDestroyed);
  }

  /**
   * Can a new HTML element be inserted into the currently selected element?
   *
   * @return {boolean}
   */
  canAddHTMLChild() {
    const selection = this.selection;

    // Don't allow to insert an element into these elements. This should only
    // contain elements where walker.insertAdjacentHTML has no effect.
    const invalidTagNames = ["html", "iframe"];

    return (
      selection.isHTMLNode() &&
      selection.isElementNode() &&
      !selection.isPseudoElementNode() &&
      !selection.isNativeAnonymousNode() &&
      !invalidTagNames.includes(selection.nodeFront.nodeName.toLowerCase())
    );
  }

  /**
   * Update the state of the add button in the toolbar depending on the current selection.
   */
  #updateAddElementButton() {
    const btn = this.panelDoc.getElementById("inspector-element-add-button");
    if (this.canAddHTMLChild()) {
      btn.removeAttribute("disabled");
    } else {
      btn.setAttribute("disabled", "true");
    }
  }

  /**
   * Handler for the "host-changed" event from the toolbox. Resets the inspector
   * sidebar sizes when the toolbox host type changes.
   */
  #onHostChanged = async () => {
    // Eagerly call our resize handling code to process the fact that we
    // switched hosts. If we don't do this, we'll wait for resize events + 200ms
    // to have passed, which causes the old layout to noticeably show up in the
    // new host, followed by the updated one.
    await this.#onLazyPanelResize();
    // Note that we may have been destroyed by now, especially in tests, so we
    // need to check if that's happened before touching anything else.
    if (!this.currentTarget || !this.isThreePaneModeEnabled) {
      return;
    }

    // When changing hosts, the toolbox chromeEventHandler might change, for instance when
    // switching from docked to window hosts. Recreate the inspector shortcuts.
    this.inspectorShortcuts.destroy();
    this.#createInspectorShortcuts();
    this.#setSidebarSplitBoxState();
  };

  /**
   * When a new node is selected.
   */
  #onNewSelection = (value, reason) => {
    if (reason === "selection-destroy") {
      return;
    }

    this.#updateAddElementButton();
    this.#updateSelectionCssSelectors();

    const selfUpdate = this.updating("inspector-panel");
    executeSoon(() => {
      try {
        selfUpdate(this.selection.nodeFront);
        Glean.devtoolsInspector.nodeSelectionCount.add(1);
      } catch (ex) {
        console.error(ex);
      }
    });
  };

  /**
   * Delay the "inspector-updated" notification while a tool
   * is updating itself.  Returns a function that must be
   * invoked when the tool is done updating with the node
   * that the tool is viewing.
   */
  updating(name) {
    if (
      this.#updateProgress &&
      this.#updateProgress.node != this.selection.nodeFront
    ) {
      this.#cancelUpdate();
    }

    if (!this.#updateProgress) {
      // Start an update in progress.
      const self = this;
      this.#updateProgress = {
        node: this.selection.nodeFront,
        outstanding: new Set(),
        checkDone() {
          if (this !== self.#updateProgress) {
            return;
          }
          // Cancel update if there is no `selection` anymore.
          // It can happen if the inspector panel is already destroyed.
          if (!self.selection || this.node !== self.selection.nodeFront) {
            self.#cancelUpdate();
            return;
          }
          if (this.outstanding.size !== 0) {
            return;
          }

          self.#updateProgress = null;
          self.emit("inspector-updated", name);
        },
      };
    }

    const progress = this.#updateProgress;
    const done = function () {
      progress.outstanding.delete(done);
      progress.checkDone();
    };
    progress.outstanding.add(done);
    return done;
  }

  /**
   * Cancel notification of inspector updates.
   */
  #cancelUpdate() {
    this.#updateProgress = null;
  }

  /**
   * When a node is deleted, select its parent node or the defaultNode if no
   * parent is found (may happen when deleting an iframe inside which the
   * node was selected).
   */
  #onDetached = parentNode => {
    this.breadcrumbs.cutAfter(this.breadcrumbs.indexOf(parentNode));
    const nodeFront = parentNode ? parentNode : this.#defaultNode;
    this.selection.setNodeFront(nodeFront, { reason: "detached" });
  };

  /**
   * Destroy the inspector.
   */
  destroy() {
    if (this.#destroyed) {
      return;
    }
    this.#destroyed = true;

    // Prevents any further action from being dispatched
    this.store.dispatch(START_IGNORE_ACTION);

    this.#cancelUpdate();

    this.panelWin.removeEventListener("resize", this.onPanelWindowResize, true);
    this.selection.off("new-node-front", this.#onNewSelection);
    this.selection.off("detached-front", this.#onDetached);
    this.toolbox.nodePicker.off("picker-node-canceled", this.onPickerCanceled);
    this.toolbox.nodePicker.off("picker-node-hovered", this.onPickerHovered);
    this.toolbox.nodePicker.off("picker-node-picked", this.onPickerPicked);

    // Destroy the sidebar first as it may unregister stuff
    // and still use random attributes on inspector and layout panel
    this.sidebar.destroy();
    // Unregister sidebar listener *after* destroying it
    // in order to process its destroy event and save sidebar sizes
    this.sidebar.off("select", this.onSidebarSelect);
    this.sidebar.off("show", this.onSidebarShown);
    this.sidebar.off("hide", this.onSidebarHidden);
    this.sidebar.off("destroy", this.onSidebarHidden);

    for (const [, panel] of this.#panels) {
      panel.destroy();
    }
    this.#panels.clear();

    if (this.#highlighters) {
      this.#highlighters.destroy();
    }

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

    this.ruleViewSideBar.destroy();
    this.ruleViewSideBar = null;

    this.#destroyMarkup();

    this.#teardownToolbar();

    this.prefObserver.destroy();

    this.breadcrumbs.destroy();
    this.styleChangeTracker.destroy();
    this.inspectorShortcuts.destroy();
    this.inspectorShortcuts = null;

    this.commands.targetCommand.unwatchTargets({
      types: [this.commands.targetCommand.TYPES.FRAME],
      onAvailable: this.#onTargetAvailable,
      onSelected: this.#onTargetSelected,
      onDestroyed: this.#onTargetDestroyed,
    });
    const { resourceCommand } = this.commands;
    resourceCommand.unwatchResources(this.#watchedResources, {
      onAvailable: this.onResourceAvailable,
    });

    this.#InspectorTabPanel = null;
    this.#TabBar = null;
    this.#InspectorSplitBox = null;
    this.sidebarSplitBoxRef = null;
    // Note that we do not unmount inspector-splitter-box
    // as it regresses inspector closing performance while not releasing
    // any object (bug 1729925)
    this.splitBox = null;

    this.#isThreePaneModeEnabled = null;
    this.#markupBox = null;
    this.#markupFrame = null;
    this.#toolbox = null;
    this.#commands = null;
    this.breadcrumbs = null;
    this.inspectorFront = null;
    this.#cssProperties = null;
    this.accessibilityFront = null;
    this.#highlighters = null;
    this.walker = null;
    this.#defaultNode = null;
    this.panelDoc = null;
    this.panelWin.inspector = null;
    this.panelWin = null;
    this.resultsLength = null;
    this.searchBox.removeEventListener("focus", this.#listenForSearchEvents);
    this.searchBox = null;
    this.show3PaneTooltip = null;
    this.sidebar = null;
    this.store = null;
    this.telemetry = null;
    this.searchResultsLabel.removeEventListener(
      "click",
      this.#onSearchLabelClick
    );
    this.searchResultsLabel = null;
  }

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

    if (this.#markupBox) {
      this.#markupBox.style.visibility = "hidden";
    }
  }

  onEyeDropperButtonClicked() {
    this.eyeDropperButton.classList.contains("checked")
      ? this.hideEyeDropper()
      : this.showEyeDropper();
  }

  startEyeDropperListeners() {
    this.toolbox.tellRDMAboutPickerState(true, PICKER_TYPES.EYEDROPPER);
    this.inspectorFront.once("color-pick-canceled", this.onEyeDropperDone);
    this.inspectorFront.once("color-picked", this.onEyeDropperDone);
    this.once("new-root", this.onEyeDropperDone);
  }

  stopEyeDropperListeners() {
    this.toolbox
      .tellRDMAboutPickerState(false, PICKER_TYPES.EYEDROPPER)
      .catch(console.error);
    this.inspectorFront.off("color-pick-canceled", this.onEyeDropperDone);
    this.inspectorFront.off("color-picked", this.onEyeDropperDone);
    this.off("new-root", this.onEyeDropperDone);
  }

  onEyeDropperDone() {
    this.eyeDropperButton.classList.remove("checked");
    this.stopEyeDropperListeners();
    this.panelWin.focus();
  }

  /**
   * Show the eyedropper on the page.
   *
   * @return {Promise} resolves when the eyedropper is visible.
   */
  showEyeDropper() {
    // The eyedropper button doesn't exist, most probably because the actor doesn't
    // support the pickColorFromPage, or because the page isn't HTML.
    if (!this.eyeDropperButton) {
      return null;
    }
    // turn off node picker when color picker is starting
    this.toolbox.nodePicker.stop({ canceled: true }).catch(console.error);
    this.eyeDropperButton.classList.add("checked");
    this.startEyeDropperListeners();
    return this.inspectorFront
      .pickColorFromPage({ copyOnSelect: true })
      .catch(console.error);
  }

  /**
   * Hide the eyedropper.
   *
   * @return {Promise} resolves when the eyedropper is hidden.
   */
  hideEyeDropper() {
    // The eyedropper button doesn't exist, most probably  because the page isn't HTML.
    if (!this.eyeDropperButton) {
      return null;
    }

    this.eyeDropperButton.classList.remove("checked");
    this.stopEyeDropperListeners();
    return this.inspectorFront.cancelPickColorFromPage().catch(console.error);
  }

  /**
   * Create a new node as the last child of the current selection, expand the
   * parent and select the new node.
   */
  async addNode() {
    if (!this.canAddHTMLChild()) {
      return;
    }

    // turn off node picker when add node is triggered
    this.toolbox.nodePicker.stop({ canceled: true });

    // turn off color picker when add node is triggered
    this.hideEyeDropper();

    const nodeFront = this.selection.nodeFront;
    const html = "<div></div>";

    // Insert the html and expect a childList markup mutation.
    const onMutations = this.once("markupmutation");
    await nodeFront.walkerFront.insertAdjacentHTML(
      this.selection.nodeFront,
      "beforeEnd",
      html
    );
    await onMutations;

    // Expand the parent node.
    this.markup.expandNode(nodeFront);
  }

  /**
   * Toggle a pseudo class.
   */
  togglePseudoClass(pseudo) {
    if (this.selection.isElementNode()) {
      const node = this.selection.nodeFront;
      if (node.hasPseudoClassLock(pseudo)) {
        return node.walkerFront.removePseudoClassLock(node, pseudo, {
          parents: true,
        });
      }

      const hierarchical = pseudo == ":hover" || pseudo == ":active";
      return node.walkerFront.addPseudoClassLock(node, pseudo, {
        parents: hierarchical,
      });
    }
    return Promise.resolve();
  }

  /**
   * Returns true if the "Change pseudo class" (either via the ":hov" panel checkboxes,
   * or the markup view context menu entries) can be performed for the currently selected node.
   *
   * @returns {boolean}
   */
  canTogglePseudoClassForSelectedNode() {
    if (!this.selection) {
      return false;
    }

    return (
      this.selection.isElementNode() && !this.selection.isPseudoElementNode()
    );
  }

  /**
   * Initiate screenshot command on selected node.
   */
  async screenshotNode() {
    // Bug 1332936 - it's possible to call `screenshotNode` while the BoxModel highlighter
    // is still visible, therefore showing it in the picture.
    // Note that other highlighters will still be visible. See Bug 1663881
    await this.highlighters.hideHighlighterType(
      this.highlighters.TYPES.BOXMODEL
    );

    const clipboardEnabled = Services.prefs.getBoolPref(
      "devtools.screenshot.clipboard.enabled"
    );
    const args = {
      file: !clipboardEnabled,
      nodeActorID: this.selection.nodeFront.actorID,
      clipboard: clipboardEnabled,
    };

    const messages = await captureAndSaveScreenshot(
      this.selection.nodeFront.targetFront,
      this.panelWin,
      args
    );
    const notificationBox = this.toolbox.getNotificationBox();
    const priorityMap = {
      error: notificationBox.PRIORITY_CRITICAL_HIGH,
      warn: notificationBox.PRIORITY_WARNING_HIGH,
    };
    for (const { text, level } of messages) {
      // captureAndSaveScreenshot returns "saved" messages, that indicate where the
      // screenshot was saved. We don't want to display them as the download UI can be
      // used to open the file.
      if (level !== "warn" && level !== "error") {
        continue;
      }
      notificationBox.appendNotification(text, null, null, priorityMap[level]);
    }
  }

  /**
   * Returns an object containing the shared handler functions used in React components.
   */
  getCommonComponentProps() {
    return {
      setSelectedNode: this.selection.setNodeFront,
    };
  }

  onPickerCanceled() {
    this.highlighters.hideHighlighterType(this.highlighters.TYPES.BOXMODEL);
  }

  onPickerHovered(nodeFront) {
    this.highlighters.showHighlighterTypeForNode(
      this.highlighters.TYPES.BOXMODEL,
      nodeFront
    );
  }

  onPickerPicked(nodeFront) {
    if (this.toolbox.isDebugTargetFenix()) {
      // When debugging a phone, as we don't have the "hover overlay", we want to provide
      // feedback to the user so they know where they tapped
      this.highlighters.showHighlighterTypeForNode(
        this.highlighters.TYPES.BOXMODEL,
        nodeFront,
        { duration: this.HIGHLIGHTER_AUTOHIDE_TIMER }
      );
      return;
    }
    this.highlighters.hideHighlighterType(this.highlighters.TYPES.BOXMODEL);
  }

  async inspectNodeActor(nodeGrip, reason) {
    const nodeFront =
      await this.inspectorFront.getNodeFrontFromNodeGrip(nodeGrip);
    if (!nodeFront) {
      console.error(
        "The object cannot be linked to the inspector, the " +
          "corresponding nodeFront could not be found."
      );
      return false;
    }

    const isAttached = await this.walker.isInDOMTree(nodeFront);
    if (!isAttached) {
      console.error("Selected DOMNode is not attached to the document tree.");
      return false;
    }

    await this.selection.setNodeFront(nodeFront, { reason });
    return true;
  }

  /**
   * Called by toolbox.js on `Esc` keydown to check if the inspector panel
   * should prevent the split console from being toggled.
   *
   * @returns {boolean} true if the split console toggle should be prevented.
   */
  shouldPreventSplitConsoleToggle() {
    // If the event tooltip is displayed, hide it and prevent the split console
    // from being toggled.
    if (
      this.markup.hasEventDetailsTooltip() &&
      this.markup.eventDetailsTooltip.isVisible()
    ) {
      this.markup.eventDetailsTooltip.hide();
      return true;
    }
    return false;
  }
}

exports.Inspector = Inspector;
