/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const flags = require("resource://devtools/shared/flags.js");
const { throttle } = require("resource://devtools/shared/throttle.js");

const gridsReducer = require("resource://devtools/client/inspector/grids/reducers/grids.js");
const highlighterSettingsReducer = require("resource://devtools/client/inspector/grids/reducers/highlighter-settings.js");
const {
  updateGridColor,
  updateGridHighlighted,
  updateGrids,
} = require("resource://devtools/client/inspector/grids/actions/grids.js");
const {
  updateShowGridAreas,
  updateShowGridLineNumbers,
  updateShowInfiniteLines,
} = require("resource://devtools/client/inspector/grids/actions/highlighter-settings.js");

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

const SHOW_GRID_AREAS = "devtools.gridinspector.showGridAreas";
const SHOW_GRID_LINE_NUMBERS = "devtools.gridinspector.showGridLineNumbers";
const SHOW_INFINITE_LINES_PREF = "devtools.gridinspector.showInfiniteLines";

// Default grid colors.
const GRID_COLORS = [
  "#9400FF",
  "#DF00A9",
  "#0A84FF",
  "#12BC00",
  "#EA8000",
  "#00B0BD",
  "#D70022",
  "#4B42FF",
  "#B5007F",
  "#058B00",
  "#A47F00",
  "#005A71",
];

class GridInspector {
  constructor(inspector, window) {
    this.document = window.document;
    this.inspector = inspector;
    this.store = inspector.store;
    this.telemetry = inspector.telemetry;

    // Maximum number of grid highlighters that can be displayed.
    this.maxHighlighters = Services.prefs.getIntPref(
      "devtools.gridinspector.maxHighlighters"
    );

    this.store.injectReducer("grids", gridsReducer);
    this.store.injectReducer("highlighterSettings", highlighterSettingsReducer);

    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.onSetGridOverlayColor = this.onSetGridOverlayColor.bind(this);
    this.onSidebarSelect = this.onSidebarSelect.bind(this);
    this.onToggleGridHighlighter = this.onToggleGridHighlighter.bind(this);
    this.onToggleShowGridAreas = this.onToggleShowGridAreas.bind(this);
    this.onToggleShowGridLineNumbers =
      this.onToggleShowGridLineNumbers.bind(this);
    this.onToggleShowInfiniteLines = this.onToggleShowInfiniteLines.bind(this);
    this.updateGridPanel = this.updateGridPanel.bind(this);
    this.listenForGridHighlighterEvents =
      this.listenForGridHighlighterEvents.bind(this);

    const { promise, resolve } = Promise.withResolvers();
    this.initialized = promise;
    this.#initializedPromiseResolve = resolve;

    this.init();
  }

  #initializedPromiseResolve;

  get highlighters() {
    if (!this._highlighters) {
      this._highlighters = this.inspector.highlighters;
    }

    return this._highlighters;
  }

  /**
   * Initializes the grid inspector by fetching the LayoutFront from the walker and
   * loading the highlighter settings.
   */
  async init() {
    if (!this.inspector) {
      return;
    }

    if (flags.testing) {
      // In tests, we start listening immediately to avoid having to simulate a mousemove.
      this.listenForGridHighlighterEvents();
    } else {
      this.document.addEventListener(
        "mousemove",
        this.listenForGridHighlighterEvents,
        {
          once: true,
        }
      );
    }

    this.inspector.sidebar.on("select", this.onSidebarSelect);
    this.inspector.on("new-root", this.onNavigate);

    this.onSidebarSelect();
  }

  listenForGridHighlighterEvents() {
    this.highlighters.on("grid-highlighter-hidden", this.onHighlighterHidden);
    this.highlighters.on("grid-highlighter-shown", this.onHighlighterShown);
  }

  /**
   * Get the LayoutActor fronts for all interesting targets where we have inspectors.
   *
   * @return {Array} The list of LayoutActor fronts
   */
  async getLayoutFronts() {
    const inspectorFronts = await this.inspector.getAllInspectorFronts();
    const layoutFronts = await Promise.all(
      inspectorFronts.map(({ walker }) => walker.getLayoutInspector())
    );
    return layoutFronts.filter(front => !front.isDestroyed());
  }

