/**
 * Copyright (c) 2013 Lea Verou. All rights reserved.
 *
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the "Software"),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
 * DEALINGS IN THE SOFTWARE.
 */

// Based on www.cubic-bezier.com by Lea Verou
// See https://github.com/LeaVerou/cubic-bezier

"use strict";

const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const {
  PREDEFINED,
  PRESETS,
  DEFAULT_PRESET_CATEGORY,
} = require("resource://devtools/client/shared/widgets/CubicBezierPresets.js");
const {
  InspectorCSSParserWrapper,
} = require("resource://devtools/shared/css/lexer.js");
const XHTML_NS = "http://www.w3.org/1999/xhtml";

/**
 * CubicBezier data structure helper
 * Accepts an array of coordinates and exposes a few useful getters
 *
 * @param {Array} coordinates i.e. [.42, 0, .58, 1]
 */
function CubicBezier(coordinates) {
  if (!coordinates) {
    throw new Error("No offsets were defined");
  }

  this.coordinates = coordinates.map(n => +n);

  for (let i = 4; i--; ) {
    const xy = this.coordinates[i];
    if (isNaN(xy) || (!(i % 2) && (xy < 0 || xy > 1))) {
      throw new Error(`Wrong coordinate at ${i}(${xy})`);
    }
  }

  this.coordinates.toString = function () {
    return (
      this.map(n => {
        return (Math.round(n * 100) / 100 + "").replace(/^0\./, ".");
      }) + ""
    );
  };
}

exports.CubicBezier = CubicBezier;

CubicBezier.prototype = {
  get P1() {
    return this.coordinates.slice(0, 2);
  },

  get P2() {
    return this.coordinates.slice(2);
  },

  toString() {
    // Check first if current coords are one of css predefined functions
    const predefName = Object.keys(PREDEFINED).find(key =>
      coordsAreEqual(PREDEFINED[key], this.coordinates)
    );

    return predefName || "cubic-bezier(" + this.coordinates + ")";
  },
};

/**
 * Bezier curve canvas plotting class
 *
 * @param {DOMNode} canvas
 * @param {CubicBezier} bezier
 * @param {Array} padding Amount of horizontal,vertical padding around the graph
 */
function BezierCanvas(canvas, bezier, padding) {
  this.canvas = canvas;
  this.bezier = bezier;
  this.padding = getPadding(padding);

  // Convert to a cartesian coordinate system with axes from 0 to 1
  this.ctx = this.canvas.getContext("2d");
  const p = this.padding;

  this.ctx.scale(
    canvas.width * (1 - p[1] - p[3]),
    -canvas.height * (1 - p[0] - p[2])
  );
  this.ctx.translate(p[3] / (1 - p[1] - p[3]), -1 - p[0] / (1 - p[0] - p[2]));
}

exports.BezierCanvas = BezierCanvas;

