/* 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 { throttle } = require("resource://devtools/shared/throttle.js");

const {
  clearFlexbox,
  updateFlexbox,
  updateFlexboxColor,
  updateFlexboxHighlighted,
} = require("resource://devtools/client/inspector/flexbox/actions/flexbox.js");
const flexboxReducer = require("resource://devtools/client/inspector/flexbox/reducers/flexbox.js");

loader.lazyRequireGetter(
  this,
  "parseURL",
  "resource://devtools/client/shared/source-utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "asyncStorage",
  "resource://devtools/shared/async-storage.js"
);

const FLEXBOX_COLOR = "#9400FF";

class FlexboxInspector {
  constructor(inspector, window) {
    this.document = window.document;
    this.inspector = inspector;
    this.selection = inspector.selection;
    this.store = inspector.store;

    this.store.injectReducer("flexbox", flexboxReducer);

    this.onHighlighterShown = this.onHighlighterShown.bind(this);
    this.onHighlighterHidden = this.onHighlighterHidden.bind(this);
    this.onNavigate = this.onNavigate.bind(this);
    this.onReflow = throttle(this.onReflow, 500, this);
    this.onSetFlexboxOverlayColor = this.onSetFlexboxOverlayColor.bind(this);
    this.onSidebarSelect = this.onSidebarSelect.bind(this);
    this.onUpdatePanel = this.onUpdatePanel.bind(this);

    this.init();
  }

  init() {
    if (!this.inspector) {
      return;
    }

    this.inspector.highlighters.on(
      "highlighter-shown",
      this.onHighlighterShown
    );
    this.inspector.highlighters.on(
      "highlighter-hidden",
      this.onHighlighterHidden
    );
    this.inspector.sidebar.on("select", this.onSidebarSelect);

    this.onSidebarSelect();
  }

  destroy() {
    this.selection.off("new-node-front", this.onUpdatePanel);
    this.inspector.off("new-root", this.onNavigate);
    this.inspector.off("reflow-in-selected-target", this.onReflow);
    this.inspector.highlighters.off(
      "highlighter-shown",
      this.onHighlighterShown
    );
    this.inspector.highlighters.off(
      "highlighter-hidden",
      this.onHighlighterHidden
    );
    this.inspector.sidebar.off("select", this.onSidebarSelect);

    this._customHostColors = null;
    this._overlayColor = null;
    this.document = null;
    this.inspector = null;
    this.selection = null;
    this.store = null;
  }

  getComponentProps() {
    return {
      onSetFlexboxOverlayColor: this.onSetFlexboxOverlayColor,
    };
  }

  /**
   * Returns an object containing the custom flexbox colors for different hosts.
   *
   * @return {object} that maps a host name to a custom flexbox color for a given host.
   */
  async getCustomHostColors() {
    if (this._customHostColors) {
      return this._customHostColors;
    }

    // Cache the custom host colors to avoid refetching from async storage.
    this._customHostColors =
      (await asyncStorage.getItem("flexboxInspectorHostColors")) || {};
    return this._customHostColors;
  }

  /**
   * Returns the flex container properties for a given node. If the given node is a flex
   * item, it attempts to fetch the flex container of the parent node of the given node.
   *
   * @param  {NodeFront} nodeFront
   *         The NodeFront to fetch the flex container properties.
   * @param  {boolean} onlyLookAtParents
   *         Whether or not to only consider the parent node of the given node.
   * @return {object} consisting of the given node's flex container's properties.
   */
  async getFlexContainerProps(nodeFront, onlyLookAtParents = false) {
    const layoutFront = await nodeFront.walkerFront.getLayoutInspector();
    const flexboxFront = await layoutFront.getCurrentFlexbox(
      nodeFront,
      onlyLookAtParents
    );

    if (!flexboxFront) {
      return null;
    }

    // If the FlexboxFront doesn't yet have access to the NodeFront for its container,
    // then get it from the walker. This happens when the walker hasn't seen this
    // particular DOM Node in the tree yet or when we are connected to an older server.
    let containerNodeFront = flexboxFront.containerNodeFront;
    if (!containerNodeFront) {
      containerNodeFront = await flexboxFront.walkerFront.getNodeFromActor(
        flexboxFront.actorID,
        ["containerEl"]
      );
    }

    const flexItems = await this.getFlexItems(flexboxFront);

    // If the current selected node is a flex item, display its flex item sizing
    // properties.
    let flexItemShown = null;
    if (onlyLookAtParents) {
      flexItemShown = this.selection.nodeFront.actorID;
    } else {
      const selectedFlexItem = flexItems.find(
        item => item.nodeFront === this.selection.nodeFront
      );
      if (selectedFlexItem) {
        flexItemShown = selectedFlexItem.nodeFront.actorID;
      }
    }

    return {
      actorID: flexboxFront.actorID,
      flexItems,
      flexItemShown,
      isFlexItemContainer: onlyLookAtParents,
      nodeFront: containerNodeFront,
      properties: flexboxFront.properties,
    };
  }

  /**
   * Returns an array of flex items object for the given flex container front.
   *
   * @param  {FlexboxFront} flexboxFront
   *         A flex container FlexboxFront.
   * @return {Array} of objects containing the flex item front properties.
   */
  async getFlexItems(flexboxFront) {
    const flexItemFronts = await flexboxFront.getFlexItems();
    const flexItems = [];

    for (const flexItemFront of flexItemFronts) {
      // Fetch the NodeFront of the flex items.
      let itemNodeFront = flexItemFront.nodeFront;
      if (!itemNodeFront) {
        itemNodeFront = await flexItemFront.walkerFront.getNodeFromActor(
          flexItemFront.actorID,
          ["element"]
        );
      }

      flexItems.push({
        actorID: flexItemFront.actorID,
        computedStyle: flexItemFront.computedStyle,
        flexItemSizing: flexItemFront.flexItemSizing,
        nodeFront: itemNodeFront,
        properties: flexItemFront.properties,
      });
    }

    return flexItems;
  }

  /**
   * Returns the custom overlay color for the current host or the default flexbox color.
   *
   * @return {string} overlay color.
   */
  async getOverlayColor() {
    if (this._overlayColor) {
      return this._overlayColor;
    }

    // Cache the overlay color for the current host to avoid repeatably parsing the host
    // and fetching the custom color from async storage.
    const customColors = await this.getCustomHostColors();
    const currentUrl = this.inspector.currentTarget.url;
    // Get the hostname, if there is no hostname, fall back on protocol
    // ex: `data:` uri, and `about:` pages
    const hostname =
      parseURL(currentUrl).hostname || parseURL(currentUrl).protocol;
    this._overlayColor = customColors[hostname]
      ? customColors[hostname]
      : FLEXBOX_COLOR;
    return this._overlayColor;
  }

  /**
   * Returns true if the layout panel is visible, and false otherwise.
   */
  isPanelVisible() {
    return (
      this.inspector &&
      this.inspector.toolbox &&
      this.inspector.sidebar &&
      this.inspector.toolbox.currentToolId === "inspector" &&
      this.inspector.sidebar.getCurrentTabID() === "layoutview"
    );
  }

  /**
   * Handler for "highlighter-shown" events emitted by HighlightersOverlay.
   * If the event is dispatched on behalf of a flex highlighter, toggle the
   * corresponding flex container's highlighted state in the Redux store.
   *
   * @param {object} data
   *        Object with data associated with the highlighter event.
   *        {NodeFront} data.nodeFront
   *        The NodeFront of the flex container element for which the flexbox
   *        highlighter is shown for.
   *        {String} data.type
   *        Highlighter type
   */
  onHighlighterShown(data) {
    if (data.type === this.inspector.highlighters.TYPES.FLEXBOX) {
      this.onHighlighterChange(true, data.nodeFront);
    }
  }

  /**
   * Handler for "highlighter-shown" events emitted by HighlightersOverlay.
   * If the event is dispatched on behalf of a flex highlighter, toggle the
   * corresponding flex container's highlighted state in the Redux store.
   *
   * @param {object} data
   *        Object with data associated with the highlighter event.
   *        {NodeFront} data.nodeFront
   *        The NodeFront of the flex container element for which the flexbox
   *        highlighter was previously shown for.
   *        {String} data.type
   *        Highlighter type
   */
  onHighlighterHidden(data) {
    if (data.type === this.inspector.highlighters.TYPES.FLEXBOX) {
      this.onHighlighterChange(false, data.nodeFront);
    }
  }

  /**
   * Updates the flex container highlighted state in the Redux store if the provided
   * NodeFront is the current selected flex container.
   *
   * @param  {boolean} highlighted
   *         Whether the change is to highlight or hide the overlay.
   * @param  {NodeFront} nodeFront
   *         The NodeFront of the flex container element for which the flexbox
   *         highlighter is shown for.
   */
  onHighlighterChange(highlighted, nodeFront) {
    const { flexbox } = this.store.getState();

    if (
      flexbox.flexContainer.nodeFront === nodeFront &&
      flexbox.highlighted !== highlighted
    ) {
      this.store.dispatch(updateFlexboxHighlighted(highlighted));
    }
  }

  /**
   * Handler for the "new-root" event fired by the inspector. Clears the cached overlay
   * color for the flexbox highlighter and updates the panel.
   */
  onNavigate() {
    this._overlayColor = null;
    this.onUpdatePanel();
  }

  /**
   * Handler for reflow events fired by the inspector when a node is selected. On reflows,
   * update the flexbox panel because the shape of the flexbox on the page may have
   * changed.
   */
  async onReflow() {
    if (
      !this.isPanelVisible() ||
      !this.store ||
      !this.selection.nodeFront ||
      this._isUpdating
    ) {
      return;
    }

    try {
      const flexContainer = await this.getFlexContainerProps(
        this.selection.nodeFront
      );

      // Clear the flexbox panel if there is no flex container for the current node
      // selection.
      if (!flexContainer) {
        this.store.dispatch(clearFlexbox());
        return;
      }

      const { flexbox } = this.store.getState();

      // Compare the new flexbox state of the current selected nodeFront with the old
      // flexbox state to determine if we need to update.
      if (hasFlexContainerChanged(flexbox.flexContainer, flexContainer)) {
        this.update(flexContainer);
        return;
      }

      let flexItemContainer = null;
      // If the current selected node is also the flex container node, check if it is
      // a flex item of a parent flex container.
      if (flexContainer.nodeFront === this.selection.nodeFront) {
        flexItemContainer = await this.getFlexContainerProps(
          this.selection.nodeFront,
          true
        );
      }

      // Compare the new and old state of the parent flex container properties.
      if (
        hasFlexContainerChanged(flexbox.flexItemContainer, flexItemContainer)
      ) {
        this.update(flexContainer, flexItemContainer);
      }
    } catch (e) {
      // This call might fail if called asynchrously after the toolbox is finished
      // closing.
    }
  }

  /**
   * Handler for a change in the flexbox overlay color picker for a flex container.
   *
   * @param  {string} color
   *         A hex string representing the color to use.
   */
  async onSetFlexboxOverlayColor(color) {
    this.store.dispatch(updateFlexboxColor(color));

    const { flexbox } = this.store.getState();

    if (flexbox.highlighted) {
      this.inspector.highlighters.showFlexboxHighlighter(
        flexbox.flexContainer.nodeFront
      );
    }

    this._overlayColor = color;

    const currentUrl = this.inspector.currentTarget.url;
    // Get the hostname, if there is no hostname, fall back on protocol
    // ex: `data:` uri, and `about:` pages
    const hostname =
      parseURL(currentUrl).hostname || parseURL(currentUrl).protocol;
    const customColors = await this.getCustomHostColors();
    customColors[hostname] = color;
    this._customHostColors = customColors;
    await asyncStorage.setItem("flexboxInspectorHostColors", customColors);
  }

  /**
   * Handler for the inspector sidebar "select" event. Updates the flexbox panel if it
   * is visible.
   */
  onSidebarSelect() {
    if (!this.isPanelVisible()) {
      this.inspector.off("reflow-in-selected-target", this.onReflow);
      this.inspector.off("new-root", this.onNavigate);
      this.selection.off("new-node-front", this.onUpdatePanel);
      return;
    }

    this.inspector.on("reflow-in-selected-target", this.onReflow);
    this.inspector.on("new-root", this.onNavigate);
    this.selection.on("new-node-front", this.onUpdatePanel);

    this.update();
  }

  /**
   * Handler for "new-root" event fired by the inspector and "new-node-front" event fired
   * by the inspector selection. Updates the flexbox panel if it is visible.
   *
   * @param  {object}
   *         This callback is sometimes executed on "new-node-front" events which means
   *         that a first param is passed here (the nodeFront), which we don't care about.
   * @param  {string} reason
   *         On "new-node-front" events, a reason is passed here, and we need it to detect
   *         if this update was caused by a node selection from the markup-view.
   */
  onUpdatePanel(_, reason) {
    if (!this.isPanelVisible()) {
      return;
    }

    this.update(null, null, reason === "treepanel");
  }

  /**
   * Updates the flexbox panel by dispatching the new flexbox data. This is called when
   * the layout view becomes visible or a new node is selected and needs to be update
   * with new flexbox data.
   *
   * @param  {object | null} flexContainer
   *         An object consisting of the current flex container's flex items and
   *         properties.
   * @param  {object | null} flexItemContainer
   *         An object consisting of the parent flex container's flex items and
   *         properties.
   * @param  {boolean} initiatedByMarkupViewSelection
   *         True if the update was due to a node selection in the markup-view.
   */
  async update(
    flexContainer,
    flexItemContainer,
    initiatedByMarkupViewSelection
  ) {
    this._isUpdating = true;

    // Stop refreshing if the inspector or store is already destroyed or no node is
    // selected.
    if (!this.inspector || !this.store || !this.selection.nodeFront) {
      this._isUpdating = false;
      return;
    }

    try {
      // Fetch the current flexbox if no flexbox front was passed into this update.
      if (!flexContainer) {
        flexContainer = await this.getFlexContainerProps(
          this.selection.nodeFront
        );
      }

      // Clear the flexbox panel if there is no flex container for the current node
      // selection.
      if (!flexContainer) {
        this.store.dispatch(clearFlexbox());
        this._isUpdating = false;
        return;
      }

      if (
        !flexItemContainer &&
        flexContainer.nodeFront === this.selection.nodeFront
      ) {
        flexItemContainer = await this.getFlexContainerProps(
          this.selection.nodeFront,
          true
        );
      }

      const highlighted =
        flexContainer.nodeFront ===
        this.inspector.highlighters.getNodeForActiveHighlighter(
          this.inspector.highlighters.TYPES.FLEXBOX
        );
      const color = await this.getOverlayColor();

      this.store.dispatch(
        updateFlexbox({
          color,
          flexContainer,
          flexItemContainer,
          highlighted,
          initiatedByMarkupViewSelection,
        })
      );
    } catch (e) {
      // This call might fail if called asynchrously after the toolbox is finished
      // closing.
    }

    this._isUpdating = false;
  }
}