  /**
   * Destruction function called when the inspector is destroyed. Removes event listeners
   * and cleans up references.
   */
  destroy() {
    if (this._highlighters) {
      this.highlighters.off(
        "grid-highlighter-hidden",
        this.onHighlighterHidden
      );
      this.highlighters.off("grid-highlighter-shown", this.onHighlighterShown);
    }
    this.document.removeEventListener(
      "mousemove",
      this.listenForGridHighlighterEvents
    );

    this.inspector.sidebar.off("select", this.onSidebarSelect);
    this.inspector.off("new-root", this.onNavigate);

    this.inspector.off("reflow", this.onReflow);

    this._highlighters = null;
    this.document = null;
    this.inspector = null;
    this.store = null;
    this.initialized = null;
  }

  getComponentProps() {
    return {
      onSetGridOverlayColor: this.onSetGridOverlayColor,
      onToggleGridHighlighter: this.onToggleGridHighlighter,
      onToggleShowGridAreas: this.onToggleShowGridAreas,
      onToggleShowGridLineNumbers: this.onToggleShowGridLineNumbers,
      onToggleShowInfiniteLines: this.onToggleShowInfiniteLines,
    };
  }

  /**
   * Returns the initial color linked to a grid container. Will attempt to check the
   * current grid highlighter state and the store.
   *
   * @param  {NodeFront} nodeFront
   *         The NodeFront for which we need the color.
   * @param  {string} customColor
   *         The color fetched from the custom palette, if it exists.
   * @param  {string} fallbackColor
   *         The color to use if no color could be found for the node front.
   * @return {string} color
   *         The color to use.
   */
  getInitialGridColor(nodeFront, customColor, fallbackColor) {
    const highlighted = this.highlighters.gridHighlighters.has(nodeFront);

    let color;
    if (customColor) {
      color = customColor;
    } else if (
      highlighted &&
      this.highlighters.state.grids.has(nodeFront.actorID)
    ) {
      // If the node front is currently highlighted, use the color from the highlighter
      // options.
      color = this.highlighters.state.grids.get(nodeFront.actorID).options
        .color;
    } else {
      // Otherwise use the color defined in the store for this node front.
      color = this.getGridColorForNodeFront(nodeFront);
    }

    return color || fallbackColor;
  }

  /**
   * Returns the color set for the grid highlighter associated with the provided
   * nodeFront.
   *
   * @param  {NodeFront} nodeFront
   *         The NodeFront for which we need the color.
   */
  getGridColorForNodeFront(nodeFront) {
    const { grids } = this.store.getState();

    for (const grid of grids) {
      if (grid.nodeFront === nodeFront) {
        return grid.color;
      }
    }

    return null;
  }

  /**
   * Given a list of new grid fronts, and if there are highlighted grids, check
   * if their fragments have changed.
   *
   * @param  {Array} newGridFronts
   *         A list of GridFront objects.
   * @return {boolean}
   */
  haveCurrentFragmentsChanged(newGridFronts) {
    const gridHighlighters = this.highlighters.gridHighlighters;

    if (!gridHighlighters.size) {
      return false;
    }

    const gridFronts = newGridFronts.filter(g =>
      gridHighlighters.has(g.containerNodeFront)
    );
    if (!gridFronts.length) {
      return false;
    }

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

    for (const node of gridHighlighters.keys()) {
      const oldFragments = grids.find(g => g.nodeFront === node).gridFragments;
      const newFragments = newGridFronts.find(
        g => g.containerNodeFront === node
      ).gridFragments;

      if (!compareFragmentsGeometry(oldFragments, newFragments)) {
        return true;
      }
    }

    return false;
  }

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

  /**
   * Updates the grid panel by dispatching the new grid data. This is called when the
   * layout view becomes visible or the view needs to be updated with new grid data.
   */
  async updateGridPanel() {
    // Stop refreshing if the inspector or store is already destroyed.
    if (!this.inspector || !this.store) {
      return;
    }

    try {
      await this._updateGridPanel();
    } catch (e) {
      this._throwUnlessDestroyed(
        e,
        "Inspector destroyed while executing updateGridPanel"
      );
    }
    this.#initializedPromiseResolve();
  }