BezierCanvas.prototype = {
  /**
   * Get P1 and P2 current top/left offsets so they can be positioned
   *
   * @return {Array} Returns an array of 2 {top:String,left:String} objects
   */
  get offsets() {
    const p = this.padding,
      w = this.canvas.width,
      h = this.canvas.height;

    return [
      {
        left:
          w * (this.bezier.coordinates[0] * (1 - p[3] - p[1]) - p[3]) + "px",
        top:
          h * (1 - this.bezier.coordinates[1] * (1 - p[0] - p[2]) - p[0]) +
          "px",
      },
      {
        left:
          w * (this.bezier.coordinates[2] * (1 - p[3] - p[1]) - p[3]) + "px",
        top:
          h * (1 - this.bezier.coordinates[3] * (1 - p[0] - p[2]) - p[0]) +
          "px",
      },
    ];
  },

  /**
   * Convert an element's left/top offsets into coordinates
   */
  offsetsToCoordinates(element) {
    const w = this.canvas.width,
      h = this.canvas.height;

    // Convert padding percentage to actual padding
    const p = this.padding.map((a, i) => a * (i % 2 ? w : h));

    return [
      (parseFloat(element.style.left) - p[3]) / (w + p[1] + p[3]),
      (h - parseFloat(element.style.top) - p[2]) / (h - p[0] - p[2]),
    ];
  },

  /**
   * Draw the cubic bezier curve for the current coordinates
   */
  plot(settings = {}) {
    const xy = this.bezier.coordinates;

    const win = this.canvas.ownerGlobal;
    const computedStyle = win.getComputedStyle(win.document.documentElement);

    const defaultSettings = {
      handleColor: computedStyle.getPropertyValue(
        "--timing-function-control-point-background"
      ),
      handleThickness: 0.008,
      diagonalThickness: 0.01,
      diagonalColor: computedStyle.getPropertyValue("--bezier-diagonal-color"),
      bezierColor: computedStyle.getPropertyValue(
        "--timing-function-line-color"
      ),
      bezierThickness: 0.015,
      drawHandles: true,
    };

    for (const setting in settings) {
      defaultSettings[setting] = settings[setting];
    }

    // Clear the canvas –making sure to clear the
    // whole area by resetting the transform first.
    this.ctx.save();
    this.ctx.setTransform(1, 0, 0, 1, 0, 0);
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    this.ctx.restore();

    if (defaultSettings.drawHandles) {
      // Draw control handles
      this.ctx.beginPath();
      this.ctx.lineWidth = defaultSettings.handleThickness;
      this.ctx.strokeStyle = defaultSettings.handleColor;

      this.ctx.moveTo(0, 0);
      this.ctx.lineTo(xy[0], xy[1]);
      this.ctx.moveTo(1, 1);
      this.ctx.lineTo(xy[2], xy[3]);

      this.ctx.stroke();
      this.ctx.closePath();

      // Draw diagonal between points
      this.ctx.beginPath();
      this.ctx.lineWidth = defaultSettings.diagonalThickness;
      this.ctx.strokeStyle = defaultSettings.diagonalColor;
      this.ctx.moveTo(0, 0);
      this.ctx.lineTo(1, 1);
      this.ctx.stroke();
      this.ctx.closePath();
    }

    // Draw bezier curve
    this.ctx.beginPath();
    this.ctx.lineWidth = defaultSettings.bezierThickness;
    this.ctx.strokeStyle = defaultSettings.bezierColor;
    this.ctx.moveTo(0, 0);
    this.ctx.bezierCurveTo(xy[0], xy[1], xy[2], xy[3], 1, 1);
    this.ctx.stroke();
    this.ctx.closePath();
  },
};

/**
 * Cubic-bezier widget. Uses the BezierCanvas class to draw the curve and
 * adds the control points and user interaction
 *
 * @param {DOMNode} parent The container where the graph should be created
 * @param {Array} coordinates Coordinates of the curve to be drawn
 *
 * Emits "updated" events whenever the curve is changed. Along with the event is
 * sent a CubicBezier object
 */
function CubicBezierWidget(
  parent,
  coordinates = PRESETS["ease-in"]["ease-in-sine"]
) {
  EventEmitter.decorate(this);

  this.parent = parent;
  const { curve, p1, p2 } = this._initMarkup();

  this.curveBoundingBox = curve.getBoundingClientRect();
  this.curve = curve;
  this.p1 = p1;
  this.p2 = p2;

  // Create and plot the bezier curve
  this.bezierCanvas = new BezierCanvas(
    this.curve,
    new CubicBezier(coordinates),
    [0.3, 0]
  );
  this.bezierCanvas.plot();

  // Place the control points
  const offsets = this.bezierCanvas.offsets;
  this.p1.style.left = offsets[0].left;
  this.p1.style.top = offsets[0].top;
  this.p2.style.left = offsets[1].left;
  this.p2.style.top = offsets[1].top;

  this._onPointMouseDown = this._onPointMouseDown.bind(this);
  this._onPointKeyDown = this._onPointKeyDown.bind(this);
  this._onCurveClick = this._onCurveClick.bind(this);
  this._onNewCoordinates = this._onNewCoordinates.bind(this);
  this.onPrefersReducedMotionChange =
    this.onPrefersReducedMotionChange.bind(this);

  // Add preset preview menu
  this.presets = new CubicBezierPresetWidget(parent);

  // Add the timing function previewer
  // if prefers-reduced-motion is not set
  this.reducedMotion = parent.ownerGlobal.matchMedia(
    "(prefers-reduced-motion)"
  );
  if (!this.reducedMotion.matches) {
    this.timingPreview = new TimingFunctionPreviewWidget(parent);
  }

  // add event listener to change prefers-reduced-motion
  // of the timing function preview during runtime
  this.reducedMotion.addEventListener(
    "change",
    this.onPrefersReducedMotionChange
  );

  this._initEvents();
}

