/* 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,
  "CssLogic",
  "resource://devtools/server/actors/inspector/css-logic.js",
  true
);
loader.lazyRequireGetter(
  this,
  "getCurrentZoom",
  "resource://devtools/shared/layout/utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "addPseudoClassLock",
  "resource://devtools/server/actors/highlighters/utils/markup.js",
  true
);
loader.lazyRequireGetter(
  this,
  "removePseudoClassLock",
  "resource://devtools/server/actors/highlighters/utils/markup.js",
  true
);
loader.lazyRequireGetter(
  this,
  "getContrastRatioAgainstBackground",
  "resource://devtools/shared/accessibility.js",
  true
);
loader.lazyRequireGetter(
  this,
  "getTextProperties",
  "resource://devtools/shared/accessibility.js",
  true
);
loader.lazyRequireGetter(
  this,
  "InspectorActorUtils",
  "resource://devtools/server/actors/inspector/utils.js"
);
const lazy = {};
ChromeUtils.defineESModuleGetters(
  lazy,
  {
    DevToolsWorker: "resource://devtools/shared/worker/worker.sys.mjs",
  },
  { global: "contextual" }
);

const WORKER_URL = "resource://devtools/server/actors/accessibility/worker.js";
const HIGHLIGHTED_PSEUDO_CLASS = ":-moz-devtools-highlighted";
const {
  LARGE_TEXT: { BOLD_LARGE_TEXT_MIN_PIXELS, LARGE_TEXT_MIN_PIXELS },
} = require("resource://devtools/shared/accessibility.js");

loader.lazyGetter(this, "worker", () => new lazy.DevToolsWorker(WORKER_URL));

/**
 * Get canvas rendering context for the current target window bound by the bounds of the
 * accessible objects.
 *
 * @param  {object}  win
 *         Current target window.
 * @param  {object}  bounds
 *         Bounds for the accessible object.
 * @param  {object}  zoom
 *         Current zoom level for the window.
 * @param  {object}  scale
 *         Scale value to scale down the drawn image.
 * @param  {null|DOMNode} node
 *         If not null, a node that corresponds to the accessible object to be used to
 *         make its text color transparent.
 * @return {CanvasRenderingContext2D}
 *         Canvas rendering context for the current window.
 */
function getImageCtx(win, bounds, zoom, scale, node) {
  const doc = win.document;
  const canvas = doc.createElementNS("http://www.w3.org/1999/xhtml", "canvas");

  const { left, top, width, height } = bounds;
  canvas.width = width * zoom * scale;
  canvas.height = height * zoom * scale;
  const ctx = canvas.getContext("2d", { alpha: false });
  ctx.imageSmoothingEnabled = false;
  ctx.scale(scale, scale);

  // If node is passed, make its color related text properties invisible.
  if (node) {
    addPseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS);
  }

  ctx.drawWindow(
    win,
    left * zoom,
    top * zoom,
    width * zoom,
    height * zoom,
    "#fff",
    ctx.DRAWWINDOW_USE_WIDGET_LAYERS
  );

  // Restore all inline styling.
  if (node) {
    removePseudoClassLock(node, HIGHLIGHTED_PSEUDO_CLASS);
  }

  return ctx;
}

/**
 * Calculate the transformed RGBA when a color matrix is set in docShell by
 * multiplying the color matrix with the RGBA vector.
 *
 * @param  {Array}  rgba
 *         Original RGBA array which we want to transform.
 * @param  {Array}  colorMatrix
 *         Flattened 4x5 color matrix that is set in docShell.
 *         A 4x5 matrix of the form:
 *           1  2  3  4  5
 *           6  7  8  9  10
 *           11 12 13 14 15
 *           16 17 18 19 20
 *         will be set in docShell as:
 *           [1, 6, 11, 16, 2, 7, 12, 17, 3, 8, 13, 18, 4, 9, 14, 19, 5, 10, 15, 20]
 * @return {Array}
 *         Transformed RGBA after the color matrix is multiplied with the original RGBA.
 */
function getTransformedRGBA(rgba, colorMatrix) {
  const transformedRGBA = [0, 0, 0, 0];

  // Only use the first four columns of the color matrix corresponding to R, G, B and A
  // color channels respectively. The fifth column is a fixed offset that does not need
  // to be considered for the matrix multiplication. We end up multiplying a 4x4 color
  // matrix with a 4x1 RGBA vector.
  for (let i = 0; i < 16; i++) {
    const row = i % 4;
    const col = Math.floor(i / 4);
    transformedRGBA[row] += colorMatrix[i] * rgba[col];
  }

  return transformedRGBA;
}

/**
 * Find RGBA or a range of RGBAs for the background pixels under the text.
 *
 * @param  {DOMNode}  node
 *         Node for which we want to get the background color data.
 * @param  {object}  options
 *         - bounds       {object}
 *                        Bounds for the accessible object.
 *         - win          {object}
 *                        Target window.
 *         - size         {Number}
 *                        Font size of the selected text node
 *         - isBoldText   {Boolean}
 *                        True if selected text node is bold
 * @return {object}
 *         Object with one or more of the following RGBA fields: value, min, max
 */