  async _updateGridPanel() {
    const gridFronts = await this.getGrids();

    if (!gridFronts.length) {
      try {
        this.store.dispatch(updateGrids([]));
        return;
      } catch (e) {
        // This call might fail if called asynchrously after the toolbox is finished
        // closing.
        return;
      }
    }

    const currentUrl = this.inspector.currentTarget.url;

    // Log how many CSS Grid elements DevTools sees.
    if (currentUrl != this.inspector.previousURL) {
      Glean.devtoolsInspector.numberOfCssGridsInAPage.accumulateSingleSample(
        gridFronts.length
      );
      this.inspector.previousURL = currentUrl;
    }

    // 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 asyncStorage.getItem("gridInspectorHostColors")) || {};

    const grids = [];
    for (let i = 0; i < gridFronts.length; i++) {
      const grid = gridFronts[i];
      let nodeFront = grid.containerNodeFront;

      // If the GridFront didn't yet have access to the NodeFront for its container, then
      // get it from the walker. This happens when the walker hasn't yet seen this
      // particular DOM Node in the tree yet, or when we are connected to an older server.
      if (!nodeFront) {
        try {
          nodeFront = await grid.walkerFront.getNodeFromActor(grid.actorID, [
            "containerEl",
          ]);
        } catch (e) {
          // This call might fail if called asynchrously after the toolbox is finished
          // closing.
          return;
        }
      }

      const colorForHost = customColors[hostname]
        ? customColors[hostname][i]
        : null;
      const fallbackColor = GRID_COLORS[i % GRID_COLORS.length];
      const color = this.getInitialGridColor(
        nodeFront,
        colorForHost,
        fallbackColor
      );
      const highlighted = this.highlighters.gridHighlighters.has(nodeFront);
      const disabled =
        !highlighted &&
        this.maxHighlighters > 1 &&
        this.highlighters.gridHighlighters.size === this.maxHighlighters;
      const isSubgrid = grid.isSubgrid;
      const gridData = {
        id: i,
        actorID: grid.actorID,
        color,
        disabled,
        direction: grid.direction,
        gridFragments: grid.gridFragments,
        highlighted,
        isSubgrid,
        nodeFront,
        parentNodeActorID: null,
        subgrids: [],
        writingMode: grid.writingMode,
      };

      if (isSubgrid) {
        let parentGridNodeFront;

        try {
          parentGridNodeFront =
            await nodeFront.walkerFront.getParentGridNode(nodeFront);
        } catch (e) {
          // This call might fail if called asynchrously after the toolbox is finished
          // closing.
          return;
        }

        if (!parentGridNodeFront) {
          return;
        }

        const parentIndex = grids.findIndex(
          g => g.nodeFront.actorID === parentGridNodeFront.actorID
        );
        gridData.parentNodeActorID = parentGridNodeFront.actorID;
        grids[parentIndex].subgrids.push(gridData.id);
      }

      grids.push(gridData);
    }

    // We need to make sure that nested subgrids are displayed above their parent grid
    // containers, so update the z-index of each grid before rendering them.
    for (const root of grids.filter(g => !g.parentNodeActorID)) {
      this._updateZOrder(grids, root);
    }

    this.store.dispatch(updateGrids(grids));
  }

  /**
   * Get all GridFront instances from the server(s).
   *
   * @return {Array} The list of GridFronts
   */
  async getGrids() {
    const promises = [];
    try {
      const layoutFronts = await this.getLayoutFronts();
      for (const layoutFront of layoutFronts) {
        promises.push(layoutFront.getAllGrids());
      }
    } catch (e) {
      // This call might fail if called asynchrously after the toolbox is finished closing
    }

    const gridFronts = (await Promise.all(promises)).flat();
    return gridFronts;
  }

  /**
   * Handler for "grid-highlighter-shown" events emitted from the
   * HighlightersOverlay. Passes nodefront and event name to handleHighlighterChange.
   * Required since on and off events need the same reference object.
   *
   * @param  {NodeFront} nodeFront
   *         The NodeFront of the grid container element for which the grid
   *         highlighter is shown for.
   */
  onHighlighterShown(nodeFront) {
    this.onHighlighterChange(nodeFront, true);
  }

  /**
   * Handler for "grid-highlighter-hidden" events emitted from the
   * HighlightersOverlay. Passes nodefront and event name to handleHighlighterChange.
   * Required since on and off events need the same reference object.
   *
   * @param  {NodeFront} nodeFront
   *         The NodeFront of the grid container element for which the grid highlighter
   *         is hidden for.
   */
  onHighlighterHidden(nodeFront) {
    this.onHighlighterChange(nodeFront, false);
  }

