/* 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 Services = require("Services");
const {angleUtils} = require("devtools/client/shared/css-angle");
const {colorUtils} = require("devtools/shared/css/color");
const {getCSSLexer} = require("devtools/shared/css/lexer");
const EventEmitter = require("devtools/shared/event-emitter");
const {appendText} = require("devtools/client/inspector/shared/utils");

loader.lazyRequireGetter(this, "ANGLE_TAKING_FUNCTIONS",
  "devtools/shared/css/properties-db", true);
loader.lazyRequireGetter(this, "BASIC_SHAPE_FUNCTIONS",
  "devtools/shared/css/properties-db", true);
loader.lazyRequireGetter(this, "BEZIER_KEYWORDS",
  "devtools/shared/css/properties-db", true);
loader.lazyRequireGetter(this, "COLOR_TAKING_FUNCTIONS",
  "devtools/shared/css/properties-db", true);
loader.lazyRequireGetter(this, "CSS_TYPES",
  "devtools/shared/css/properties-db", true);

const STYLE_INSPECTOR_PROPERTIES = "devtools/shared/locales/styleinspector.properties";
const {LocalizationHelper} = require("devtools/shared/l10n");
const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);

const HTML_NS = "http://www.w3.org/1999/xhtml";

const FLEXBOX_HIGHLIGHTER_ENABLED_PREF = "devtools.inspector.flexboxHighlighter.enabled";
const CSS_SHAPES_ENABLED_PREF = "devtools.inspector.shapesHighlighter.enabled";
const CSS_SHAPE_OUTSIDE_ENABLED_PREF = "layout.css.shape-outside.enabled";

/**
 * This module is used to process text for output by developer tools. This means
 * linking JS files with the debugger, CSS files with the style editor, JS
 * functions with the debugger, placing color swatches next to colors and
 * adding doorhanger previews where possible (images, angles, lengths,
 * border radius, cubic-bezier etc.).
 *
 * Usage:
 *   const OutputParser = require("devtools/client/shared/output-parser");
 *
 *   let parser = new OutputParser(document, supportsType);
 *
 *   parser.parseCssProperty("color", "red"); // Returns document fragment.
 *
 * @param {Document} document Used to create DOM nodes.
 * @param {Function} supportsTypes - A function that returns a boolean when asked if a css
 *                   property name supports a given css type.
 *                   The function is executed like supportsType("color", CSS_TYPES.COLOR)
 *                   where CSS_TYPES is defined in devtools/shared/css/properties-db.js
 * @param {Function} isValidOnClient - A function that checks if a css property
 *                   name/value combo is valid.
 * @param {Function} supportsCssColor4ColorFunction - A function for checking
 *                   the supporting of css-color-4 color function.
 */
function OutputParser(document,
                      {supportsType, isValidOnClient, supportsCssColor4ColorFunction}) {
  this.parsed = [];
  this.doc = document;
  this.supportsType = supportsType;
  this.isValidOnClient = isValidOnClient;
  this.colorSwatches = new WeakMap();
  this.angleSwatches = new WeakMap();
  this._onColorSwatchMouseDown = this._onColorSwatchMouseDown.bind(this);
  this._onAngleSwatchMouseDown = this._onAngleSwatchMouseDown.bind(this);

  this.cssColor4 = supportsCssColor4ColorFunction();
}

