/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
const {
  MultiLocalizationHelper,
} = require("resource://devtools/shared/l10n.js");

loader.lazyRequireGetter(
  this,
  "colorUtils",
  "resource://devtools/shared/css/color.js",
  true
);
loader.lazyRequireGetter(
  this,
  "labColors",
  "resource://devtools/shared/css/color-db.js",
  true
);
loader.lazyRequireGetter(
  this,
  ["getTextProperties", "getContrastRatioAgainstBackground"],
  "resource://devtools/shared/accessibility.js",
  true
);

const L10N = new MultiLocalizationHelper(
  "devtools/client/locales/accessibility.properties",
  "devtools/client/locales/inspector.properties"
);
const ARROW_KEYS = ["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"];
const [ArrowUp, ArrowRight, ArrowDown, ArrowLeft] = ARROW_KEYS;
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const SLIDER = {
  hue: {
    MIN: "0",
    MAX: "128",
    STEP: "1",
  },
  alpha: {
    MIN: "0",
    MAX: "1",
    STEP: "0.01",
  },
};

/**
 * Spectrum creates a color picker widget in any container you give it.
 *
 * Simple usage example:
 *
 * const {Spectrum} = require("devtools/client/shared/widgets/Spectrum");
 * let s = new Spectrum(containerElement, [255, 126, 255, 1]);
 * s.on("changed", (rgba, color) => {
 *   console.log("rgba(" + rgba[0] + ", " + rgba[1] + ", " + rgba[2] + ", " +
 *     rgba[3] + ")");
 * });
 * s.show();
 * s.destroy();
 *
 * Note that the color picker is hidden by default and you need to call show to
 * make it appear. This 2 stages initialization helps in cases you are creating
 * the color picker in a parent element that hasn't been appended anywhere yet
 * or that is hidden. Calling show() when the parent element is appended and
 * visible will allow spectrum to correctly initialize its various parts.
 *
 * Fires the following events:
 * - changed : When the user changes the current color
 */
class Spectrum {
  constructor(parentEl, rgb) {
    EventEmitter.decorate(this);

    this.document = parentEl.ownerDocument;
    this.element = parentEl.ownerDocument.createElementNS(XHTML_NS, "div");
    this.parentEl = parentEl;

    this.element.className = "spectrum-container";
    // eslint-disable-next-line no-unsanitized/property
    this.element.innerHTML = `
    <section class="spectrum-color-picker">
      <div class="spectrum-color spectrum-box"
           tabindex="0"
           role="slider"
           title="${L10N.getStr("colorPickerTooltip.spectrumDraggerTitle")}"
           aria-describedby="spectrum-dragger">
        <div class="spectrum-sat">
          <div class="spectrum-val">
            <div class="spectrum-dragger" id="spectrum-dragger"></div>
          </div>
        </div>
      </div>
    </section>
    <section class="spectrum-controls">
      <div class="spectrum-color-preview"></div>
      <div class="spectrum-slider-container">
        <div class="spectrum-hue spectrum-box"></div>
        <div class="spectrum-alpha spectrum-checker spectrum-box"></div>
      </div>
    </section>
    <section class="spectrum-color-contrast accessibility-color-contrast">
      <div class="contrast-ratio-header-and-single-ratio">
        <span class="contrast-ratio-label" role="presentation"></span>
        <span class="contrast-value-and-swatch contrast-ratio-single" role="presentation">
          <span class="accessibility-contrast-value"></span>
        </span>
      </div>
      <div class="contrast-ratio-range">
        <span class="contrast-value-and-swatch contrast-ratio-min" role="presentation">
          <span class="accessibility-contrast-value"></span>
        </span>
        <span class="accessibility-color-contrast-separator"></span>
        <span class="contrast-value-and-swatch contrast-ratio-max" role="presentation">
          <span class="accessibility-contrast-value"></span>
        </span>
      </div>
    </section>
  `;

    this.onElementClick = this.onElementClick.bind(this);
    this.element.addEventListener("click", this.onElementClick);

    this.parentEl.appendChild(this.element);

    // Color spectrum dragger.
    this.dragger = this.element.querySelector(".spectrum-color");
    this.dragHelper = this.element.querySelector(".spectrum-dragger");
    draggable(this.dragger, this.dragHelper, this.onDraggerMove.bind(this));

    // Here we define the components for the "controls" section of the color picker.
    this.controls = this.element.querySelector(".spectrum-controls");
    this.colorPreview = this.element.querySelector(".spectrum-color-preview");

    // Create the eyedropper.
    const eyedropper = this.document.createElementNS(XHTML_NS, "button");
    eyedropper.id = "eyedropper-button";
    eyedropper.className = "devtools-button";
    eyedropper.style.pointerEvents = "auto";
    eyedropper.setAttribute(
      "aria-label",
      L10N.getStr("colorPickerTooltip.eyedropperTitle")
    );
    this.controls.insertBefore(eyedropper, this.colorPreview);

    // Hue slider and alpha slider
    this.hueSlider = this.createSlider("hue", this.onHueSliderMove.bind(this));
    this.hueSlider.setAttribute("aria-describedby", this.dragHelper.id);
    this.alphaSlider = this.createSlider(
      "alpha",
      this.onAlphaSliderMove.bind(this)
    );

    // Color contrast
    this.spectrumContrast = this.element.querySelector(
      ".spectrum-color-contrast"
    );
    this.contrastLabel = this.element.querySelector(".contrast-ratio-label");
    [this.contrastValue, this.contrastValueMin, this.contrastValueMax] =
      this.element.querySelectorAll(".accessibility-contrast-value");

    // Create the learn more info button
    const learnMore = this.document.createElementNS(XHTML_NS, "button");
    learnMore.id = "learn-more-button";
    learnMore.className = "learn-more";
    learnMore.title = L10N.getStr("accessibility.learnMore");
    this.element
      .querySelector(".contrast-ratio-header-and-single-ratio")
      .appendChild(learnMore);

    if (rgb) {
      this.rgb = rgb;
      this.updateUI();
    }
  }