  /**
   * Handler for "grid-highlighter-shown" and "grid-highlighter-hidden" events emitted
   * from the HighlightersOverlay. Updates the NodeFront's grid highlighted state.
   *
   * @param  {NodeFront} nodeFront
   *         The NodeFront of the grid container element for which the grid highlighter
   *         is shown for.
   * @param  {boolean} highlighted
   *         If the grid should be updated to highlight or hide.
   */
  onHighlighterChange(nodeFront, highlighted) {
    if (!this.isPanelVisible()) {
      return;
    }

    const { grids } = this.store.getState();
    const grid = grids.find(g => g.nodeFront === nodeFront);

    if (!grid || grid.highlighted === highlighted) {
      return;
    }

    this.store.dispatch(updateGridHighlighted(nodeFront, highlighted));
  }

  /**
   * Handler for "new-root" event fired by the inspector, which indicates a page
   * navigation. Updates grid panel contents.
   */
  onNavigate() {
    if (this.isPanelVisible()) {
      this.updateGridPanel();
    }
  }

  /**
   * Handler for reflow events fired by the inspector when a node is selected. On reflows,
   * update the grid panel content, because the shape or number of grids on the page may
   * have changed.
   *
   * Note that there may be frequent reflows on the page and that not all of them actually
   * cause the grids to change. So, we want to limit how many times we update the grid
   * panel to only reflows that actually either change the list of grids, or those that
   * change the current outlined grid.
   * To achieve this, this function compares the list of grid containers from before and
   * after the reflow, as well as the grid fragment data on the currently highlighted
   * grid.
   */
  async onReflow() {
    try {
      if (!this.isPanelVisible()) {
        return;
      }

      // The list of grids currently displayed.
      const { grids } = this.store.getState();

      // The new list of grids from the server.
      const newGridFronts = await this.getGrids();

      // In some cases, the nodes for current grids may have been removed from the DOM in
      // which case we need to update.
      if (grids.length && grids.some(grid => !grid.nodeFront.actorID)) {
        await this.updateGridPanel(newGridFronts);
        return;
      }

      // Get the node front(s) from the current grid(s) so we can compare them to them to
      // the node(s) of the new grids.
      const oldNodeFronts = grids.map(grid => grid.nodeFront.actorID);
      const newNodeFronts = newGridFronts
        .filter(grid => grid.containerNode)
        .map(grid => grid.containerNodeFront.actorID);

      if (
        grids.length === newGridFronts.length &&
        oldNodeFronts.sort().join(",") == newNodeFronts.sort().join(",") &&
        !this.haveCurrentFragmentsChanged(newGridFronts)
      ) {
        // Same list of containers and the geometry of all the displayed grids remained the
        // same, we can safely abort.
        return;
      }

      // Either the list of containers or the current fragments have changed, do update.
      await this.updateGridPanel(newGridFronts);
    } catch (e) {
      this._throwUnlessDestroyed(
        e,
        "Inspector destroyed while executing onReflow callback"
      );
    }
  }

  /**
   * Handler for a change in the grid overlay color picker for a grid container.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the grid container element for which the grid color is
   *         being updated.
   * @param  {string} color
   *         A hex string representing the color to use.
   */
  async onSetGridOverlayColor(node, color) {
    this.store.dispatch(updateGridColor(node, color));

    const { grids } = this.store.getState();
    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 customGridColors =
      (await asyncStorage.getItem("gridInspectorHostColors")) || {};

    for (const grid of grids) {
      if (grid.nodeFront !== node) {
        continue;
      }

      if (!customGridColors[hostname]) {
        customGridColors[hostname] = [];
      }
      // Update the custom color for the grid in this position.
      customGridColors[hostname][grid.id] = color;
      await asyncStorage.setItem("gridInspectorHostColors", customGridColors);

      if (!this.isPanelVisible()) {
        // This call might fail if called asynchrously after the toolbox is finished
        // closing.
        return;
      }

      // If the grid for which the color was updated currently has a highlighter, update
      // the color.
      if (this.highlighters.gridHighlighters.has(node)) {
        this.highlighters.showGridHighlighter(node);
        continue;
      }

      // If the node is not explicitly highlighted, but is a parent grid which has an
      // highlighted subgrid, we also want to update the color.
      const subGrid = grids.find(({ id }) => grid.subgrids.includes(id));
      if (subGrid?.highlighted) {
        this.highlighters.showParentGridHighlighter(node);
      }
    }
  }