exports.CubicBezierWidget = CubicBezierWidget;

CubicBezierWidget.prototype = {
  _initMarkup() {
    const doc = this.parent.ownerDocument;

    const wrap = doc.createElementNS(XHTML_NS, "div");
    wrap.className = "display-wrap";

    const plane = doc.createElementNS(XHTML_NS, "div");
    plane.className = "coordinate-plane";

    const p1 = doc.createElementNS(XHTML_NS, "button");
    p1.className = "control-point";
    plane.appendChild(p1);

    const p2 = doc.createElementNS(XHTML_NS, "button");
    p2.className = "control-point";
    plane.appendChild(p2);

    const curve = doc.createElementNS(XHTML_NS, "canvas");
    curve.className = "curve";
    const parentComputedStyle = this.parent.ownerGlobal.getComputedStyle(
      this.parent
    );
    // We need to set the canvas dimension to the actual rendered dimension
    // to avoid the canvas to scale. We can retrie the CSS variable values
    // and striping their unit.
    const dimensionRegex = /(?<size>\d+)px$/;
    curve.setAttribute(
      "width",
      dimensionRegex.exec(
        parentComputedStyle.getPropertyValue("--bezier-curve-width")
      ).groups.size
    );
    curve.setAttribute(
      "height",
      dimensionRegex.exec(
        parentComputedStyle.getPropertyValue("--bezier-curve-height")
      ).groups.size
    );

    plane.appendChild(curve);
    wrap.appendChild(plane);

    this.parent.appendChild(wrap);

    return {
      p1,
      p2,
      curve,
    };
  },

  onPrefersReducedMotionChange(event) {
    // if prefers-reduced-motion is enabled destroy timing function preview
    // else create it if it does not exist
    if (event.matches) {
      if (this.timingPreview) {
        this.timingPreview.destroy();
      }
      this.timingPreview = undefined;
    } else if (!this.timingPreview) {
      this.timingPreview = new TimingFunctionPreviewWidget(this.parent);
    }
  },

  _removeMarkup() {
    this.parent.querySelector(".display-wrap").remove();
  },

  _initEvents() {
    this.p1.addEventListener("mousedown", this._onPointMouseDown);
    this.p2.addEventListener("mousedown", this._onPointMouseDown);

    this.p1.addEventListener("keydown", this._onPointKeyDown);
    this.p2.addEventListener("keydown", this._onPointKeyDown);

    this.curve.addEventListener("click", this._onCurveClick);

    this.presets.on("new-coordinates", this._onNewCoordinates);
  },

  _removeEvents() {
    this.p1.removeEventListener("mousedown", this._onPointMouseDown);
    this.p2.removeEventListener("mousedown", this._onPointMouseDown);

    this.p1.removeEventListener("keydown", this._onPointKeyDown);
    this.p2.removeEventListener("keydown", this._onPointKeyDown);

    this.curve.removeEventListener("click", this._onCurveClick);

    this.presets.off("new-coordinates", this._onNewCoordinates);
  },

  _onPointMouseDown(event) {
    // Updating the boundingbox in case it has changed
    this.curveBoundingBox = this.curve.getBoundingClientRect();

    const point = event.target;
    const doc = point.ownerDocument;
    const self = this;

    doc.onmousemove = function drag(e) {
      let x = e.pageX;
      const y = e.pageY;
      const left = self.curveBoundingBox.left;
      const top = self.curveBoundingBox.top;

      if (x === 0 && y == 0) {
        return;
      }

      // Constrain x
      x = Math.min(Math.max(left, x), left + self.curveBoundingBox.width);

      point.style.left = x - left + "px";
      point.style.top = y - top + "px";

      self._updateFromPoints();
    };

    doc.onmouseup = function () {
      point.focus();
      doc.onmousemove = doc.onmouseup = null;
    };
  },

  _onPointKeyDown(event) {
    const point = event.target;
    const code = event.keyCode;

    if (code >= 37 && code <= 40) {
      event.preventDefault();

      // Arrow keys pressed
      const left = parseInt(point.style.left, 10);
      const top = parseInt(point.style.top, 10);
      const offset = 3 * (event.shiftKey ? 10 : 1);

      switch (code) {
        case 37:
          point.style.left = left - offset + "px";
          break;
        case 38:
          point.style.top = top - offset + "px";
          break;
        case 39:
          point.style.left = left + offset + "px";
          break;
        case 40:
          point.style.top = top + offset + "px";
          break;
      }

      this._updateFromPoints();
    }
  },

  _onCurveClick(event) {
    this.curveBoundingBox = this.curve.getBoundingClientRect();

    const left = this.curveBoundingBox.left;
    const top = this.curveBoundingBox.top;
    const x = event.pageX - left;
    const y = event.pageY - top;

    // Find which point is closer
    const distP1 = distance(
      x,
      y,
      parseInt(this.p1.style.left, 10),
      parseInt(this.p1.style.top, 10)
    );
    const distP2 = distance(
      x,
      y,
      parseInt(this.p2.style.left, 10),
      parseInt(this.p2.style.top, 10)
    );

    const point = distP1 < distP2 ? this.p1 : this.p2;
    point.style.left = x + "px";
    point.style.top = y + "px";

    this._updateFromPoints();
  },

  _onNewCoordinates(coordinates) {
    this.coordinates = coordinates;
  },

  /**
   * Get the current point coordinates and redraw the curve to match
   */
  _updateFromPoints() {
    // Get the new coordinates from the point's offsets
    let coordinates = this.bezierCanvas.offsetsToCoordinates(this.p1);
    coordinates = coordinates.concat(
      this.bezierCanvas.offsetsToCoordinates(this.p2)
    );

    this.presets.refreshMenu(coordinates);
    this._redraw(coordinates);
  },

  /**
   * Redraw the curve
   *
   * @param {Array} coordinates The array of control point coordinates
   */
  _redraw(coordinates) {
    // Provide a new CubicBezier to the canvas and plot the curve
    this.bezierCanvas.bezier = new CubicBezier(coordinates);
    this.bezierCanvas.plot();
    this.emit("updated", this.bezierCanvas.bezier);

    if (this.timingPreview) {
      this.timingPreview.preview(this.bezierCanvas.bezier.toString());
    }
  },

  /**
   * Set new coordinates for the control points and redraw the curve
   *
   * @param {Array} coordinates
   */
  set coordinates(coordinates) {
    this._redraw(coordinates);

    // Move the points
    const offsets = this.bezierCanvas.offsets;
    this.p1.style.left = offsets[0].left;
    this.p1.style.top = offsets[0].top;
    this.p2.style.left = offsets[1].left;
    this.p2.style.top = offsets[1].top;
  },

  /**
   * Set new coordinates for the control point and redraw the curve
   *
   * @param {string} value A string value. E.g. "linear",
   * "cubic-bezier(0,0,1,1)"
   */
  set cssCubicBezierValue(value) {
    if (!value) {
      return;
    }

    value = value.trim();

    // Try with one of the predefined values
    const coordinates = parseTimingFunction(value);

    this.presets.refreshMenu(coordinates);
    this.coordinates = coordinates;
  },

  destroy() {
    this._removeEvents();
    this._removeMarkup();

    // remove prefers-reduced-motion event listener
    this.reducedMotion.removeEventListener(
      "change",
      this.onPrefersReducedMotionChange
    );
    this.reducedMotion = null;

    if (this.timingPreview) {
      this.timingPreview.destroy();
      this.timingPreview = null;
    }
    this.presets.destroy();

    this.curve = this.p1 = this.p2 = null;
  },
};