  set textProps(style) {
    this._textProps = style
      ? {
          fontSize: style["font-size"].value,
          fontWeight: style["font-weight"].value,
          opacity: style.opacity.value,
        }
      : null;
  }

  set rgb(color) {
    this.hsv = rgbToHsv(color[0], color[1], color[2], color[3]);
  }

  set backgroundColorData(colorData) {
    this._backgroundColorData = colorData;
  }

  get backgroundColorData() {
    return this._backgroundColorData;
  }

  get textProps() {
    return this._textProps;
  }

  get rgb() {
    const rgb = hsvToRgb(this.hsv[0], this.hsv[1], this.hsv[2], this.hsv[3]);
    return [
      Math.round(rgb[0]),
      Math.round(rgb[1]),
      Math.round(rgb[2]),
      Math.round(rgb[3] * 100) / 100,
    ];
  }

  /**
   * Map current rgb to the closest color available in the database by
   * calculating the delta-E between each available color and the current rgb
   *
   * @return {String}
   *         Color name or closest color name
   */
  get colorName() {
    const labColorEntries = Object.entries(labColors);

    const deltaEs = labColorEntries.map(color =>
      colorUtils.calculateDeltaE(color[1], colorUtils.rgbToLab(this.rgb))
    );

    // Get the color name for the one that has the lowest delta-E
    const minDeltaE = Math.min(...deltaEs);
    const colorName = labColorEntries[deltaEs.indexOf(minDeltaE)][0];
    return minDeltaE === 0
      ? colorName
      : L10N.getFormatStr("colorPickerTooltip.colorNameTitle", colorName);
  }