OutputParser.prototype = {
  /**
   * Parse a CSS property value given a property name.
   *
   * @param  {String} name
   *         CSS Property Name
   * @param  {String} value
   *         CSS Property value
   * @param  {Object} [options]
   *         Options object. For valid options and default values see
   *         _mergeOptions().
   * @return {DocumentFragment}
   *         A document fragment containing color swatches etc.
   */
  parseCssProperty: function(name, value, options = {}) {
    options = this._mergeOptions(options);

    options.expectCubicBezier = this.supportsType(name, CSS_TYPES.TIMING_FUNCTION);
    options.expectDisplay = name === "display";
    options.expectFilter = name === "filter";
    options.expectShape = name === "clip-path" ||
                          (name === "shape-outside"
                           && Services.prefs.getBoolPref(CSS_SHAPE_OUTSIDE_ENABLED_PREF));
    options.expectFont = name === "font-family";
    options.supportsColor = this.supportsType(name, CSS_TYPES.COLOR) ||
                            this.supportsType(name, CSS_TYPES.GRADIENT);

    // The filter property is special in that we want to show the
    // swatch even if the value is invalid, because this way the user
    // can easily use the editor to fix it.
    if (options.expectFilter || this._cssPropertySupportsValue(name, value)) {
      return this._parse(value, options);
    }
    this._appendTextNode(value);

    return this._toDOM();
  },

  /**
   * Read tokens from |tokenStream| and collect all the (non-comment)
   * text. Return the collected texts and variable data (if any).
   * Stop when an unmatched closing paren is seen.
   * If |stopAtComma| is true, then also stop when a top-level
   * (unparenthesized) comma is seen.
   *
   * @param  {String} text
   *         The original source text.
   * @param  {CSSLexer} tokenStream
   *         The token stream from which to read.
   * @param  {Object} options
   *         The options object in use; @see _mergeOptions.
   * @param  {Boolean} stopAtComma
   *         If true, stop at a comma.
   * @return {Object}
   *         An object of the form {tokens, functionData, sawComma, sawVariable}.
   *         |tokens| is a list of the non-comment, non-whitespace tokens
   *         that were seen. The stopping token (paren or comma) will not
   *         be included.
   *         |functionData| is a list of parsed strings and nodes that contain the
   *         data between the matching parenthesis. The stopping token's text will
   *         not be included.
   *         |sawComma| is true if the stop was due to a comma, or false otherwise.
   *         |sawVariable| is true if a variable was seen while parsing the text.
   */
  _parseMatchingParens: function(text, tokenStream, options, stopAtComma) {
    let depth = 1;
    const functionData = [];
    const tokens = [];
    let sawVariable = false;

    while (depth > 0) {
      const token = tokenStream.nextToken();
      if (!token) {
        break;
      }
      if (token.tokenType === "comment") {
        continue;
      }

      if (token.tokenType === "symbol") {
        if (stopAtComma && depth === 1 && token.text === ",") {
          return { tokens, functionData, sawComma: true, sawVariable };
        } else if (token.text === "(") {
          ++depth;
        } else if (token.text === ")") {
          --depth;
          if (depth === 0) {
            break;
          }
        }
      } else if (token.tokenType === "function" && token.text === "var" &&
                 options.isVariableInUse) {
        sawVariable = true;
        const variableNode = this._parseVariable(token, text, tokenStream, options);
        functionData.push(variableNode);
      } else if (token.tokenType === "function") {
        ++depth;
      }

      if (token.tokenType !== "function" || token.text !== "var" ||
          !options.isVariableInUse) {
        functionData.push(text.substring(token.startOffset, token.endOffset));
      }

      if (token.tokenType !== "whitespace") {
        tokens.push(token);
      }
    }

    return { tokens, functionData, sawComma: false, sawVariable };
  },

  /**
   * Parse var() use and return a variable node to be added to the output state.
   * This will read tokens up to and including the ")" that closes the "var("
   * invocation.
   *
   * @param  {CSSToken} initialToken
   *         The "var(" token that was already seen.
   * @param  {String} text
   *         The original input text.
   * @param  {CSSLexer} tokenStream
   *         The token stream from which to read.
   * @param  {Object} options
   *         The options object in use; @see _mergeOptions.
   * @return {Object}
   *         A node for the variable, with the appropriate text and
   *         title. Eg. a span with "var(--var1)" as the textContent
   *         and a title for --var1 like "--var1 = 10" or
   *         "--var1 is not set".
   */
  _parseVariable: function(initialToken, text, tokenStream, options) {
    // Handle the "var(".
    const varText = text.substring(initialToken.startOffset,
                                 initialToken.endOffset);
    const variableNode = this._createNode("span", {}, varText);

    // Parse the first variable name within the parens of var().
    const {tokens, functionData, sawComma, sawVariable} =
        this._parseMatchingParens(text, tokenStream, options, true);

    const result = sawVariable ? "" : functionData.join("");

    // Display options for the first and second argument in the var().
    const firstOpts = {};
    const secondOpts = {};

    let varValue;

    // Get the variable value if it is in use.
    if (tokens && tokens.length === 1) {
      varValue = options.isVariableInUse(tokens[0].text);
    }

    // Get the variable name.
    const varName = text.substring(tokens[0].startOffset, tokens[0].endOffset);

    if (typeof varValue === "string") {
      // The variable value is valid, set the variable name's title of the first argument
      // in var() to display the variable name and value.
      firstOpts["data-variable"] =
        STYLE_INSPECTOR_L10N.getFormatStr("rule.variableValue", varName, varValue);
      firstOpts.class = options.matchedVariableClass;
      secondOpts.class = options.unmatchedVariableClass;
    } else {
      // The variable name is not valid, mark it unmatched.
      firstOpts.class = options.unmatchedVariableClass;
      firstOpts["data-variable"] = STYLE_INSPECTOR_L10N.getFormatStr("rule.variableUnset",
                                                                      varName);
    }

    variableNode.appendChild(this._createNode("span", firstOpts, result));

    // If we saw a ",", then append it and show the remainder using
    // the correct highlighting.
    if (sawComma) {
      variableNode.appendChild(this.doc.createTextNode(","));

      // Parse the text up until the close paren, being sure to
      // disable the special case for filter.
      const subOptions = Object.assign({}, options);
      subOptions.expectFilter = false;
      const saveParsed = this.parsed;
      this.parsed = [];
      const rest = this._doParse(text, subOptions, tokenStream, true);
      this.parsed = saveParsed;

      const span = this._createNode("span", secondOpts);
      span.appendChild(rest);
      variableNode.appendChild(span);
    }
    variableNode.appendChild(this.doc.createTextNode(")"));

    return variableNode;
  },

  /* eslint-disable complexity */
  /**
   * The workhorse for @see _parse. This parses some CSS text,
   * stopping at EOF; or optionally when an umatched close paren is
   * seen.
   *
   * @param  {String} text
   *         The original input text.
   * @param  {Object} options
   *         The options object in use; @see _mergeOptions.
   * @param  {CSSLexer} tokenStream
   *         The token stream from which to read
   * @param  {Boolean} stopAtCloseParen
   *         If true, stop at an umatched close paren.
   * @return {DocumentFragment}
   *         A document fragment.
   */
  _doParse: function(text, options, tokenStream, stopAtCloseParen) {
    let parenDepth = stopAtCloseParen ? 1 : 0;
    let outerMostFunctionTakesColor = false;
    let fontFamilyNameParts = [];
    let previousWasBang = false;

    const colorOK = function() {
      return options.supportsColor ||
        (options.expectFilter && parenDepth === 1 &&
         outerMostFunctionTakesColor);
    };

    const angleOK = function(angle) {
      return (new angleUtils.CssAngle(angle)).valid;
    };

    let spaceNeeded = false;
    let done = false;

    while (!done) {
      const token = tokenStream.nextToken();
      if (!token) {
        if (options.expectFont && fontFamilyNameParts.length !== 0) {
          this._appendFontFamily(fontFamilyNameParts.join(""), options);
        }
        break;
      }

      if (token.tokenType === "comment") {
        // This doesn't change spaceNeeded, because we didn't emit
        // anything to the output.
        continue;
      }

      switch (token.tokenType) {
        case "function": {
          if (COLOR_TAKING_FUNCTIONS.includes(token.text) ||
              ANGLE_TAKING_FUNCTIONS.includes(token.text)) {
            // The function can accept a color or an angle argument, and we know
            // it isn't special in some other way. So, we let it
            // through to the ordinary parsing loop so that the value
            // can be handled in a single place.
            this._appendTextNode(text.substring(token.startOffset,
                                                token.endOffset));
            if (parenDepth === 0) {
              outerMostFunctionTakesColor = COLOR_TAKING_FUNCTIONS.includes(
                token.text);
            }
            ++parenDepth;
          } else if (token.text === "var" && options.isVariableInUse) {
            const variableNode = this._parseVariable(token, text, tokenStream, options);
            this.parsed.push(variableNode);
          } else {
            const {functionData, sawVariable} =
              this._parseMatchingParens(text, tokenStream, options);

            const functionName = text.substring(token.startOffset, token.endOffset);

            if (sawVariable) {
              // If function contains variable, we need to add both strings
              // and nodes.
              this._appendTextNode(functionName);
              for (const data of functionData) {
                if (typeof data === "string") {
                  this._appendTextNode(data);
                } else if (data) {
                  this.parsed.push(data);
                }
              }
              this._appendTextNode(")");
            } else {
              // If no variable in function, join the text together and add
              // to DOM accordingly.
              const functionText = functionName + functionData.join("") + ")";

              if (options.expectCubicBezier && token.text === "cubic-bezier") {
                this._appendCubicBezier(functionText, options);
              } else if (colorOK() &&
                         colorUtils.isValidCSSColor(functionText, this.cssColor4)) {
                this._appendColor(functionText, options);
              } else if (options.expectShape &&
                         Services.prefs.getBoolPref(CSS_SHAPES_ENABLED_PREF) &&
                         BASIC_SHAPE_FUNCTIONS.includes(token.text)) {
                this._appendShape(functionText, options);
              } else {
                this._appendTextNode(functionText);
              }
            }
          }
          break;
        }

        case "ident":
          if (options.expectCubicBezier &&
              BEZIER_KEYWORDS.includes(token.text)) {
            this._appendCubicBezier(token.text, options);
          } else if (this._isDisplayFlex(text, token, options) &&
                     Services.prefs.getBoolPref(FLEXBOX_HIGHLIGHTER_ENABLED_PREF)) {
            this._appendHighlighterToggle(token.text, options.flexClass);
          } else if (this._isDisplayGrid(text, token, options)) {
            this._appendHighlighterToggle(token.text, options.gridClass);
          } else if (colorOK() &&
                     colorUtils.isValidCSSColor(token.text, this.cssColor4)) {
            this._appendColor(token.text, options);
          } else if (angleOK(token.text)) {
            this._appendAngle(token.text, options);
          } else if (options.expectFont && !previousWasBang) {
            // We don't append the identifier if the previous token
            // was equal to '!', since in that case we expect the
            // identifier to be equal to 'important'.
            fontFamilyNameParts.push(token.text);
          } else {
            this._appendTextNode(text.substring(token.startOffset,
                                                token.endOffset));
          }
          break;

        case "id":
        case "hash": {
          const original = text.substring(token.startOffset, token.endOffset);
          if (colorOK() && colorUtils.isValidCSSColor(original, this.cssColor4)) {
            if (spaceNeeded) {
              // Insert a space to prevent token pasting when a #xxx
              // color is changed to something like rgb(...).
              this._appendTextNode(" ");
            }
            this._appendColor(original, options);
          } else {
            this._appendTextNode(original);
          }
          break;
        }
        case "dimension":
          const value = text.substring(token.startOffset, token.endOffset);
          if (angleOK(value)) {
            this._appendAngle(value, options);
          } else {
            this._appendTextNode(value);
          }
          break;
        case "url":
        case "bad_url":
          this._appendURL(text.substring(token.startOffset, token.endOffset),
                          token.text, options);
          break;

        case "string":
          if (options.expectFont) {
            fontFamilyNameParts.push(text.substring(token.startOffset, token.endOffset));
          } else {
            this._appendTextNode(
              text.substring(token.startOffset, token.endOffset));
          }
          break;

        case "whitespace":
          if (options.expectFont) {
            fontFamilyNameParts.push(" ");
          } else {
            this._appendTextNode(
              text.substring(token.startOffset, token.endOffset));
          }
          break;

        case "symbol":
          if (token.text === "(") {
            ++parenDepth;
          } else if (token.text === ")") {
            --parenDepth;

            if (stopAtCloseParen && parenDepth === 0) {
              done = true;
              break;
            }

            if (parenDepth === 0) {
              outerMostFunctionTakesColor = false;
            }
          } else if ((token.text === "," || token.text === "!") &&
                     options.expectFont && fontFamilyNameParts.length !== 0) {
            this._appendFontFamily(fontFamilyNameParts.join(""), options);
            fontFamilyNameParts = [];
          }
          // falls through
        default:
          this._appendTextNode(
            text.substring(token.startOffset, token.endOffset));
          break;
      }

      // If this token might possibly introduce token pasting when
      // color-cycling, require a space.
      spaceNeeded = (token.tokenType === "ident" || token.tokenType === "at" ||
                     token.tokenType === "id" || token.tokenType === "hash" ||
                     token.tokenType === "number" || token.tokenType === "dimension" ||
                     token.tokenType === "percentage" || token.tokenType === "dimension");
      previousWasBang = (token.tokenType === "symbol" && token.text === "!");
    }

    let result = this._toDOM();

    if (options.expectFilter && !options.filterSwatch) {
      result = this._wrapFilter(text, options, result);
    }

    return result;
  },
  /* eslint-enable complexity */

  /**
   * Parse a string.
   *
   * @param  {String} text
   *         Text to parse.
   * @param  {Object} [options]
   *         Options object. For valid options and default values see
   *         _mergeOptions().
   * @return {DocumentFragment}
   *         A document fragment.
   */
  _parse: function(text, options = {}) {
    text = text.trim();
    this.parsed.length = 0;

    const tokenStream = getCSSLexer(text);
    return this._doParse(text, options, tokenStream, false);
  },

  /**
   * Returns true if it's a "display: [inline-]flex" token.
   *
   * @param  {String} text
   *         The parsed text.
   * @param  {Object} token
   *         The parsed token.
   * @param  {Object} options
   *         The options given to _parse.
   */
  _isDisplayFlex: function(text, token, options) {
    return options.expectDisplay &&
      (token.text === "flex" || token.text === "inline-flex");
  },

  /**
   * Returns true if it's a "display: [inline-]grid" token.
   *
   * @param  {String} text
   *         The parsed text.
   * @param  {Object} token
   *         The parsed token.
   * @param  {Object} options
   *         The options given to _parse.
   */
  _isDisplayGrid: function(text, token, options) {
    return options.expectDisplay &&
      (token.text === "grid" || token.text === "inline-grid");
  },

  /**
   * Append a cubic-bezier timing function value to the output
   *
   * @param {String} bezier
   *        The cubic-bezier timing function
   * @param {Object} options
   *        Options object. For valid options and default values see
   *        _mergeOptions()
   */
  _appendCubicBezier: function(bezier, options) {
    const container = this._createNode("span", {
      "data-bezier": bezier
    });

    if (options.bezierSwatchClass) {
      const swatch = this._createNode("span", {
        class: options.bezierSwatchClass
      });
      container.appendChild(swatch);
    }

    const value = this._createNode("span", {
      class: options.bezierClass
    }, bezier);

    container.appendChild(value);
    this.parsed.push(container);
  },

  /**
   * Append a Flexbox|Grid highlighter toggle icon next to the value in a
   * "display: [inline-]flex" or "display: [inline-]grid" declaration.
   *
   * @param {String} text
   *        The text value to append
   * @param {String} className
   *        The class name for the toggle span
   */
  _appendHighlighterToggle: function(text, className) {
    const container = this._createNode("span", {});

    const toggle = this._createNode("span", {
      class: className
    });

    const value = this._createNode("span", {});
    value.textContent = text;

    container.appendChild(toggle);
    container.appendChild(value);
    this.parsed.push(container);
  },

  /**
   * Append a CSS shapes highlighter toggle next to the value, and parse the value
   * into spans, each containing a point that can be hovered over.
   *
   * @param {String} shape
   *        The shape text value to append
   * @param {Object} options
   *        Options object. For valid options and default values see
   *        _mergeOptions()
   */
  _appendShape: function(shape, options) {
    const shapeTypes = [{
      prefix: "polygon(",
      coordParser: this._addPolygonPointNodes.bind(this)
    }, {
      prefix: "circle(",
      coordParser: this._addCirclePointNodes.bind(this)
    }, {
      prefix: "ellipse(",
      coordParser: this._addEllipsePointNodes.bind(this)
    }, {
      prefix: "inset(",
      coordParser: this._addInsetPointNodes.bind(this)
    }];

    const container = this._createNode("span", {});

    const toggle = this._createNode("span", {
      class: options.shapeSwatchClass
    });

    for (const { prefix, coordParser } of shapeTypes) {
      if (shape.includes(prefix)) {
        const coordsBegin = prefix.length;
        const coordsEnd = shape.lastIndexOf(")");
        let valContainer = this._createNode("span", {
          class: options.shapeClass
        });

        container.appendChild(toggle);

        appendText(valContainer, shape.substring(0, coordsBegin));

        const coordsString = shape.substring(coordsBegin, coordsEnd);
        valContainer = coordParser(coordsString, valContainer);

        appendText(valContainer, shape.substring(coordsEnd));
        container.appendChild(valContainer);
      }
    }

    this.parsed.push(container);
  },

  /**
   * Parse the given polygon coordinates and create a span for each coordinate pair,
   * adding it to the given container node.
   *
   * @param {String} coords
   *        The string of coordinate pairs.
   * @param {Node} container
   *        The node to which spans containing points are added.
   * @returns {Node} The container to which spans have been added.
   */
  _addPolygonPointNodes: function(coords, container) {
    const tokenStream = getCSSLexer(coords);
    let token = tokenStream.nextToken();
    let coord = "";
    let i = 0;
    let depth = 0;
    let isXCoord = true;
    let fillRule = false;
    let coordNode = this._createNode("span", {
      class: "ruleview-shape-point",
      "data-point": `${i}`,
    });

    while (token) {
      if (token.tokenType === "symbol" && token.text === ",") {
        // Comma separating coordinate pairs; add coordNode to container and reset vars
        if (!isXCoord) {
          // Y coord not added to coordNode yet
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": `${i}`,
            "data-pair": (isXCoord) ? "x" : "y"
          }, coord);
          coordNode.appendChild(node);
          coord = "";
          isXCoord = !isXCoord;
        }

        if (fillRule) {
          // If the last text added was a fill-rule, do not increment i.
          fillRule = false;
        } else {
          container.appendChild(coordNode);
          i++;
        }
        appendText(container, coords.substring(token.startOffset, token.endOffset));
        coord = "";
        depth = 0;
        isXCoord = true;
        coordNode = this._createNode("span", {
          class: "ruleview-shape-point",
          "data-point": `${i}`,
        });
      } else if (token.tokenType === "symbol" && token.text === "(") {
        depth++;
        coord += coords.substring(token.startOffset, token.endOffset);
      } else if (token.tokenType === "symbol" && token.text === ")") {
        depth--;
        coord += coords.substring(token.startOffset, token.endOffset);
      } else if (token.tokenType === "whitespace" && coord === "") {
        // Whitespace at beginning of coord; add to container
        appendText(container, coords.substring(token.startOffset, token.endOffset));
      } else if (token.tokenType === "whitespace" && depth === 0) {
        // Whitespace signifying end of coord
        const node = this._createNode("span", {
          class: "ruleview-shape-point",
          "data-point": `${i}`,
          "data-pair": (isXCoord) ? "x" : "y"
        }, coord);
        coordNode.appendChild(node);
        appendText(coordNode, coords.substring(token.startOffset, token.endOffset));
        coord = "";
        isXCoord = !isXCoord;
      } else if ((token.tokenType === "number" || token.tokenType === "dimension" ||
                  token.tokenType === "percentage" || token.tokenType === "function")) {
        if (isXCoord && coord && depth === 0) {
          // Whitespace is not necessary between x/y coords.
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": `${i}`,
            "data-pair": "x"
          }, coord);
          coordNode.appendChild(node);
          isXCoord = false;
          coord = "";
        }

        coord += coords.substring(token.startOffset, token.endOffset);
        if (token.tokenType === "function") {
          depth++;
        }
      } else if (token.tokenType === "ident" &&
                 (token.text === "nonzero" || token.text === "evenodd")) {
        // A fill-rule (nonzero or evenodd).
        appendText(container, coords.substring(token.startOffset, token.endOffset));
        fillRule = true;
      } else {
        coord += coords.substring(token.startOffset, token.endOffset);
      }
      token = tokenStream.nextToken();
    }

    // Add coords if any are left over
    if (coord) {
      const node = this._createNode("span", {
        class: "ruleview-shape-point",
        "data-point": `${i}`,
        "data-pair": (isXCoord) ? "x" : "y"
      }, coord);
      coordNode.appendChild(node);
      container.appendChild(coordNode);
    }
    return container;
  },

  /**
   * Parse the given circle coordinates and populate the given container appropriately
   * with a separate span for the center point.
   *
   * @param {String} coords
   *        The circle definition.
   * @param {Node} container
   *        The node to which the definition is added.
   * @returns {Node} The container to which the definition has been added.
   */
  _addCirclePointNodes: function(coords, container) {
    const tokenStream = getCSSLexer(coords);
    let token = tokenStream.nextToken();
    let depth = 0;
    let coord = "";
    let point = "radius";
    const centerNode = this._createNode("span", {
      class: "ruleview-shape-point",
      "data-point": "center"
    });
    while (token) {
      if (token.tokenType === "symbol" && token.text === "(") {
        depth++;
        coord += coords.substring(token.startOffset, token.endOffset);
      } else if (token.tokenType === "symbol" && token.text === ")") {
        depth--;
        coord += coords.substring(token.startOffset, token.endOffset);
      } else if (token.tokenType === "whitespace" && coord === "") {
        // Whitespace at beginning of coord; add to container
        appendText(container, coords.substring(token.startOffset, token.endOffset));
      } else if (token.tokenType === "whitespace" && point === "radius" && depth === 0) {
        // Whitespace signifying end of radius
        const node = this._createNode("span", {
          class: "ruleview-shape-point",
          "data-point": "radius"
        }, coord);
        container.appendChild(node);
        appendText(container, coords.substring(token.startOffset, token.endOffset));
        point = "cx";
        coord = "";
        depth = 0;
      } else if (token.tokenType === "whitespace" && depth === 0) {
        // Whitespace signifying end of cx/cy
        const node = this._createNode("span", {
          class: "ruleview-shape-point",
          "data-point": "center",
          "data-pair": (point === "cx") ? "x" : "y"
        }, coord);
        centerNode.appendChild(node);
        appendText(centerNode, coords.substring(token.startOffset, token.endOffset));
        point = (point === "cx") ? "cy" : "cx";
        coord = "";
        depth = 0;
      } else if (token.tokenType === "ident" && token.text === "at") {
        // "at"; Add radius to container if not already done so
        if (point === "radius" && coord) {
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": "radius"
          }, coord);
          container.appendChild(node);
        }
        appendText(container, coords.substring(token.startOffset, token.endOffset));
        point = "cx";
        coord = "";
        depth = 0;
      } else if ((token.tokenType === "number" || token.tokenType === "dimension" ||
                  token.tokenType === "percentage" || token.tokenType === "function")) {
        if (point === "cx" && coord && depth === 0) {
          // Center coords don't require whitespace between x/y. So if current point is
          // cx, we have the cx coord, and depth is 0, then this token is actually cy.
          // Add cx to centerNode and set point to cy.
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": "center",
            "data-pair": "x"
          }, coord);
          centerNode.appendChild(node);
          point = "cy";
          coord = "";
        }

        coord += coords.substring(token.startOffset, token.endOffset);
        if (token.tokenType === "function") {
          depth++;
        }
      } else {
        coord += coords.substring(token.startOffset, token.endOffset);
      }
      token = tokenStream.nextToken();
    }

    // Add coords if any are left over.
    if (coord) {
      if (point === "radius") {
        const node = this._createNode("span", {
          class: "ruleview-shape-point",
          "data-point": "radius"
        }, coord);
        container.appendChild(node);
      } else {
        const node = this._createNode("span", {
          class: "ruleview-shape-point",
          "data-point": "center",
          "data-pair": (point === "cx") ? "x" : "y"
        }, coord);
        centerNode.appendChild(node);
      }
    }

    if (centerNode.textContent) {
      container.appendChild(centerNode);
    }
    return container;
  },

  /**
   * Parse the given ellipse coordinates and populate the given container appropriately
   * with a separate span for each point
   *
   * @param {String} coords
   *        The ellipse definition.
   * @param {Node} container
   *        The node to which the definition is added.
   * @returns {Node} The container to which the definition has been added.
   */
  _addEllipsePointNodes: function(coords, container) {
    const tokenStream = getCSSLexer(coords);
    let token = tokenStream.nextToken();
    let depth = 0;
    let coord = "";
    let point = "rx";
    const centerNode = this._createNode("span", {
      class: "ruleview-shape-point",
      "data-point": "center"
    });
    while (token) {
      if (token.tokenType === "symbol" && token.text === "(") {
        depth++;
        coord += coords.substring(token.startOffset, token.endOffset);
      } else if (token.tokenType === "symbol" && token.text === ")") {
        depth--;
        coord += coords.substring(token.startOffset, token.endOffset);
      } else if (token.tokenType === "whitespace" && coord === "") {
        // Whitespace at beginning of coord; add to container
        appendText(container, coords.substring(token.startOffset, token.endOffset));
      } else if (token.tokenType === "whitespace" && depth === 0) {
        if (point === "rx" || point === "ry") {
          // Whitespace signifying end of rx/ry
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": point,
          }, coord);
          container.appendChild(node);
          appendText(container, coords.substring(token.startOffset, token.endOffset));
          point = (point === "rx") ? "ry" : "cx";
          coord = "";
          depth = 0;
        } else {
          // Whitespace signifying end of cx/cy
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": "center",
            "data-pair": (point === "cx") ? "x" : "y"
          }, coord);
          centerNode.appendChild(node);
          appendText(centerNode, coords.substring(token.startOffset, token.endOffset));
          point = (point === "cx") ? "cy" : "cx";
          coord = "";
          depth = 0;
        }
      } else if (token.tokenType === "ident" && token.text === "at") {
        // "at"; Add radius to container if not already done so
        if (point === "ry" && coord) {
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": "ry"
          }, coord);
          container.appendChild(node);
        }
        appendText(container, coords.substring(token.startOffset, token.endOffset));
        point = "cx";
        coord = "";
        depth = 0;
      } else if ((token.tokenType === "number" || token.tokenType === "dimension" ||
                  token.tokenType === "percentage" || token.tokenType === "function")) {
        if (point === "rx" && coord && depth === 0) {
          // Radius coords don't require whitespace between x/y.
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": "rx",
          }, coord);
          container.appendChild(node);
          point = "ry";
          coord = "";
        }
        if (point === "cx" && coord && depth === 0) {
          // Center coords don't require whitespace between x/y.
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
            "data-point": "center",
            "data-pair": "x"
          }, coord);
          centerNode.appendChild(node);
          point = "cy";
          coord = "";
        }

        coord += coords.substring(token.startOffset, token.endOffset);
        if (token.tokenType === "function") {
          depth++;
        }
      } else {
        coord += coords.substring(token.startOffset, token.endOffset);
      }
      token = tokenStream.nextToken();
    }

    // Add coords if any are left over.
    if (coord) {
      if (point === "rx" || point === "ry") {
        const node = this._createNode("span", {
          class: "ruleview-shape-point",
          "data-point": point
        }, coord);
        container.appendChild(node);
      } else {
        const node = this._createNode("span", {
          class: "ruleview-shape-point",
          "data-point": "center",
          "data-pair": (point === "cx") ? "x" : "y"
        }, coord);
        centerNode.appendChild(node);
      }
    }

    if (centerNode.textContent) {
      container.appendChild(centerNode);
    }
    return container;
  },

  /**
   * Parse the given inset coordinates and populate the given container appropriately.
   *
   * @param {String} coords
   *        The inset definition.
   * @param {Node} container
   *        The node to which the definition is added.
   * @returns {Node} The container to which the definition has been added.
   */
  _addInsetPointNodes: function(coords, container) {
    const insetPoints = ["top", "right", "bottom", "left"];
    const tokenStream = getCSSLexer(coords);
    let token = tokenStream.nextToken();
    let depth = 0;
    let coord = "";
    let i = 0;
    let round = false;
    // nodes is an array containing all the coordinate spans. otherText is an array of
    // arrays, each containing the text that should be inserted into container before
    // the node with the same index. i.e. all elements of otherText[i] is inserted
    // into container before nodes[i].
    const nodes = [];
    const otherText = [[]];

    while (token) {
      if (round) {
        // Everything that comes after "round" should just be plain text
        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
      } else if (token.tokenType === "symbol" && token.text === "(") {
        depth++;
        coord += coords.substring(token.startOffset, token.endOffset);
      } else if (token.tokenType === "symbol" && token.text === ")") {
        depth--;
        coord += coords.substring(token.startOffset, token.endOffset);
      } else if (token.tokenType === "whitespace" && coord === "") {
        // Whitespace at beginning of coord; add to container
        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
      } else if (token.tokenType === "whitespace" && depth === 0) {
        // Whitespace signifying end of coord; create node and push to nodes
        const node = this._createNode("span", {
          class: "ruleview-shape-point"
        }, coord);
        nodes.push(node);
        i++;
        coord = "";
        otherText[i] = [coords.substring(token.startOffset, token.endOffset)];
        depth = 0;
      } else if ((token.tokenType === "number" || token.tokenType === "dimension" ||
                  token.tokenType === "percentage" || token.tokenType === "function")) {
        if (coord && depth === 0) {
          // Inset coords don't require whitespace between each coord.
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
          }, coord);
          nodes.push(node);
          i++;
          coord = "";
          otherText[i] = [];
        }

        coord += coords.substring(token.startOffset, token.endOffset);
        if (token.tokenType === "function") {
          depth++;
        }
      } else if (token.tokenType === "ident" && token.text === "round") {
        if (coord && depth === 0) {
          // Whitespace is not necessary before "round"; create a new node for the coord
          const node = this._createNode("span", {
            class: "ruleview-shape-point",
          }, coord);
          nodes.push(node);
          i++;
          coord = "";
          otherText[i] = [];
        }
        round = true;
        otherText[i].push(coords.substring(token.startOffset, token.endOffset));
      } else {
        coord += coords.substring(token.startOffset, token.endOffset);
      }
      token = tokenStream.nextToken();
    }

    // Take care of any leftover text
    if (coord) {
      if (round) {
        otherText[i].push(coord);
      } else {
        const node = this._createNode("span", {
          class: "ruleview-shape-point",
        }, coord);
        nodes.push(node);
      }
    }

    // insetPoints contains the 4 different possible inset points in the order they are
    // defined. By taking the modulo of the index in insetPoints with the number of nodes,
    // we can get which node represents each point (e.g. if there is only 1 node, it
    // represents all 4 points). The exception is "left" when there are 3 nodes. In that
    // case, it is nodes[1] that represents the left point rather than nodes[0].
    for (let j = 0; j < 4; j++) {
      const point = insetPoints[j];
      const nodeIndex = (point === "left" && nodes.length === 3) ? 1 : j % nodes.length;
      nodes[nodeIndex].classList.add(point);
    }

    nodes.forEach((node, j, array) => {
      for (const text of otherText[j]) {
        appendText(container, text);
      }
      container.appendChild(node);
    });

    // Add text that comes after the last node, if any exists
    if (otherText[nodes.length]) {
      for (const text of otherText[nodes.length]) {
        appendText(container, text);
      }
    }

    return container;
  },

  /**
   * Append a angle value to the output
   *
   * @param {String} angle
   *        angle to append
   * @param {Object} options
   *        Options object. For valid options and default values see
   *        _mergeOptions()
   */
  _appendAngle: function(angle, options) {
    const angleObj = new angleUtils.CssAngle(angle);
    const container = this._createNode("span", {
      "data-angle": angle
    });

    if (options.angleSwatchClass) {
      const swatch = this._createNode("span", {
        class: options.angleSwatchClass
      });
      this.angleSwatches.set(swatch, angleObj);
      swatch.addEventListener("mousedown", this._onAngleSwatchMouseDown);

      // Add click listener to stop event propagation when shift key is pressed
      // in order to prevent the value input to be focused.
      // Bug 711942 will add a tooltip to edit angle values and we should
      // be able to move this listener to Tooltip.js when it'll be implemented.
      swatch.addEventListener("click", function(event) {
        if (event.shiftKey) {
          event.stopPropagation();
        }
      });
      EventEmitter.decorate(swatch);
      container.appendChild(swatch);
    }

    const value = this._createNode("span", {
      class: options.angleClass
    }, angle);

    container.appendChild(value);
    this.parsed.push(container);
  },

  /**
   * Check if a CSS property supports a specific value.
   *
   * @param  {String} name
   *         CSS Property name to check
   * @param  {String} value
   *         CSS Property value to check
   */
  _cssPropertySupportsValue: function(name, value) {
    return this.isValidOnClient(name, value, this.doc);
  },

  /**
   * Tests if a given colorObject output by CssColor is valid for parsing.
   * Valid means it's really a color, not any of the CssColor SPECIAL_VALUES
   * except transparent
   */
  _isValidColor: function(colorObj) {
    return colorObj.valid &&
      (!colorObj.specialValue || colorObj.specialValue === "transparent");
  },

  /**
   * Append a color to the output.
   *
   * @param  {String} color
   *         Color to append
   * @param  {Object} [options]
   *         Options object. For valid options and default values see
   *         _mergeOptions().
   */
  _appendColor: function(color, options = {}) {
    const colorObj = new colorUtils.CssColor(color, this.cssColor4);

    if (this._isValidColor(colorObj)) {
      const container = this._createNode("span", {
        "data-color": color
      });

      if (options.colorSwatchClass) {
        const swatch = this._createNode("span", {
          class: options.colorSwatchClass,
          style: "background-color:" + color
        });
        this.colorSwatches.set(swatch, colorObj);
        swatch.addEventListener("mousedown", this._onColorSwatchMouseDown);
        EventEmitter.decorate(swatch);
        container.appendChild(swatch);
      }

      if (options.defaultColorType) {
        color = colorObj.toString();
        container.dataset.color = color;
      }

      const value = this._createNode("span", {
        class: options.colorClass
      }, color);

      container.appendChild(value);
      this.parsed.push(container);
    } else {
      this._appendTextNode(color);
    }
  },

  /**
   * Wrap some existing nodes in a filter editor.
   *
   * @param {String} filters
   *        The full text of the "filter" property.
   * @param {object} options
   *        The options object passed to parseCssProperty().
   * @param {object} nodes
   *        Nodes created by _toDOM().
   *
   * @returns {object}
   *        A new node that supplies a filter swatch and that wraps |nodes|.
   */
  _wrapFilter: function(filters, options, nodes) {
    const container = this._createNode("span", {
      "data-filters": filters
    });

    if (options.filterSwatchClass) {
      const swatch = this._createNode("span", {
        class: options.filterSwatchClass
      });
      container.appendChild(swatch);
    }

    const value = this._createNode("span", {
      class: options.filterClass
    });
    value.appendChild(nodes);
    container.appendChild(value);

    return container;
  },

  _onColorSwatchMouseDown: function(event) {
    if (!event.shiftKey) {
      return;
    }

    // Prevent click event to be fired to not show the tooltip
    event.stopPropagation();

    const swatch = event.target;
    const color = this.colorSwatches.get(swatch);
    const val = color.nextColorUnit();

    swatch.nextElementSibling.textContent = val;
    swatch.emit("unit-change", val);
  },

  _onAngleSwatchMouseDown: function(event) {
    if (!event.shiftKey) {
      return;
    }

    event.stopPropagation();

    const swatch = event.target;
    const angle = this.angleSwatches.get(swatch);
    const val = angle.nextAngleUnit();

    swatch.nextElementSibling.textContent = val;
    swatch.emit("unit-change", val);
  },

  /**
   * A helper function that sanitizes a possibly-unterminated URL.
   */
  _sanitizeURL: function(url) {
    // Re-lex the URL and add any needed termination characters.
    const urlTokenizer = getCSSLexer(url);
    // Just read until EOF; there will only be a single token.
    while (urlTokenizer.nextToken()) {
      // Nothing.
    }

    return urlTokenizer.performEOFFixup(url, true);
  },

  /**
   * Append a URL to the output.
   *
   * @param  {String} match
   *         Complete match that may include "url(xxx)"
   * @param  {String} url
   *         Actual URL
   * @param  {Object} [options]
   *         Options object. For valid options and default values see
   *         _mergeOptions().
   */
  _appendURL: function(match, url, options) {
    if (options.urlClass) {
      // Sanitize the URL.  Note that if we modify the URL, we just
      // leave the termination characters.  This isn't strictly
      // "as-authored", but it makes a bit more sense.
      match = this._sanitizeURL(match);
      // This regexp matches a URL token.  It puts the "url(", any
      // leading whitespace, and any opening quote into |leader|; the
      // URL text itself into |body|, and any trailing quote, trailing
      // whitespace, and the ")" into |trailer|.  We considered adding
      // functionality for this to CSSLexer, in some way, but this
      // seemed simpler on the whole.
      const [, leader, , body, trailer] =
        /^(url\([ \t\r\n\f]*(["']?))(.*?)(\2[ \t\r\n\f]*\))$/i.exec(match);

      this._appendTextNode(leader);

      let href = url;
      if (options.baseURI) {
        try {
          href = new URL(url, options.baseURI).href;
        } catch (e) {
          // Ignore.
        }
      }

      this._appendNode("a", {
        target: "_blank",
        class: options.urlClass,
        href: href
      }, body);

      this._appendTextNode(trailer);
    } else {
      this._appendTextNode(match);
    }
  },

  /**
   * Append a font family to the output.
   *
   * @param  {String} fontFamily
   *         Font family to append
   * @param  {Object} options
   *         Options object. For valid options and default values see
   *         _mergeOptions().
   */
  _appendFontFamily: function(fontFamily, options) {
    let spanContents = fontFamily;
    let quoteChar = null;
    let trailingWhitespace = false;

    // Before appending the actual font-family span, we need to trim
    // down the actual contents by removing any whitespace before and
    // after, and any quotation characters in the passed string.  Any
    // such characters are preserved in the actual output, but just
    // not inside the span element.

    if (spanContents[0] === " ") {
      this._appendTextNode(" ");
      spanContents = spanContents.slice(1);
    }

    if (spanContents[spanContents.length - 1] === " ") {
      spanContents = spanContents.slice(0, -1);
      trailingWhitespace = true;
    }

    if (spanContents[0] === "'" || spanContents[0] === "\"") {
      quoteChar = spanContents[0];
    }

    if (quoteChar) {
      this._appendTextNode(quoteChar);
      spanContents = spanContents.slice(1, -1);
    }

    this._appendNode("span", {
      class: options.fontFamilyClass
    }, spanContents);

    if (quoteChar) {
      this._appendTextNode(quoteChar);
    }

    if (trailingWhitespace) {
      this._appendTextNode(" ");
    }
  },

  /**
   * Create a node.
   *
   * @param  {String} tagName
   *         Tag type e.g. "div"
   * @param  {Object} attributes
   *         e.g. {class: "someClass", style: "cursor:pointer"};
   * @param  {String} [value]
   *         If a value is included it will be appended as a text node inside
   *         the tag. This is useful e.g. for span tags.
   * @return {Node} Newly created Node.
   */
  _createNode: function(tagName, attributes, value = "") {
    const node = this.doc.createElementNS(HTML_NS, tagName);
    const attrs = Object.getOwnPropertyNames(attributes);

    for (const attr of attrs) {
      if (attributes[attr]) {
        node.setAttribute(attr, attributes[attr]);
      }
    }

    if (value) {
      const textNode = this.doc.createTextNode(value);
      node.appendChild(textNode);
    }

    return node;
  },

  /**
   * Append a node to the output.
   *
   * @param  {String} tagName
   *         Tag type e.g. "div"
   * @param  {Object} attributes
   *         e.g. {class: "someClass", style: "cursor:pointer"};
   * @param  {String} [value]
   *         If a value is included it will be appended as a text node inside
   *         the tag. This is useful e.g. for span tags.
   */
  _appendNode: function(tagName, attributes, value = "") {
    const node = this._createNode(tagName, attributes, value);
    this.parsed.push(node);
  },

  /**
   * Append a text node to the output. If the previously output item was a text
   * node then we append the text to that node.
   *
   * @param  {String} text
   *         Text to append
   */
  _appendTextNode: function(text) {
    const lastItem = this.parsed[this.parsed.length - 1];
    if (typeof lastItem === "string") {
      this.parsed[this.parsed.length - 1] = lastItem + text;
    } else {
      this.parsed.push(text);
    }
  },

  /**
   * Take all output and append it into a single DocumentFragment.
   *
   * @return {DocumentFragment}
   *         Document Fragment
   */
  _toDOM: function() {
    const frag = this.doc.createDocumentFragment();

    for (const item of this.parsed) {
      if (typeof item === "string") {
        frag.appendChild(this.doc.createTextNode(item));
      } else {
        frag.appendChild(item);
      }
    }

    this.parsed.length = 0;
    return frag;
  },

  /**
   * Merges options objects. Default values are set here.
   *
   * @param  {Object} overrides
   *         The option values to override e.g. _mergeOptions({colors: false})
   *
   *         Valid options are:
   *           - defaultColorType: true // Convert colors to the default type
   *                                    // selected in the options panel.
   *           - angleClass: ""         // The class to use for the angle value
   *                                    // that follows the swatch.
   *           - angleSwatchClass: ""   // The class to use for angle swatches.
   *           - bezierClass: ""        // The class to use for the bezier value
   *                                    // that follows the swatch.
   *           - bezierSwatchClass: ""  // The class to use for bezier swatches.
   *           - colorClass: ""         // The class to use for the color value
   *                                    // that follows the swatch.
   *           - colorSwatchClass: ""   // The class to use for color swatches.
   *           - filterSwatch: false    // A special case for parsing a
   *                                    // "filter" property, causing the
   *                                    // parser to skip the call to
   *                                    // _wrapFilter.  Used only for
   *                                    // previewing with the filter swatch.
   *           - flexClass: ""          // The class to use for the flex icon.
   *           - gridClass: ""          // The class to use for the grid icon.
   *           - shapeClass: ""         // The class to use for the shape value
   *                                    // that follows the swatch.
   *           - shapeSwatchClass: ""   // The class to use for the shape swatch.
   *           - supportsColor: false   // Does the CSS property support colors?
   *           - urlClass: ""           // The class to be used for url() links.
   *           - fontFamilyClass: ""    // The class to be used for font families.
   *           - baseURI: undefined     // A string used to resolve
   *                                    // relative links.
   *           - isVariableInUse        // A function taking a single
   *                                    // argument, the name of a variable.
   *                                    // This should return the variable's
   *                                    // value, if it is in use; or null.
   *           - unmatchedVariableClass: ""
   *                                    // The class to use for a component
   *                                    // of a "var(...)" that is not in
   *                                    // use.
   * @return {Object}
   *         Overridden options object
   */
  _mergeOptions: function(overrides) {
    const defaults = {
      defaultColorType: true,
      angleClass: "",
      angleSwatchClass: "",
      bezierClass: "",
      bezierSwatchClass: "",
      colorClass: "",
      colorSwatchClass: "",
      filterSwatch: false,
      flexClass: "",
      gridClass: "",
      shapeClass: "",
      shapeSwatchClass: "",
      supportsColor: false,
      urlClass: "",
      fontFamilyClass: "",
      baseURI: undefined,
      isVariableInUse: null,
      unmatchedVariableClass: null,
    };

    for (const item in overrides) {
      defaults[item] = overrides[item];
    }
    return defaults;
  }
};

module.exports = OutputParser;