/**
 * CubicBezierPreset widget.
 * Builds a menu of presets from CubicBezierPresets
 *
 * @param {DOMNode} parent The container where the preset panel should be
 * created
 *
 * Emits "new-coordinate" event along with the coordinates
 * whenever a preset is selected.
 */
function CubicBezierPresetWidget(parent) {
  this.parent = parent;

  const { presetPane, presets, categories } = this._initMarkup();
  this.presetPane = presetPane;
  this.presets = presets;
  this.categories = categories;

  this._activeCategory = null;
  this._activePresetList = null;
  this._activePreset = null;

  this._onCategoryClick = this._onCategoryClick.bind(this);
  this._onPresetClick = this._onPresetClick.bind(this);

  EventEmitter.decorate(this);
  this._initEvents();
}

exports.CubicBezierPresetWidget = CubicBezierPresetWidget;

CubicBezierPresetWidget.prototype = {
  /*
   * Constructs a list of all preset categories and a list
   * of presets for each category.
   *
   * High level markup:
   *  div .preset-pane
   *    div .preset-categories
   *      div .category
   *      div .category
   *      ...
   *    div .preset-container
   *      div .presetList
   *        div .preset
   *        ...
   *      div .presetList
   *        div .preset
   *        ...
   */
  _initMarkup() {
    const doc = this.parent.ownerDocument;

    const presetPane = doc.createElementNS(XHTML_NS, "div");
    presetPane.className = "preset-pane";

    const categoryList = doc.createElementNS(XHTML_NS, "div");
    categoryList.id = "preset-categories";

    const presetContainer = doc.createElementNS(XHTML_NS, "div");
    presetContainer.id = "preset-container";

    Object.keys(PRESETS).forEach(categoryLabel => {
      const category = this._createCategory(categoryLabel);
      categoryList.appendChild(category);

      const presetList = this._createPresetList(categoryLabel);
      presetContainer.appendChild(presetList);
    });

    presetPane.appendChild(categoryList);
    presetPane.appendChild(presetContainer);

    this.parent.appendChild(presetPane);

    const allCategories = presetPane.querySelectorAll(".category");
    const allPresets = presetPane.querySelectorAll(".preset");

    return {
      presetPane,
      presets: allPresets,
      categories: allCategories,
    };
  },

  _createCategory(categoryLabel) {
    const doc = this.parent.ownerDocument;

    const category = doc.createElementNS(XHTML_NS, "div");
    category.id = categoryLabel;
    category.classList.add("category");

    const categoryDisplayLabel = this._normalizeCategoryLabel(categoryLabel);
    category.textContent = categoryDisplayLabel;
    category.setAttribute("title", categoryDisplayLabel);

    return category;
  },

  _normalizeCategoryLabel(categoryLabel) {
    return categoryLabel.replace("/-/g", " ");
  },

  _createPresetList(categoryLabel) {
    const doc = this.parent.ownerDocument;

    const presetList = doc.createElementNS(XHTML_NS, "div");
    presetList.id = "preset-category-" + categoryLabel;
    presetList.classList.add("preset-list");

    Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
      const preset = this._createPreset(categoryLabel, presetLabel);
      presetList.appendChild(preset);
    });

    return presetList;
  },

  _createPreset(categoryLabel, presetLabel) {
    const doc = this.parent.ownerDocument;

    const preset = doc.createElementNS(XHTML_NS, "div");
    preset.classList.add("preset");
    preset.id = presetLabel;
    preset.coordinates = PRESETS[categoryLabel][presetLabel];
    // Create preset preview
    const curve = doc.createElementNS(XHTML_NS, "canvas");
    const bezier = new CubicBezier(preset.coordinates);
    curve.setAttribute("height", 50);
    curve.setAttribute("width", 50);
    preset.bezierCanvas = new BezierCanvas(curve, bezier, [0.15, 0]);
    preset.bezierCanvas.plot({
      drawHandles: false,
      bezierThickness: 0.025,
    });
    preset.appendChild(curve);

    // Create preset label
    const presetLabelElem = doc.createElementNS(XHTML_NS, "p");
    const presetDisplayLabel = this._normalizePresetLabel(
      categoryLabel,
      presetLabel
    );
    presetLabelElem.textContent = presetDisplayLabel;
    preset.appendChild(presetLabelElem);
    preset.setAttribute("title", presetDisplayLabel);

    return preset;
  },

  _normalizePresetLabel(categoryLabel, presetLabel) {
    return presetLabel.replace(categoryLabel + "-", "").replace("/-/g", " ");
  },

  _initEvents() {
    for (const category of this.categories) {
      category.addEventListener("click", this._onCategoryClick);
    }

    for (const preset of this.presets) {
      preset.addEventListener("click", this._onPresetClick);
    }
  },

  _removeEvents() {
    for (const category of this.categories) {
      category.removeEventListener("click", this._onCategoryClick);
    }

    for (const preset of this.presets) {
      preset.removeEventListener("click", this._onPresetClick);
    }
  },

  _onPresetClick(event) {
    this.emit("new-coordinates", event.currentTarget.coordinates);
    this.activePreset = event.currentTarget;
  },

  _onCategoryClick(event) {
    this.activeCategory = event.target;
  },

  _setActivePresetList(presetListId) {
    const presetList = this.presetPane.querySelector("#" + presetListId);
    swapClassName("active-preset-list", this._activePresetList, presetList);
    this._activePresetList = presetList;
  },

  set activeCategory(category) {
    swapClassName("active-category", this._activeCategory, category);
    this._activeCategory = category;
    this._setActivePresetList("preset-category-" + category.id);
  },

  get activeCategory() {
    return this._activeCategory;
  },

  set activePreset(preset) {
    swapClassName("active-preset", this._activePreset, preset);
    this._activePreset = preset;
  },

  get activePreset() {
    return this._activePreset;
  },

  /**
   * Called by CubicBezierWidget onload and when
   * the curve is modified via the canvas.
   * Attempts to match the new user setting with an
   * existing preset.
   *
   * @param {Array} coordinates new coords [i, j, k, l]
   */
  refreshMenu(coordinates) {
    // If we cannot find a matching preset, keep
    // menu on last known preset category.
    let category = this._activeCategory;

    // If we cannot find a matching preset
    // deselect any selected preset.
    let preset = null;

    // If a category has never been viewed before
    // show the default category.
    if (!category) {
      category = this.parent.querySelector("#" + DEFAULT_PRESET_CATEGORY);
    }

    // If the new coordinates do match a preset,
    // set its category and preset button as active.
    Object.keys(PRESETS).forEach(categoryLabel => {
      Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => {
        if (coordsAreEqual(PRESETS[categoryLabel][presetLabel], coordinates)) {
          category = this.parent.querySelector("#" + categoryLabel);
          preset = this.parent.querySelector("#" + presetLabel);
        }
      });
    });

    this.activeCategory = category;
    this.activePreset = preset;
  },

  destroy() {
    this._removeEvents();
    this.parent.querySelector(".preset-pane").remove();
  },
};