  /**
   * Handler for the inspector sidebar "select" event. Starts tracking reflows
   * if the layout panel is visible. Otherwise, stop tracking reflows.
   * Finally, refresh the layout view if it is visible.
   */
  onSidebarSelect() {
    if (!this.isPanelVisible()) {
      this.inspector.off("reflow", this.onReflow);
      return;
    }

    // The panel shows grids from all debugged document, so we need to listen for the
    // `reflow` event (and not `reflow-in-selected-target`).
    this.inspector.on("reflow", this.onReflow);
    this.updateGridPanel();
  }

  /**
   * Handler for a change in the input checkboxes in the GridList component.
   * Toggles on/off the grid highlighter for the provided grid container element.
   *
   * @param  {NodeFront} node
   *         The NodeFront of the grid container element for which the grid
   *         highlighter is toggled on/off for.
   */
  onToggleGridHighlighter(node) {
    const { grids } = this.store.getState();
    const grid = grids.find(g => g.nodeFront === node);
    this.store.dispatch(updateGridHighlighted(node, !grid.highlighted));
    this.highlighters.toggleGridHighlighter(node, "grid");
  }

  /**
   * Handler for a change in the show grid areas checkbox in the GridDisplaySettings
   * component. Toggles on/off the option to show the grid areas in the grid highlighter.
   * Refreshes the shown grid highlighter for the grids currently highlighted.
   *
   * @param  {boolean} enabled
   *         Whether or not the grid highlighter should show the grid areas.
   */
  onToggleShowGridAreas(enabled) {
    this.store.dispatch(updateShowGridAreas(enabled));
    Services.prefs.setBoolPref(SHOW_GRID_AREAS, enabled);

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

    for (const grid of grids) {
      if (grid.highlighted) {
        this.highlighters.showGridHighlighter(grid.nodeFront);
      }
    }
  }

  /**
   * Handler for a change in the show grid line numbers checkbox in the
   * GridDisplaySettings component. Toggles on/off the option to show the grid line
   * numbers in the grid highlighter. Refreshes the shown grid highlighter for the
   * grids currently highlighted.
   *
   * @param  {boolean} enabled
   *         Whether or not the grid highlighter should show the grid line numbers.
   */
  onToggleShowGridLineNumbers(enabled) {
    this.store.dispatch(updateShowGridLineNumbers(enabled));
    Services.prefs.setBoolPref(SHOW_GRID_LINE_NUMBERS, enabled);

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

    for (const grid of grids) {
      if (grid.highlighted) {
        this.highlighters.showGridHighlighter(grid.nodeFront);
      }
    }
  }

  /**
   * Handler for a change in the extend grid lines infinitely checkbox in the
   * GridDisplaySettings component. Toggles on/off the option to extend the grid
   * lines infinitely in the grid highlighter. Refreshes the shown grid highlighter
   * for grids currently highlighted.
   *
   * @param  {boolean} enabled
   *         Whether or not the grid highlighter should extend grid lines infinitely.
   */
  onToggleShowInfiniteLines(enabled) {
    this.store.dispatch(updateShowInfiniteLines(enabled));
    Services.prefs.setBoolPref(SHOW_INFINITE_LINES_PREF, enabled);

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

    for (const grid of grids) {
      if (grid.highlighted) {
        this.highlighters.showGridHighlighter(grid.nodeFront);
      }
    }
  }

  /**
   * Some grid-inspector methods are highly asynchronous and might still run
   * after the inspector was destroyed. Swallow errors if the grid inspector is
   * already destroyed, throw otherwise.
   *
   * @param {Error} error
   *        The original error object.
   * @param {string} message
   *        The message to log in case the inspector is already destroyed and
   *        the error is swallowed.
   */
  _throwUnlessDestroyed(error, message) {
    if (!this.inspector) {
      console.warn(message);
    } else {
      // If the grid inspector was not destroyed, this is an unexpected error.
      throw error;
    }
  }

  /**
   * Set z-index of each grids so that nested subgrids are always above their parent grid
   * container.
   *
   * @param {Array} grids
   *        A list of grid data.
   * @param {object} parent
   *        A grid data of parent.
   * @param {number} zIndex
   *        z-index for the parent.
   */
  _updateZOrder(grids, parent, zIndex = 0) {
    parent.zIndex = zIndex;

    for (const childIndex of parent.subgrids) {
      // Recurse into children grids.
      this._updateZOrder(grids, grids[childIndex], zIndex + 1);
    }
  }
}

module.exports = GridInspector;