/**
 * For a given flex container object, returns the flex container properties that can be
 * used to check if 2 flex container objects are the same.
 *
 * @param  {object | null} flexContainer
 *         Object consisting of the flex container's properties.
 * @return {object | null} consisting of the comparable flex container's properties.
 */
function getComparableFlexContainerProperties(flexContainer) {
  if (!flexContainer) {
    return null;
  }

  return {
    flexItems: getComparableFlexItemsProperties(flexContainer.flexItems),
    nodeFront: flexContainer.nodeFront.actorID,
    properties: flexContainer.properties,
  };
}

/**
 * Given an array of flex item objects, returns the relevant flex item properties that can
 * be compared to check if any changes has occurred.
 *
 * @param  {Array} flexItems
 *         Array of objects containing the flex item properties.
 * @return {Array} of objects consisting of the comparable flex item's properties.
 */
function getComparableFlexItemsProperties(flexItems) {
  return flexItems.map(item => {
    return {
      computedStyle: item.computedStyle,
      flexItemSizing: item.flexItemSizing,
      nodeFront: item.nodeFront.actorID,
      properties: item.properties,
    };
  });
}

/**
 * Compares the old and new flex container properties
 *
 * @param  {object} oldFlexContainer
 *         Object consisting of the old flex container's properties.
 * @param  {object} newFlexContainer
 *         Object consisting of the new flex container's properties.
 * @return {boolean} true if the flex container properties are the same, false otherwise.
 */
function hasFlexContainerChanged(oldFlexContainer, newFlexContainer) {
  return (
    JSON.stringify(getComparableFlexContainerProperties(oldFlexContainer)) !==
    JSON.stringify(getComparableFlexContainerProperties(newFlexContainer))
  );
}

module.exports = FlexboxInspector;
