/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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 promise = require("promise");
const {Task} = require("devtools/shared/task");
const flags = require("devtools/shared/flags");

/**
 * Client-side highlighter shared module.
 * To be used by toolbox panels that need to highlight DOM elements.
 *
 * Highlighting and selecting elements is common enough that it needs to be at
 * toolbox level, accessible by any panel that needs it.
 * That's why the toolbox is the one that initializes the inspector and
 * highlighter. It's also why the API returned by this module needs a reference
 * to the toolbox which should be set once only.
 */

/**
 * Get the highighterUtils instance for a given toolbox.
 * This should be done once only by the toolbox itself and stored there so that
 * panels can get it from there. That's because the API returned has a stateful
 * scope that would be different for another instance returned by this function.
 *
 * @param {Toolbox} toolbox
 * @return {Object} the highlighterUtils public API
 */
exports.getHighlighterUtils = function (toolbox) {
  if (!toolbox || !toolbox.target) {
    throw new Error("Missing or invalid toolbox passed to getHighlighterUtils");
    return;
  }

  // Exported API properties will go here
  let exported = {};

  // The current toolbox target
  let target = toolbox.target;

  // Is the highlighter currently in pick mode
  let isPicking = false;

  // Is the box model already displayed, used to prevent dispatching
  // unnecessary requests, especially during toolbox shutdown
  let isNodeFrontHighlighted = false;

  /**
   * Release this utils, nullifying the references to the toolbox
   */
  exported.release = function () {
    toolbox = target = null;
  };

  /**
   * Does the target have the highlighter actor.
   * The devtools must be backwards compatible with at least B2G 1.3 (28),
   * which doesn't have the highlighter actor. This can be removed as soon as
   * the minimal supported version becomes 1.4 (29)
   */
  let isRemoteHighlightable = exported.isRemoteHighlightable = function () {
    return target.client.traits.highlightable;
  };

  /**
   * Does the target support custom highlighters.
   */
  let supportsCustomHighlighters = exported.supportsCustomHighlighters = () => {
    return !!target.client.traits.customHighlighters;
  };

  /**
   * Make a function that initializes the inspector before it runs.
   * Since the init of the inspector is asynchronous, the return value will be
   * produced by Task.async and the argument should be a generator
   * @param {Function*} generator A generator function
   * @return {Function} A function
   */
  let isInspectorInitialized = false;
  let requireInspector = generator => {
    return Task.async(function* (...args) {
      if (!isInspectorInitialized) {
        yield toolbox.initInspector();
        isInspectorInitialized = true;
      }
      return yield generator.apply(null, args);
    });
  };

  /**
   * Start/stop the element picker on the debuggee target.
   * @param {Boolean} doFocus - Optionally focus the content area once the picker is
   *                            activated.
   * @return A promise that resolves when done
   */
  let togglePicker = exported.togglePicker = function (doFocus) {
    if (isPicking) {
      return cancelPicker();
    } else {
      return startPicker(doFocus);
    }
  };

  /**
   * Start the element picker on the debuggee target.
   * This will request the inspector actor to start listening for mouse events
   * on the target page to highlight the hovered/picked element.
   * Depending on the server-side capabilities, this may fire events when nodes
   * are hovered.
   * @param {Boolean} doFocus - Optionally focus the content area once the picker is
   *                            activated.
   * @return A promise that resolves when the picker has started or immediately
   * if it is already started
   */
  let startPicker = exported.startPicker = requireInspector(function* (doFocus = false) {
    if (isPicking) {
      return;
    }
    isPicking = true;

    toolbox.pickerButtonChecked = true;
    yield toolbox.selectTool("inspector");
    toolbox.on("select", cancelPicker);

    if (isRemoteHighlightable()) {
      toolbox.walker.on("picker-node-hovered", onPickerNodeHovered);
      toolbox.walker.on("picker-node-picked", onPickerNodePicked);
      toolbox.walker.on("picker-node-previewed", onPickerNodePreviewed);
      toolbox.walker.on("picker-node-canceled", onPickerNodeCanceled);

      yield toolbox.highlighter.pick(doFocus);
      toolbox.emit("picker-started");
    } else {
      // If the target doesn't have the highlighter actor, we can use the
      // walker's pick method instead, knowing that it only responds when a node
      // is picked (instead of emitting events)
      toolbox.emit("picker-started");
      let node = yield toolbox.walker.pick();
      onPickerNodePicked({node: node});
    }
  });

  /**
   * Stop the element picker. Note that the picker is automatically stopped when
   * an element is picked
   * @return A promise that resolves when the picker has stopped or immediately
   * if it is already stopped
   */
  let stopPicker = exported.stopPicker = requireInspector(function* () {
    if (!isPicking) {
      return;
    }
    isPicking = false;

    toolbox.pickerButtonChecked = false;

    if (isRemoteHighlightable()) {
      yield toolbox.highlighter.cancelPick();
      toolbox.walker.off("picker-node-hovered", onPickerNodeHovered);
      toolbox.walker.off("picker-node-picked", onPickerNodePicked);
      toolbox.walker.off("picker-node-previewed", onPickerNodePreviewed);
      toolbox.walker.off("picker-node-canceled", onPickerNodeCanceled);
    } else {
      // If the target doesn't have the highlighter actor, use the walker's
      // cancelPick method instead
      yield toolbox.walker.cancelPick();
    }

    toolbox.off("select", cancelPicker);
    toolbox.emit("picker-stopped");
  });

  /**
   * Stop the picker, but also emit an event that the picker was canceled.
   */
  let cancelPicker = exported.cancelPicker = Task.async(function* () {
    yield stopPicker();
    toolbox.emit("picker-canceled");
  });

  /**
   * When a node is hovered by the mouse when the highlighter is in picker mode
   * @param {Object} data Information about the node being hovered
   */
  function onPickerNodeHovered(data) {
    toolbox.emit("picker-node-hovered", data.node);
  }

  /**
   * When a node has been picked while the highlighter is in picker mode
   * @param {Object} data Information about the picked node
   */
  function onPickerNodePicked(data) {
    toolbox.selection.setNodeFront(data.node, "picker-node-picked");
    stopPicker();
  }

  /**
   * When a node has been shift-clicked (previewed) while the highlighter is in
   * picker mode
   * @param {Object} data Information about the picked node
   */
  function onPickerNodePreviewed(data) {
    toolbox.selection.setNodeFront(data.node, "picker-node-previewed");
  }

  /**
   * When the picker is canceled, stop the picker, and make sure the toolbox
   * gets the focus.
   */
  function onPickerNodeCanceled() {
    cancelPicker();
    toolbox.win.focus();
  }

  /**
   * Show the box model highlighter on a node in the content page.
   * The node needs to be a NodeFront, as defined by the inspector actor
   * @see devtools/server/actors/inspector.js
   * @param {NodeFront} nodeFront The node to highlight
   * @param {Object} options
   * @return A promise that resolves when the node has been highlighted
   */
  let highlightNodeFront = exported.highlightNodeFront = requireInspector(
  function* (nodeFront, options = {}) {
    if (!nodeFront) {
      return;
    }

    isNodeFrontHighlighted = true;
    if (isRemoteHighlightable()) {
      yield toolbox.highlighter.showBoxModel(nodeFront, options);
    } else {
      // If the target doesn't have the highlighter actor, revert to the
      // walker's highlight method, which draws a simple outline
      yield toolbox.walker.highlight(nodeFront);
    }

    toolbox.emit("node-highlight", nodeFront, options.toSource());
  });

  /**
   * This is a convenience method in case you don't have a nodeFront but a
   * valueGrip. This is often the case with VariablesView properties.
   * This method will simply translate the grip into a nodeFront and call
   * highlightNodeFront, so it has the same signature.
   * @see highlightNodeFront
   */
  let highlightDomValueGrip = exported.highlightDomValueGrip = requireInspector(
  function* (valueGrip, options = {}) {
    let nodeFront = yield gripToNodeFront(valueGrip);
    if (nodeFront) {
      yield highlightNodeFront(nodeFront, options);
    } else {
      throw new Error("The ValueGrip passed could not be translated to a NodeFront");
    }
  });

  /**
   * Translate a debugger value grip into a node front usable by the inspector
   * @param {ValueGrip}
   * @return a promise that resolves to the node front when done
   */
  let gripToNodeFront = exported.gripToNodeFront = requireInspector(
  function* (grip) {
    return yield toolbox.walker.getNodeActorFromObjectActor(grip.actor);
  });

  /**
   * Hide the highlighter.
   * @param {Boolean} forceHide Only really matters in test mode (when
   * flags.testing is true). In test mode, hovering over several nodes
   * in the markup view doesn't hide/show the highlighter to ease testing. The
   * highlighter stays visible at all times, except when the mouse leaves the
   * markup view, which is when this param is passed to true
   * @return a promise that resolves when the highlighter is hidden
   */
  let unhighlight = exported.unhighlight = Task.async(
  function* (forceHide = false) {
    forceHide = forceHide || !flags.testing;

    // Note that if isRemoteHighlightable is true, there's no need to hide the
    // highlighter as the walker uses setTimeout to hide it after some time
    if (isNodeFrontHighlighted && forceHide && toolbox.highlighter && isRemoteHighlightable()) {
      isNodeFrontHighlighted = false;
      yield toolbox.highlighter.hideBoxModel();
    }

    // unhighlight is called when destroying the toolbox, which means that by
    // now, the toolbox reference might have been nullified already.
    if (toolbox) {
      toolbox.emit("node-unhighlight");
    }
  });

  /**
   * If the main, box-model, highlighter isn't enough, or if multiple
   * highlighters are needed in parallel, this method can be used to return a
   * new instance of a highlighter actor, given a type.
   * The type of the highlighter passed must be known by the server.
   * The highlighter actor returned will have the show(nodeFront) and hide()
   * methods and needs to be released by the consumer when not needed anymore.
   * @return a promise that resolves to the highlighter
   */
  let getHighlighterByType = exported.getHighlighterByType = requireInspector(
  function* (typeName) {
    let highlighter = null;

    if (supportsCustomHighlighters()) {
      highlighter = yield toolbox.inspector.getHighlighterByType(typeName);
    }

    return highlighter || promise.reject("The target doesn't support " +
        `creating highlighters by types or ${typeName} is unknown`);

  });

  // Return the public API
  return exported;
};