/**
 * The TimingFunctionPreviewWidget animates a dot on a scale with a given
 * timing-function
 *
 * @param {DOMNode} parent The container where this widget should go
 */
function TimingFunctionPreviewWidget(parent) {
  this.previousValue = null;

  this.parent = parent;
  this._initMarkup();
}

TimingFunctionPreviewWidget.prototype = {
  PREVIEW_DURATION: 1000,

  _initMarkup() {
    const doc = this.parent.ownerDocument;

    const container = doc.createElementNS(XHTML_NS, "div");
    container.className = "timing-function-preview";

    this.dot = doc.createElementNS(XHTML_NS, "div");
    this.dot.className = "dot";
    container.appendChild(this.dot);

    const scale = doc.createElementNS(XHTML_NS, "div");
    scale.className = "scale";
    container.appendChild(scale);

    this.parent.appendChild(container);
  },

  destroy() {
    this.dot.getAnimations().forEach(anim => anim.cancel());
    this.parent.querySelector(".timing-function-preview").remove();
    this.parent = this.dot = null;
  },

  /**
   * Preview a new timing function. The current preview will only be stopped if
   * the supplied function value is different from the previous one. If the
   * supplied function is invalid, the preview will stop.
   *
   * @param {string} value
   */
  preview(value) {
    // Don't restart the preview animation if the value is the same
    if (value === this.previousValue) {
      return;
    }

    if (parseTimingFunction(value)) {
      this.restartAnimation(value);
    }

    this.previousValue = value;
  },

  /**
   * Re-start the preview animation from the beginning.
   *
   * @param {string} timingFunction The value for the timing-function.
   */
  restartAnimation(timingFunction) {
    // Cancel the previous animation if there was any.
    this.dot.getAnimations().forEach(anim => anim.cancel());

    // And start the new one.
    // The animation consists of a few keyframes that move the dot to the right of the
    // container, and then move it back to the left.
    // It also contains some pause where the dot is greyed-out, before it moves to
    // the right, and once again, before it comes back to the left.
    // The timing function passed to this function is applied to the keyframes that
    // actually move the dot. This way it can be previewed in both direction, instead of
    // being spread over the whole animation.
    const translateStart = "calc(var(--bezier-curve-width) / -2)";
    const translateEnd = "calc(var(--bezier-curve-width) / 2)";
    const grayscaleFilter = "grayscale(100%)";

    this.dot.animate(
      [
        { translate: translateStart, filter: grayscaleFilter, offset: 0 },
        {
          translate: translateStart,
          filter: grayscaleFilter,
          offset: 0.19,
        },
        {
          translate: translateStart,
          filter: "none",
          offset: 0.2,
          easing: timingFunction,
        },
        { translate: translateEnd, filter: "none", offset: 0.5 },
        { translate: translateEnd, filter: grayscaleFilter, offset: 0.51 },
        { translate: translateEnd, filter: grayscaleFilter, offset: 0.7 },
        {
          translate: translateEnd,
          filter: "none",
          offset: 0.71,
          easing: timingFunction,
        },
        { translate: translateStart, filter: "none", offset: 1 },
      ],
      {
        duration: this.PREVIEW_DURATION * 2,
        iterations: Infinity,
      }
    );
  },
};