  get rgbNoSatVal() {
    const rgb = hsvToRgb(this.hsv[0], 1, 1);
    return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), rgb[3]];
  }

  get rgbCssString() {
    const rgb = this.rgb;
    return (
      "rgba(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ", " + rgb[3] + ")"
    );
  }

  show() {
    this.dragWidth = this.dragger.offsetWidth;
    this.dragHeight = this.dragger.offsetHeight;
    this.dragHelperHeight = this.dragHelper.offsetHeight;

    this.updateUI();
  }

  onElementClick(e) {
    e.stopPropagation();
  }

  onHueSliderMove() {
    this.hsv[0] = this.hueSlider.value / this.hueSlider.max;
    this.updateUI();
    this.onChange();
  }

  onDraggerMove(dragX, dragY) {
    this.hsv[1] = dragX / this.dragWidth;
    this.hsv[2] = (this.dragHeight - dragY) / this.dragHeight;
    this.updateUI();
    this.onChange();
  }

  onAlphaSliderMove() {
    this.hsv[3] = this.alphaSlider.value / this.alphaSlider.max;
    this.updateUI();
    this.onChange();
  }

  onChange() {
    this.emit("changed", this.rgb, this.rgbCssString);
  }

  /**
   * Creates and initializes a slider element, attaches it to its parent container
   * based on the slider type and returns it
   *
   * @param  {String} sliderType
   *         The type of the slider (i.e. alpha or hue)
   * @param  {Function} onSliderMove
   *         The function to tie the slider to on input
   * @return {DOMNode}
   *         Newly created slider
   */
  createSlider(sliderType, onSliderMove) {
    const container = this.element.querySelector(`.spectrum-${sliderType}`);

    const slider = this.document.createElementNS(XHTML_NS, "input");
    slider.className = `spectrum-${sliderType}-input`;
    slider.type = "range";
    slider.min = SLIDER[sliderType].MIN;
    slider.max = SLIDER[sliderType].MAX;
    slider.step = SLIDER[sliderType].STEP;
    slider.title = L10N.getStr(`colorPickerTooltip.${sliderType}SliderTitle`);
    slider.addEventListener("input", onSliderMove);

    container.appendChild(slider);
    return slider;
  }

  /**
   * Updates the contrast label with appropriate content (i.e. large text indicator
   * if the contrast is calculated for large text, or a base label otherwise)
   *
   * @param  {Boolean} isLargeText
   *         True if contrast is calculated for large text.
   */
  updateContrastLabel(isLargeText) {
    if (!isLargeText) {
      this.contrastLabel.textContent = L10N.getStr(
        "accessibility.contrast.ratio.label"
      );
      return;
    }

    // Clear previously appended children before appending any new children
    while (this.contrastLabel.firstChild) {
      this.contrastLabel.firstChild.remove();
    }

    const largeTextStr = L10N.getStr("accessibility.contrast.large.text");
    const contrastLabelStr = L10N.getFormatStr(
      "colorPickerTooltip.contrast.large.title",
      largeTextStr
    );

    // Build an array of children nodes for the contrast label element
    const contents = contrastLabelStr
      .split(new RegExp(largeTextStr), 2)
      .map(content => this.document.createTextNode(content));
    const largeTextIndicator = this.document.createElementNS(XHTML_NS, "span");
    largeTextIndicator.className = "accessibility-color-contrast-large-text";
    largeTextIndicator.textContent = largeTextStr;
    largeTextIndicator.title = L10N.getStr(
      "accessibility.contrast.large.title"
    );
    contents.splice(1, 0, largeTextIndicator);

    // Append children to contrast label
    for (const content of contents) {
      this.contrastLabel.appendChild(content);
    }
  }

  /**
   * Updates a contrast value element with the given score, value and swatches.
   *
   * @param  {DOMNode} el
   *         Contrast value element to update.
   * @param  {String} score
   *         Contrast ratio score.
   * @param  {Number} value
   *         Contrast ratio value.
   * @param  {Array} backgroundColor
   *         RGBA color array for the background color to show in the swatch.
   */
  updateContrastValueEl(el, score, value, backgroundColor) {
    el.classList.toggle(score, true);
    el.textContent = value.toFixed(2);
    el.title = L10N.getFormatStr(
      `accessibility.contrast.annotation.${score}`,
      L10N.getFormatStr(
        "colorPickerTooltip.contrastAgainstBgTitle",
        `rgba(${backgroundColor})`
      )
    );
    el.parentElement.style.setProperty(
      "--accessibility-contrast-color",
      this.rgbCssString
    );
    el.parentElement.style.setProperty(
      "--accessibility-contrast-bg",
      `rgba(${backgroundColor})`
    );
  }

  updateAlphaSlider() {
    // Set alpha slider background
    const rgb = this.rgb;

    const rgbNoAlpha = "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
    const rgbAlpha0 = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ", 0)";
    const alphaGradient =
      "linear-gradient(to right, " + rgbAlpha0 + ", " + rgbNoAlpha + ")";
    this.alphaSlider.style.background = alphaGradient;
  }

  updateColorPreview() {
    // Overlay the rgba color over a checkered image background.
    this.colorPreview.style.setProperty("--overlay-color", this.rgbCssString);

    // We should be able to distinguish the color preview on high luminance rgba values.
    // Give the color preview a light grey border if the luminance of the current rgba
    // tuple is great.
    const colorLuminance = colorUtils.calculateLuminance(this.rgb);
    this.colorPreview.classList.toggle("high-luminance", colorLuminance > 0.85);

    // Set title on color preview for better UX
    this.colorPreview.title = this.colorName;
  }

  updateDragger() {
    // Set dragger background color
    const flatColor =
      "rgb(" +
      this.rgbNoSatVal[0] +
      ", " +
      this.rgbNoSatVal[1] +
      ", " +
      this.rgbNoSatVal[2] +
      ")";
    this.dragger.style.backgroundColor = flatColor;

    // Set dragger aria attributes
    this.dragger.setAttribute("aria-valuetext", this.rgbCssString);
  }

  updateHueSlider() {
    // Set hue slider aria attributes
    this.hueSlider.setAttribute("aria-valuetext", this.rgbCssString);
  }

  updateHelperLocations() {
    const h = this.hsv[0];
    const s = this.hsv[1];
    const v = this.hsv[2];

    // Placing the color dragger
    let dragX = s * this.dragWidth;
    let dragY = this.dragHeight - v * this.dragHeight;
    const helperDim = this.dragHelperHeight / 2;

    dragX = Math.max(
      -helperDim,
      Math.min(this.dragWidth - helperDim, dragX - helperDim)
    );
    dragY = Math.max(
      -helperDim,
      Math.min(this.dragHeight - helperDim, dragY - helperDim)
    );

    this.dragHelper.style.top = dragY + "px";
    this.dragHelper.style.left = dragX + "px";

    // Placing the hue slider
    this.hueSlider.value = h * this.hueSlider.max;

    // Placing the alpha slider
    this.alphaSlider.value = this.hsv[3] * this.alphaSlider.max;
  }

  /* Calculates the contrast ratio for the currently selected
   * color against a single or range of background colors and displays contrast ratio section
   * components depending on the contrast ratio calculated.
   *
   * Contrast ratio components include:
   *    - contrastLargeTextIndicator: Hidden by default, shown when text has large font
   *                                  size if there is no error in calculation.
   *    - contrastValue(s):           Set to calculated value(s), score(s) and text color on
   *                                  background swatches. Set to error text
   *                                  if there is an error in calculation.
   */
  updateContrast() {
    // Remove additional classes on spectrum contrast, leaving behind only base classes
    this.spectrumContrast.classList.toggle("visible", false);
    this.spectrumContrast.classList.toggle("range", false);
    this.spectrumContrast.classList.toggle("error", false);
    // Assign only base class to all contrastValues, removing any score class
    this.contrastValue.className =
      this.contrastValueMin.className =
      this.contrastValueMax.className =
        "accessibility-contrast-value";

    if (!this.contrastEnabled) {
      return;
    }

    const isRange = this.backgroundColorData.min !== undefined;
    this.spectrumContrast.classList.toggle("visible", true);
    this.spectrumContrast.classList.toggle("range", isRange);

    const colorContrast = getContrastRatio(
      {
        ...this.textProps,
        color: this.rgbCssString,
      },
      this.backgroundColorData
    );

    const {
      value,
      min,
      max,
      score,
      scoreMin,
      scoreMax,
      backgroundColor,
      backgroundColorMin,
      backgroundColorMax,
      isLargeText,
      error,
    } = colorContrast;

    if (error) {
      this.updateContrastLabel(false);
      this.spectrumContrast.classList.toggle("error", true);

      // If current background color is a range, show the error text in the contrast range
      // span. Otherwise, show it in the single contrast span.
      const contrastValEl = isRange
        ? this.contrastValueMin
        : this.contrastValue;
      contrastValEl.textContent = L10N.getStr("accessibility.contrast.error");
      contrastValEl.title = L10N.getStr(
        "accessibility.contrast.annotation.transparent.error"
      );

      return;
    }

    this.updateContrastLabel(isLargeText);
    if (!isRange) {
      this.updateContrastValueEl(
        this.contrastValue,
        score,
        value,
        backgroundColor
      );

      return;
    }

    this.updateContrastValueEl(
      this.contrastValueMin,
      scoreMin,
      min,
      backgroundColorMin
    );
    this.updateContrastValueEl(
      this.contrastValueMax,
      scoreMax,
      max,
      backgroundColorMax
    );
  }

  updateUI() {
    this.updateHelperLocations();

    this.updateColorPreview();
    this.updateDragger();
    this.updateHueSlider();
    this.updateAlphaSlider();
    this.updateContrast();
  }

  destroy() {
    this.element.removeEventListener("click", this.onElementClick);
    this.hueSlider.removeEventListener("input", this.onHueSliderMove);
    this.alphaSlider.removeEventListener("input", this.onAlphaSliderMove);

    this.parentEl.removeChild(this.element);

    this.dragger = this.dragHelper = null;
    this.alphaSlider = null;
    this.hueSlider = null;
    this.colorPreview = null;
    this.element = null;
    this.parentEl = null;
    this.spectrumContrast = null;
    this.contrastValue = this.contrastValueMin = this.contrastValueMax = null;
    this.contrastLabel = null;
  }
}

