/* 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 {Cc, Ci, Cu} = require("chrome");
const {colorUtils} = require("devtools/css-color");
const {Services} = Cu.import("resource://gre/modules/Services.jsm", {});

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

const MAX_ITERATIONS = 100;
const REGEX_QUOTES = /^".*?"|^".*|^'.*?'|^'.*/;
const REGEX_WHITESPACE = /^\s+/;
const REGEX_FIRST_WORD_OR_CHAR = /^\w+|^./;
const REGEX_CSS_PROPERTY_VALUE = /(^[^;]+)/;
const REGEX_CUBIC_BEZIER = /^linear|^ease-in-out|^ease-in|^ease-out|^ease|^cubic-bezier\(([0-9.\- ]+,){3}[0-9.\- ]+\)/;

/**
 * This regex matches:
 *  - #F00
 *  - #FF0000
 *  - hsl()
 *  - hsla()
 *  - rgb()
 *  - rgba()
 *  - color names
 */
const REGEX_ALL_COLORS = /^#[0-9a-fA-F]{3}\b|^#[0-9a-fA-F]{6}\b|^hsl\(.*?\)|^hsla\(.*?\)|^rgba?\(.*?\)|^[a-zA-Z-]+/;

loader.lazyGetter(this, "DOMUtils", function () {
  return Cc["@mozilla.org/inspector/dom-utils;1"].getService(Ci.inIDOMUtils);
});

/**
 * This regular expression catches all css property names with their trailing
 * spaces and semicolon. This is used to ensure a value is valid for a property
 * name within style="" attributes.
 */
loader.lazyGetter(this, "REGEX_ALL_CSS_PROPERTIES", function () {
  let names = DOMUtils.getCSSPropertyNames();
    let pattern = "^(";

    for (let i = 0; i < names.length; i++) {
      if (i > 0) {
        pattern += "|";
      }
      pattern += names[i];
    }
    pattern += ")\\s*:\\s*";

    return new RegExp(pattern);
});

/**
 * 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 {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 *   const {OutputParser} = devtools.require("devtools/output-parser");
 *
 *   let parser = new OutputParser();
 *
 *   parser.parseCssProperty("color", "red"); // Returns document fragment.
 *   parser.parseHTMLAttribute("color:red; font-size: 12px;"); // Returns document
 *                                                             // fragment.
 */
function OutputParser() {
  this.parsed = [];
}