// Helpers

function getPadding(padding) {
  const p = typeof padding === "number" ? [padding] : padding;

  if (p.length === 1) {
    p[1] = p[0];
  }

  if (p.length === 2) {
    p[2] = p[0];
  }

  if (p.length === 3) {
    p[3] = p[1];
  }

  return p;
}

function distance(x1, y1, x2, y2) {
  return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
}

/**
 * Parse a string to see whether it is a valid timing function.
 * If it is, return the coordinates as an array.
 * Otherwise, return undefined.
 *
 * @param {string} value
 * @return {Array} of coordinates, or undefined
 */
function parseTimingFunction(value) {
  if (value in PREDEFINED) {
    return PREDEFINED[value];
  }

  const tokenStream = new InspectorCSSParserWrapper(value);
  const getNextToken = () => {
    while (true) {
      const token = tokenStream.nextToken();
      if (
        !token ||
        (token.tokenType !== "WhiteSpace" && token.tokenType !== "Comment")
      ) {
        return token;
      }
    }
  };

  let token = getNextToken();
  if (token.tokenType !== "Function" || token.value !== "cubic-bezier") {
    return undefined;
  }

  const result = [];
  for (let i = 0; i < 4; ++i) {
    token = getNextToken();
    if (!token || token.tokenType !== "Number") {
      return undefined;
    }
    result.push(token.number);

    token = getNextToken();
    if (!token || token.tokenType !== (i == 3 ? "CloseParenthesis" : "Comma")) {
      return undefined;
    }
  }

  return result;
}

exports.parseTimingFunction = parseTimingFunction;

/**
 * Removes a class from a node and adds it to another.
 *
 * @param {string} className the class to swap
 * @param {DOMNode} from the node to remove the class from
 * @param {DOMNode} to the node to add the class to
 */
function swapClassName(className, from, to) {
  if (from !== null) {
    from.classList.remove(className);
  }

  if (to !== null) {
    to.classList.add(className);
  }
}

/**
 * Compares two arrays of coordinates [i, j, k, l]
 *
 * @param {Array} c1 first coordinate array to compare
 * @param {Array} c2 second coordinate array to compare
 * @return {boolean}
 */
function coordsAreEqual(c1, c2) {
  return c1.reduce((prev, curr, index) => prev && curr === c2[index], true);
}