function getBackgroundFor(node, { win, bounds, size, isBoldText }) {
  const zoom = 1 / getCurrentZoom(win);
  // When calculating colour contrast, we traverse image data for text nodes that are
  // drawn both with and without transparent text. Image data arrays are typically really
  // big. In cases when the font size is fairly large or when the page is zoomed in image
  // data is especially large (retrieving it and/or traversing it takes significant amount
  // of time). Here we optimize the size of the image data by scaling down the drawn nodes
  // to a size where their text size equals either BOLD_LARGE_TEXT_MIN_PIXELS or
  // LARGE_TEXT_MIN_PIXELS (lower threshold for large text size) depending on the font
  // weight.
  //
  // IMPORTANT: this optimization, in some cases where background colour is non-uniform
  // (gradient or image), can result in small (not noticeable) blending of the background
  // colours. In turn this might affect the reported values of the contrast ratio. The
  // delta is fairly small (<0.1) to noticeably skew the results.
  //
  // NOTE: this optimization does not help in cases where contrast is being calculated for
  // nodes with a lot of text.
  let scale =
    ((isBoldText ? BOLD_LARGE_TEXT_MIN_PIXELS : LARGE_TEXT_MIN_PIXELS) / size) *
    zoom;
  // We do not need to scale the images if the font is smaller than large or if the page
  // is zoomed out (scaling in this case would've been scaling up).
  scale = scale > 1 ? 1 : scale;

  const textContext = getImageCtx(win, bounds, zoom, scale);
  const backgroundContext = getImageCtx(win, bounds, zoom, scale, node);

  const { data: dataText } = textContext.getImageData(
    0,
    0,
    bounds.width * scale,
    bounds.height * scale
  );
  const { data: dataBackground } = backgroundContext.getImageData(
    0,
    0,
    bounds.width * scale,
    bounds.height * scale
  );

  return worker.performTask(
    "getBgRGBA",
    {
      dataTextBuf: dataText.buffer,
      dataBackgroundBuf: dataBackground.buffer,
    },
    [dataText.buffer, dataBackground.buffer]
  );
}

/**
 * Calculates the contrast ratio of the referenced DOM node.
 *
 * @param  {DOMNode} node
 *         The node for which we want to calculate the contrast ratio.
 * @param  {object}  options
 *         - bounds                           {object}
 *                                            Bounds for the accessible object.
 *         - win                              {object}
 *                                            Target window.
 *         - appliedColorMatrix               {Array|null}
 *                                            Simulation color matrix applied to
 *                                            to the viewport, if it exists.
 * @return {object}
 *         An object that may contain one or more of the following fields: error,
 *         isLargeText, value, min, max values for contrast.
 */
async function getContrastRatioFor(node, options = {}) {
  const computedStyle = CssLogic.getComputedStyle(node);
  const props = computedStyle ? getTextProperties(computedStyle) : null;

  if (!props) {
    return {
      error: true,
    };
  }

  const { isLargeText, isBoldText, size, opacity } = props;
  const { appliedColorMatrix } = options;
  const color = appliedColorMatrix
    ? getTransformedRGBA(props.color, appliedColorMatrix)
    : props.color;
  let rgba = await getBackgroundFor(node, {
    ...options,
    isBoldText,
    size,
  });

  if (!rgba) {
    // Fallback (original) contrast calculation algorithm. It tries to get the
    // closest background colour for the node and use it to calculate contrast.
    const backgroundColor = InspectorActorUtils.getClosestBackgroundColor(node);
    const backgroundImage = InspectorActorUtils.getClosestBackgroundImage(node);

    if (backgroundImage !== "none") {
      // Both approaches failed, at this point we don't have a better one yet.
      return {
        error: true,
      };
    }

    let { r, g, b, a } = InspectorUtils.colorToRGBA(backgroundColor);
    // If the element has opacity in addition to background alpha value, take it
    // into account. TODO: this does not handle opacity set on ancestor
    // elements (see bug https://bugzilla.mozilla.org/show_bug.cgi?id=1544721).
    if (opacity < 1) {
      a = opacity * a;
    }

    return getContrastRatioAgainstBackground(
      {
        value: appliedColorMatrix
          ? getTransformedRGBA([r, g, b, a], appliedColorMatrix)
          : [r, g, b, a],
      },
      {
        color,
        isLargeText,
      }
    );
  }

  if (appliedColorMatrix) {
    rgba = rgba.value
      ? {
          value: getTransformedRGBA(rgba.value, appliedColorMatrix),
        }
      : {
          min: getTransformedRGBA(rgba.min, appliedColorMatrix),
          max: getTransformedRGBA(rgba.max, appliedColorMatrix),
        };
  }

  return getContrastRatioAgainstBackground(rgba, {
    color,
    isLargeText,
  });
}

exports.getContrastRatioFor = getContrastRatioFor;
exports.getBackgroundFor = getBackgroundFor;