exports.OutputParser = OutputParser;

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);

    // XXX: This is a quick fix that should stay until bug 977063 gets fixed.
    // It avoids parsing "linear" as a timing-function in "linear-gradient(...)"
    options.expectCubicBezier = ["transition", "transition-timing-function",
      "animation", "animation-timing-function"].indexOf(name) !== -1;

    if (this._cssPropertySupportsValue(name, value)) {
      return this._parse(value, options);
    }
    this._appendTextNode(value);

    return this._toDOM();
  },

  /**
   * Parse a string.
   *
   * @param  {String} value
   *         Text to parse.
   * @param  {Object} [options]
   *         Options object. For valid options and default values see
   *         _mergeOptions().
   * @return {DocumentFragment}
   *         A document fragment. Colors will not be parsed.
   */
  parseHTMLAttribute: function(value, options={}) {
    options.isHTMLAttribute = true;
    options = this._mergeOptions(options);

    return this._parse(value, options);
  },

  /**
   * Matches the beginning of the provided string to a css background-image url
   * and return both the whole url(...) match and the url itself.
   * This isn't handled via a regular expression to make sure we can match urls
   * that contain parenthesis easily
   */
  _matchBackgroundUrl: function(text) {
    let startToken = "url(";
    if (text.indexOf(startToken) !== 0) {
      return null;
    }

    let uri = text.substring(startToken.length).trim();
    let quote = uri.substring(0, 1);
    if (quote === "'" || quote === '"') {
      uri = uri.substring(1, uri.search(new RegExp(quote + "\\s*\\)")));
    } else {
      uri = uri.substring(0, uri.indexOf(")"));
      quote = "";
    }
    let end = startToken + quote + uri;
    text = text.substring(0, text.indexOf(")", end.length) + 1);

    return [text, uri.trim()];
  },

  /**
   * 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;
    let i = 0;

    while (text.length > 0) {
      let matched = null;

      // Prevent this loop from slowing down the browser with too
      // many nodes being appended into output. In practice it is very unlikely
      // that this will ever happen.
      i++;
      if (i > MAX_ITERATIONS) {
        this._appendTextNode(text);
        text = "";
        break;
      }

      matched = text.match(REGEX_QUOTES);
      if (matched) {
        let match = matched[0];

        text = this._trimMatchFromStart(text, match);
        this._appendTextNode(match);
        continue;
      }

      matched = text.match(REGEX_WHITESPACE);
      if (matched) {
        let match = matched[0];

        text = this._trimMatchFromStart(text, match);
        this._appendTextNode(match);
        continue;
      }

      matched = this._matchBackgroundUrl(text);
      if (matched) {
        let [match, url] = matched;
        text = this._trimMatchFromStart(text, match);

        this._appendURL(match, url, options);
        continue;
      }

      if (options.expectCubicBezier) {
        matched = text.match(REGEX_CUBIC_BEZIER);
        if (matched) {
          let match = matched[0];
          text = this._trimMatchFromStart(text, match);

          this._appendCubicBezier(match, options);
          continue;
        }
      }

      matched = text.match(REGEX_ALL_CSS_PROPERTIES);
      if (matched) {
        let [match] = matched;

        text = this._trimMatchFromStart(text, match);
        this._appendTextNode(match);

        if (options.isHTMLAttribute) {
          [text] = this._appendColorOnMatch(text, options);
        }
        continue;
      }

      if (!options.isHTMLAttribute) {
        let dirty;

        [text, dirty] = this._appendColorOnMatch(text, options);

        if (dirty) {
          continue;
        }
      }

      // This test must always be last as it indicates use of an unknown
      // character that needs to be removed to prevent infinite loops.
      matched = text.match(REGEX_FIRST_WORD_OR_CHAR);
      if (matched) {
        let match = matched[0];

        text = this._trimMatchFromStart(text, match);
        this._appendTextNode(match);
      }
    }

    return this._toDOM();
  },

  /**
   * Convenience function to make the parser a little more readable.
   *
   * @param  {String} text
   *         Main text
   * @param  {String} match
   *         Text to remove from the beginning
   *
   * @return {String}
   *         The string passed as 'text' with 'match' stripped from the start.
   */
  _trimMatchFromStart: function(text, match) {
    return text.substr(match.length);
  },

  /**
   * Check if there is a color match and append it if it is valid.
   *
   * @param  {String} text
   *         Main text
   * @param  {Object} options
   *         Options object. For valid options and default values see
   *         _mergeOptions().
   *
   * @return {Array}
   *         An array containing the remaining text and a dirty flag. This array
   *         is designed for deconstruction using [text, dirty].
   */
  _appendColorOnMatch: function(text, options) {
    let dirty;
    let matched = text.match(REGEX_ALL_COLORS);

    if (matched) {
      let match = matched[0];
      if (this._appendColor(match, options)) {
        text = this._trimMatchFromStart(text, match);
        dirty = true;
      }
    } else {
      dirty = false;
    }

    return [text, dirty];
  },

  /**
   * 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) {
    let container = this._createNode("span", {
       "data-bezier": bezier
    });

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

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

    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) {
    let win = Services.appShell.hiddenDOMWindow;
    let doc = win.document;

    value = value.replace("!important", "");

    let div = doc.createElement("div");
    div.style.setProperty(name, value);

    return !!div.style.getPropertyValue(name);
  },

  /**
   * 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().
   * @returns {Boolean}
   *          true if the color passed in was valid, false otherwise. Special
   *          values such as transparent also return false.
   */
  _appendColor: function(color, options={}) {
    let colorObj = new colorUtils.CssColor(color);

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

      if (options.colorSwatchClass) {
        let swatch = this._createNode("span", {
          class: options.colorSwatchClass,
          style: "background-color:" + color
        });
        container.appendChild(swatch);
      }

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

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

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

   /**
    * 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) {
      this._appendTextNode("url(\"");

      let href = url;
      if (options.baseURI) {
        href = options.baseURI.resolve(url);
      }

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

      this._appendTextNode("\")");
    } else {
      this._appendTextNode("url(\"" + url + "\")");
    }
  },

  /**
   * 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="") {
    let win = Services.appShell.hiddenDOMWindow;
    let doc = win.document;
    let node = doc.createElementNS(HTML_NS, tagName);
    let attrs = Object.getOwnPropertyNames(attributes);

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

    if (value) {
      let textNode = 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="") {
    let 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) {
    let 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() {
    let win = Services.appShell.hiddenDOMWindow;
    let doc = win.document;
    let frag = doc.createDocumentFragment();

    for (let item of this.parsed) {
      if (typeof item === "string") {
        frag.appendChild(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.
   *           - colorSwatchClass: ""   // The class to use for color swatches.
   *           - colorClass: ""         // The class to use for the color value
   *                                    // that follows the swatch.
   *           - bezierSwatchClass: ""  // The class to use for bezier swatches.
   *           - bezierClass: ""        // The class to use for the bezier value
   *                                    // that follows the swatch.
   *           - isHTMLAttribute: false // This property indicates whether we
   *                                    // are parsing an HTML attribute value.
   *                                    // When the value is passed in from an
   *                                    // HTML attribute we need to check that
   *                                    // any CSS property values are supported
   *                                    // by the property name before
   *                                    // processing the property value.
   *           - urlClass: ""           // The class to be used for url() links.
   *           - baseURI: ""            // A string or nsIURI used to resolve
   *                                    // relative links.
   * @return {Object}
   *         Overridden options object
   */
  _mergeOptions: function(overrides) {
    let defaults = {
      defaultColorType: true,
      colorSwatchClass: "",
      colorClass: "",
      bezierSwatchClass: "",
      bezierClass: "",
      isHTMLAttribute: false,
      urlClass: "",
      baseURI: ""
    };

    if (typeof overrides.baseURI === "string") {
      overrides.baseURI = Services.io.newURI(overrides.baseURI, null, null);
    }

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