function hsvToRgb(h, s, v, a) {
  let r, g, b;

  const i = Math.floor(h * 6);
  const f = h * 6 - i;
  const p = v * (1 - s);
  const q = v * (1 - f * s);
  const t = v * (1 - (1 - f) * s);

  switch (i % 6) {
    case 0:
      r = v;
      g = t;
      b = p;
      break;
    case 1:
      r = q;
      g = v;
      b = p;
      break;
    case 2:
      r = p;
      g = v;
      b = t;
      break;
    case 3:
      r = p;
      g = q;
      b = v;
      break;
    case 4:
      r = t;
      g = p;
      b = v;
      break;
    case 5:
      r = v;
      g = p;
      b = q;
      break;
  }

  return [r * 255, g * 255, b * 255, a];
}

function rgbToHsv(r, g, b, a) {
  r = r / 255;
  g = g / 255;
  b = b / 255;

  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);

  const v = max;
  const d = max - min;
  const s = max == 0 ? 0 : d / max;

  let h;
  if (max == min) {
    // achromatic
    h = 0;
  } else {
    switch (max) {
      case r:
        h = (g - b) / d + (g < b ? 6 : 0);
        break;
      case g:
        h = (b - r) / d + 2;
        break;
      case b:
        h = (r - g) / d + 4;
        break;
    }
    h /= 6;
  }
  return [h, s, v, a];
}

