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

loader.lazyRequireGetter(
  this,
  "EventEmitter",
  "resource://devtools/shared/event-emitter.js"
);

/**
 * Client-side NodePicker module.
 * To be used by inspector front when it needs to select DOM elements.
 *
 * NodePicker is a proxy for the node picker functionality from WalkerFront instances
 * of all available InspectorFronts. It is a single point of entry for the client to:
 * - invoke actions to start and stop picking nodes on all walkers
 * - listen to node picker events from all walkers and relay them to subscribers
 *
 * @param {Commands} commands
 *        The commands object with all interfaces defined from devtools/shared/commands/
 */
class NodePicker extends EventEmitter {
  constructor(commands) {
    super();
    this.commands = commands;
    this.targetCommand = commands.targetCommand;

    // Whether or not the node picker is active.
    this.isPicking = false;
    // Whether to focus the top-level frame before picking nodes.
    this.doFocus = false;
  }

  // The set of inspector fronts corresponding to the targets where picking happens.
  #currentInspectorFronts = new Set();

  /**
   * Start/stop the element picker on the debuggee target.
   *
   * @param {boolean} doFocus
   *        Optionally focus the content area once the picker is activated.
   * @return Promise that resolves when done
   */
  togglePicker = doFocus => {
    if (this.isPicking) {
      return this.stop({ canceled: true });
    }
    return this.start(doFocus);
  };

