/* 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 {
  PureComponent,
} = require("resource://devtools/client/shared/vendor/react.mjs");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
const {
  getStr,
} = require("resource://devtools/client/inspector/layout/utils/l10n.js");
const {
  getWritingModeMatrix,
  getCSSMatrixTransform,
} = require("resource://devtools/shared/layout/dom-matrix-2d.js");

const Types = require("resource://devtools/client/inspector/grids/types.js");

// The delay prior to executing the grid cell highlighting.
const GRID_HIGHLIGHTING_DEBOUNCE = 50;

// Prefs for the max number of rows/cols a grid container can have for
// the outline to display.
const GRID_OUTLINE_MAX_ROWS_PREF = Services.prefs.getIntPref(
  "devtools.gridinspector.gridOutlineMaxRows"
);
const GRID_OUTLINE_MAX_COLUMNS_PREF = Services.prefs.getIntPref(
  "devtools.gridinspector.gridOutlineMaxColumns"
);

// Move SVG grid to the right 100 units, so that it is not flushed against the edge of
// layout border
const TRANSLATE_X = 0;
const TRANSLATE_Y = 0;

const GRID_CELL_SCALE_FACTOR = 50;

const VIEWPORT_MIN_HEIGHT = 100;
const VIEWPORT_MAX_HEIGHT = 150;

const {
  showGridHighlighter,
} = require("resource://devtools/client/inspector/grids/actions/grid-highlighter.js");

class GridOutline extends PureComponent {
  static get propTypes() {
    return {
      dispatch: PropTypes.func.isRequired,
      grids: PropTypes.arrayOf(PropTypes.shape(Types.grid)).isRequired,
    };
  }

  static getDerivedStateFromProps(props) {
    const selectedGrid = props.grids.find(grid => grid.highlighted);

    // Store the height of the grid container in the component state to prevent overflow
    // issues. We want to store the width of the grid container as well so that the
    // viewbox is only the calculated width of the grid outline.
    const { width, height } = selectedGrid?.gridFragments.length
      ? getTotalWidthAndHeight(selectedGrid)
      : { width: 0, height: 0 };
    let showOutline;

    if (selectedGrid?.gridFragments.length) {
      const { cols, rows } = selectedGrid.gridFragments[0];

      // Show the grid outline if both the rows/columns are less than or equal
      // to their max prefs.
      showOutline =
        cols.lines.length <= GRID_OUTLINE_MAX_COLUMNS_PREF &&
        rows.lines.length <= GRID_OUTLINE_MAX_ROWS_PREF;
    }

    return { height, width, selectedGrid, showOutline };
  }

  constructor(props) {
    super(props);

    this.state = {
      height: 0,
      selectedGrid: null,
      showOutline: true,
      width: 0,
    };

    this.doHighlightCell = this.doHighlightCell.bind(this);
    this.getGridAreaName = this.getGridAreaName.bind(this);
    this.getHeight = this.getHeight.bind(this);
    this.onHighlightCell = this.onHighlightCell.bind(this);
    this.renderCannotShowOutlineText =
      this.renderCannotShowOutlineText.bind(this);
    this.renderGrid = this.renderGrid.bind(this);
    this.renderGridCell = this.renderGridCell.bind(this);
    this.renderGridOutline = this.renderGridOutline.bind(this);
    this.renderGridOutlineBorder = this.renderGridOutlineBorder.bind(this);
    this.renderOutline = this.renderOutline.bind(this);
  }

  doHighlightCell(target, hide) {
    const { dispatch, grids } = this.props;
    const name = target.dataset.gridAreaName;
    const id = target.dataset.gridId;
    const gridFragmentIndex = target.dataset.gridFragmentIndex;
    const rowNumber = target.dataset.gridRow;
    const columnNumber = target.dataset.gridColumn;
    const nodeFront = grids[id].nodeFront;

    // The options object has the following properties which corresponds to the
    // required parameters for showing the grid cell or area highlights.
    // See devtools/server/actors/highlighters/css-grid.js
    // {
    //   showGridArea: String,
    //   showGridCell: {
    //     gridFragmentIndex: Number,
    //     rowNumber: Number,
    //     columnNumber: Number,
    //   },
    // }
    const options = {
      showGridArea: name,
      showGridCell: {
        gridFragmentIndex,
        rowNumber,
        columnNumber,
      },
    };

    if (hide) {
      // Reset the grid highlighter to default state; no options = hide cell/area outline.
      dispatch(showGridHighlighter(nodeFront));
    } else {
      dispatch(showGridHighlighter(nodeFront, options));
    }
  }

  /**
   * Returns the grid area name if the given grid cell is part of a grid area, otherwise
   * null.
   *
   * @param  {number} columnNumber
   *         The column number of the grid cell.
   * @param  {number} rowNumber
   *         The row number of the grid cell.
   * @param  {Array} areas
   *         Array of grid areas data stored in the grid fragment.
   * @return {string} If there is a grid area return area name, otherwise null.
   */
  getGridAreaName(columnNumber, rowNumber, areas) {
    const gridArea = areas.find(
      area =>
        area.rowStart <= rowNumber &&
        area.rowEnd > rowNumber &&
        area.columnStart <= columnNumber &&
        area.columnEnd > columnNumber
    );

    if (!gridArea) {
      return null;
    }

    return gridArea.name;
  }

  /**
   * Returns the height of the grid outline ranging between a minimum and maximum height.
   *
   * @return {number} The height of the grid outline.
   */
  getHeight() {
    const { height } = this.state;

    if (height >= VIEWPORT_MAX_HEIGHT) {
      return VIEWPORT_MAX_HEIGHT;
    } else if (height <= VIEWPORT_MIN_HEIGHT) {
      return VIEWPORT_MIN_HEIGHT;
    }

    return height;
  }

  /**
   * Displays a message text "Cannot show outline for this grid".
   */
  renderCannotShowOutlineText() {
    return dom.div(
      { className: "grid-outline-text" },
      dom.span({
        className: "grid-outline-text-icon",
        title: getStr("layout.cannotShowGridOutline.title"),
      }),
      getStr("layout.cannotShowGridOutline")
    );
  }

  /**
   * Renders the grid outline for the given grid container object.
   *
   * @param  {object} grid
   *         A single grid container in the document.
   */
  renderGrid(grid) {
    // TODO: We are drawing the first fragment since only one is currently being stored.
    // In the future we will need to iterate over all fragments of a grid.
    const gridFragmentIndex = 0;
    const { id, color, gridFragments } = grid;
    const { rows, cols, areas } = gridFragments[gridFragmentIndex];

    const numberOfColumns = cols.lines.length - 1;
    const numberOfRows = rows.lines.length - 1;
    const rectangles = [];
    let x = 0;
    let y = 0;
    let width = 0;
    let height = 0;

    // Draw the cells contained within the grid outline border.
    for (let rowNumber = 1; rowNumber <= numberOfRows; rowNumber++) {
      height =
        GRID_CELL_SCALE_FACTOR * (rows.tracks[rowNumber - 1].breadth / 100);

      for (
        let columnNumber = 1;
        columnNumber <= numberOfColumns;
        columnNumber++
      ) {
        width =
          GRID_CELL_SCALE_FACTOR *
          (cols.tracks[columnNumber - 1].breadth / 100);

        const gridAreaName = this.getGridAreaName(
          columnNumber,
          rowNumber,
          areas
        );
        const gridCell = this.renderGridCell(
          id,
          gridFragmentIndex,
          x,
          y,
          rowNumber,
          columnNumber,
          color,
          gridAreaName,
          width,
          height
        );

        rectangles.push(gridCell);
        x += width;
      }

      x = 0;
      y += height;
    }

    // Transform the cells as needed to match the grid container's writing mode.
    const cellGroupStyle = {};
    const writingModeMatrix = getWritingModeMatrix(this.state, grid);
    cellGroupStyle.transform = getCSSMatrixTransform(writingModeMatrix);
    const cellGroup = dom.g(
      {
        id: "grid-cell-group",
        style: cellGroupStyle,
      },
      rectangles
    );

    // Draw a rectangle that acts as the grid outline border.
    const border = this.renderGridOutlineBorder(
      this.state.width,
      this.state.height,
      color
    );

    return [border, cellGroup];
  }

  /**
   * Renders the grid cell of a grid fragment.
   *
   * @param  {number} id
   *         The grid id stored on the grid fragment
   * @param  {number} gridFragmentIndex
   *         The index of the grid fragment rendered to the document.
   * @param  {number} x
   *         The x-position of the grid cell.
   * @param  {number} y
   *         The y-position of the grid cell.
   * @param  {number} rowNumber
   *         The row number of the grid cell.
   * @param  {number} columnNumber
   *         The column number of the grid cell.
   * @param  {string | null} gridAreaName
   *         The grid area name or null if the grid cell is not part of a grid area.
   * @param  {number} width
   *         The width of grid cell.
   * @param  {number} height
   *         The height of the grid cell.
   */
  renderGridCell(
    id,
    gridFragmentIndex,
    x,
    y,
    rowNumber,
    columnNumber,
    color,
    gridAreaName,
    width,
    height
  ) {
    return dom.rect({
      key: `${id}-${rowNumber}-${columnNumber}`,
      className: "grid-outline-cell",
      "data-grid-area-name": gridAreaName,
      "data-grid-fragment-index": gridFragmentIndex,
      "data-grid-id": id,
      "data-grid-row": rowNumber,
      "data-grid-column": columnNumber,
      x,
      y,
      width,
      height,
      fill: "none",
      onMouseEnter: this.onHighlightCell,
      onMouseLeave: this.onHighlightCell,
    });
  }

  renderGridOutline(grid) {
    const { color } = grid;

    return dom.g(
      {
        id: "grid-outline-group",
        className: "grid-outline-group",
        style: { color },
      },
      this.renderGrid(grid)
    );
  }

  renderGridOutlineBorder(borderWidth, borderHeight) {
    return dom.rect({
      key: "border",
      className: "grid-outline-border",
      x: 0,
      y: 0,
      width: borderWidth,
      height: borderHeight,
    });
  }

  renderOutline() {
    const { height, selectedGrid, showOutline, width } = this.state;

    return showOutline
      ? dom.svg(
          {
            id: "grid-outline",
            width: "100%",
            height: this.getHeight(),
            viewBox: `${TRANSLATE_X} ${TRANSLATE_Y} ${width} ${height}`,
          },
          this.renderGridOutline(selectedGrid)
        )
      : this.renderCannotShowOutlineText();
  }

  onHighlightCell({ target, type }) {
    // Debounce the highlighting of cells.
    // This way we don't end up sending many requests to the server for highlighting when
    // cells get hovered in a rapid succession We only send a request if the user settles
    // on a cell for some time.
    if (this.highlightTimeout) {
      clearTimeout(this.highlightTimeout);
    }

    this.highlightTimeout = setTimeout(() => {
      this.doHighlightCell(target, type === "mouseleave");
      this.highlightTimeout = null;
    }, GRID_HIGHLIGHTING_DEBOUNCE);
  }

  render() {
    const { selectedGrid } = this.state;

    return selectedGrid?.gridFragments.length
      ? dom.div(
          {
            id: "grid-outline-container",
            className: "grid-outline-container",
          },
          this.renderOutline()
        )
      : null;
  }
}

/**
 * Get the width and height of a given grid.
 *
 * @param  {object} grid
 *         A single grid container in the document.
 * @return {object} An object like { width, height }
 */
function getTotalWidthAndHeight(grid) {
  // TODO: We are drawing the first fragment since only one is currently being stored.
  // In the future we will need to iterate over all fragments of a grid.
  const { gridFragments } = grid;
  const { rows, cols } = gridFragments[0];

  let height = 0;
  for (let i = 0; i < rows.lines.length - 1; i++) {
    height += GRID_CELL_SCALE_FACTOR * (rows.tracks[i].breadth / 100);
  }

  let width = 0;
  for (let i = 0; i < cols.lines.length - 1; i++) {
    width += GRID_CELL_SCALE_FACTOR * (cols.tracks[i].breadth / 100);
  }

  // All writing modes other than horizontal-tb (the initial value) involve a 90 deg
  // rotation, so swap width and height.
  if (grid.writingMode != "horizontal-tb") {
    [width, height] = [height, width];
  }

  return { width, height };
}

module.exports = GridOutline;