function draggable(element, dragHelper, onmove) {
  onmove = onmove || function () {};

  const doc = element.ownerDocument;
  let dragging = false;
  let offset = {};
  let maxHeight = 0;
  let maxWidth = 0;

  function setDraggerDimensionsAndOffset() {
    maxHeight = element.offsetHeight;
    maxWidth = element.offsetWidth;
    offset = element.getBoundingClientRect();
  }

  function prevent(e) {
    e.stopPropagation();
    e.preventDefault();
  }

  function move(e) {
    if (dragging) {
      if (e.buttons === 0) {
        // The button is no longer pressed but we did not get a mouseup event.
        stop();
        return;
      }
      const pageX = e.pageX;
      const pageY = e.pageY;

      const dragX = Math.max(0, Math.min(pageX - offset.left, maxWidth));
      const dragY = Math.max(0, Math.min(pageY - offset.top, maxHeight));

      onmove.apply(element, [dragX, dragY]);
    }
  }

  function start(e) {
    const rightClick = e.which === 3;

    if (!rightClick && !dragging) {
      dragging = true;
      setDraggerDimensionsAndOffset();

      move(e);

      doc.addEventListener("selectstart", prevent);
      doc.addEventListener("dragstart", prevent);
      doc.addEventListener("mousemove", move);
      doc.addEventListener("mouseup", stop);

      prevent(e);
    }
  }

  function stop() {
    if (dragging) {
      doc.removeEventListener("selectstart", prevent);
      doc.removeEventListener("dragstart", prevent);
      doc.removeEventListener("mousemove", move);
      doc.removeEventListener("mouseup", stop);
    }
    dragging = false;
  }

  function onKeydown(e) {
    const { key } = e;

    if (!ARROW_KEYS.includes(key)) {
      return;
    }

    setDraggerDimensionsAndOffset();
    const { offsetHeight, offsetTop, offsetLeft } = dragHelper;
    let dragX = offsetLeft + offsetHeight / 2;
    let dragY = offsetTop + offsetHeight / 2;

    if (key === ArrowLeft && dragX > 0) {
      dragX -= 1;
    } else if (key === ArrowRight && dragX < maxWidth) {
      dragX += 1;
    } else if (key === ArrowUp && dragY > 0) {
      dragY -= 1;
    } else if (key === ArrowDown && dragY < maxHeight) {
      dragY += 1;
    }

    onmove.apply(element, [dragX, dragY]);
  }

  element.addEventListener("mousedown", start);
  element.addEventListener("keydown", onKeydown);
}

/**
 * Calculates the contrast ratio for a DOM node's computed style against
 * a given background.
 *
 * @param  {Object} computedStyle
 *         The computed style for which we want to calculate the contrast ratio.
 * @param  {Object} backgroundColor
 *         Object with one or more of the following properties: value, min, max
 * @return {Object}
 *         An object that may contain one or more of the following fields: error,
 *         isLargeText, value, score for contrast.
 */
function getContrastRatio(computedStyle, backgroundColor) {
  const props = getTextProperties(computedStyle);

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

  return getContrastRatioAgainstBackground(backgroundColor, props);
}

module.exports = Spectrum;