  /**
   * Tell the walker front corresponding to the given inspector front to enter node
   * picking mode (listen for mouse movements over its nodes) and set event listeners
   * associated with node picking: hover node, pick node, preview, cancel. See WalkerSpec.
   *
   * @param {InspectorFront} inspectorFront
   * @return {Promise}
   */
  #onInspectorFrontAvailable = async inspectorFront => {
    this.#currentInspectorFronts.add(inspectorFront);
    // watchFront may notify us about inspector fronts that aren't initialized yet,
    // so ensure waiting for initialization in order to have a defined `walker` attribute.
    await inspectorFront.initialize();
    const { walker } = inspectorFront;
    walker.on("picker-node-hovered", this.#onHovered);
    walker.on("picker-node-picked", this.#onPicked);
    walker.on("picker-node-previewed", this.#onPreviewed);
    walker.on("picker-node-canceled", this.#onCanceled);
    await walker.pick(this.doFocus);

    this.emitForTests("inspector-front-ready-for-picker", walker);
  };

  /**
   * Tell the walker front corresponding to the given inspector front to exit the node
   * picking mode and remove all event listeners associated with node picking.
   *
   * @param {InspectorFront} inspectorFront
   * @param {boolean} isDestroyCodePath
   *        Optional. If true, we assume that's when the toolbox closes
   *        and we should avoid doing any RDP request.
   * @return {Promise}
   */
  #onInspectorFrontDestroyed = async (
    inspectorFront,
    { isDestroyCodepath } = {}
  ) => {
    this.#currentInspectorFronts.delete(inspectorFront);

    const { walker } = inspectorFront;
    if (!walker) {
      return;
    }

    walker.off("picker-node-hovered", this.#onHovered);
    walker.off("picker-node-picked", this.#onPicked);
    walker.off("picker-node-previewed", this.#onPreviewed);
    walker.off("picker-node-canceled", this.#onCanceled);
    // Only do a RDP request if we stop the node picker from a user action.
    // Avoid doing one when we close the toolbox, in this scenario
    // the walker actor on the server side will automatically cancel the node picking.
    if (!isDestroyCodepath) {
      await walker.cancelPick();
    }
  };

  /**
   * While node picking, we want each target's walker fronts to listen for mouse
   * movements over their nodes and emit events. Walker fronts are obtained from
   * inspector fronts so we watch for the creation and destruction of inspector fronts
   * in order to add or remove the necessary event listeners.
   *
   * @param {TargetFront} targetFront
   * @return {Promise}
   */
  #onTargetAvailable = async ({ targetFront }) => {
    targetFront.watchFronts(
      "inspector",
      this.#onInspectorFrontAvailable,
      this.#onInspectorFrontDestroyed
    );
  };

  /**
   * Start the element picker.
   * This will instruct walker fronts of all available targets (and those of targets
   * created while node picking is active) to listen for mouse movements over their nodes
   * and trigger events when a node is hovered or picked.
   *
   * @param {boolean} doFocus
   *        Optionally focus the content area once the picker is activated.
   */
  start = async doFocus => {
    if (this.isPicking) {
      return;
    }
    this.isPicking = true;
    this.doFocus = doFocus;

    this.emit("picker-starting");

    this.targetCommand.watchTargets({
      types: this.targetCommand.ALL_TYPES,
      onAvailable: this.#onTargetAvailable,
    });

    this.emit("picker-started");
  };

  /**
   * Stop the element picker. Note that the picker is automatically stopped when
   * an element is picked.
   *
   * @param {boolean} isDestroyCodePath
   *        Optional. If true, we assume that's when the toolbox closes
   *        and we should avoid doing any RDP request.
   * @param {boolean} canceled
   *        Optional. If true, emit an additional event to notify that the
   *        picker was canceled, ie stopped without selecting a node.
   */
  stop = async ({ isDestroyCodepath, canceled } = {}) => {
    if (!this.isPicking) {
      return;
    }
    this.isPicking = false;
    this.doFocus = false;

    this.targetCommand.unwatchTargets({
      types: this.targetCommand.ALL_TYPES,
      onAvailable: this.#onTargetAvailable,
    });

    const promises = [];
    for (const inspectorFront of this.#currentInspectorFronts) {
      promises.push(
        this.#onInspectorFrontDestroyed(inspectorFront, {
          isDestroyCodepath,
        })
      );
    }
    await Promise.all(promises);

    this.#currentInspectorFronts.clear();

    this.emit("picker-stopped");

    if (canceled) {
      this.emit("picker-node-canceled");
    }
  };

  destroy() {
    // Do not await for stop as the isDestroy argument will make this method synchronous
    // and we want to avoid having an async destroy
    this.stop({ isDestroyCodepath: true });
    this.targetCommand = null;
    this.commands = null;
  }

  /**
   * When a node is hovered by the mouse when the highlighter is in picker mode
   *
   * @param {object} data
   *        Information about the node being hovered
   */
  #onHovered = async data => {
    // When debugging WebExtensions, Background page and popups are independent documents.
    // None is the parent of each others.
    // This means that if the toolbox is having the background page selected and you mouse over a popup,
    // the popup DOM Element won't be in the markup view as that's not in a children document of the background page.
    // Because of that, we have to select the hovered node's document and target in order to have it visible in the markup view.
    //
    // These top documents (background pages and popups) can actually have nested iframes,
    // for these, we will also select these nested iframes, even if they could theoritically be shown from the top document.
    if (
      this.targetCommand.descriptorFront.isWebExtensionDescriptor &&
      data.node.targetFront != this.targetCommand.selectedTargetFront
    ) {
      await this.targetCommand.selectTarget(data.node.targetFront);
    }

    this.emit("picker-node-hovered", data.node);

    // We're going to cleanup references for all the other walkers, so that if we hover
    // back the same node, we will receive a new `picker-node-hovered` event.
    for (const inspectorFront of this.#currentInspectorFronts) {
      if (inspectorFront.walker !== data.node.walkerFront) {
        inspectorFront.walker.clearPicker();
      }
    }
  };

  /**
   * When a node has been picked while the highlighter is in picker mode
   *
   * @param {object} data
   *        Information about the picked node
   */
  #onPicked = data => {
    this.emit("picker-node-picked", data.node);
    return this.stop();
  };

  /**
   * When a node has been shift-clicked (previewed) while the highlighter is in
   * picker mode
   *
   * @param {object} data
   *        Information about the picked node
   */
  #onPreviewed = data => {
    this.emit("picker-node-previewed", data.node);
  };

  /**
   * When the picker is canceled, stop the picker, and make sure the toolbox
   * gets the focus.
   */
  #onCanceled = () => {
    return this.stop({ canceled: true });
  };
}

module.exports = NodePicker;
