/* 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/. */

const { Cc, Ci, Cu } = require('chrome');
const {cssTokenizer, cssTokenizerWithLineColumn}  =
      require("devtools/client/shared/css-parsing-utils");

/**
 * Here is what this file (+ css-parsing-utils.js) do.
 *
 * The main objective here is to provide as much suggestions to the user editing
 * a stylesheet in Style Editor. The possible things that can be suggested are:
 *  - CSS property names
 *  - CSS property values
 *  - CSS Selectors
 *  - Some other known CSS keywords
 *
 * Gecko provides a list of both property names and their corresponding values.
 * We take out a list of matching selectors using the Inspector actor's
 * `getSuggestionsForQuery` method. Now the only thing is to parse the CSS being
 * edited by the user, figure out what token or word is being written and last
 * but the most difficult, what is being edited.
 *
 * The file 'css-parsing-utils' helps to convert the CSS into meaningful tokens,
 * each having a certain type associated with it. These tokens help us to figure
 * out the currently edited word and to write a CSS state machine to figure out
 * what the user is currently editing. By that, I mean, whether he is editing a
 * selector or a property or a value, or even fine grained information like an
 * id in the selector.
 *
 * The `resolveState` method iterated over the tokens spitted out by the
 * tokenizer, using switch cases, follows a state machine logic and finally
 * figures out these informations:
 *  - The state of the CSS at the cursor (one out of CSS_STATES)
 *  - The current token that is being edited `cmpleting`
 *  - If the state is "selector", the selector state (one of SELECTOR_STATES)
 *  - If the state is "selector", the current selector till the cursor
 *  - If the state is "value", the corresponding property name
 *
 * In case of "value" and "property" states, we simply use the information
 * provided by Gecko to filter out the possible suggestions.
 * For "selector" state, we request the Inspector actor to query the page DOM
 * and filter out the possible suggestions.
 * For "media" and "keyframes" state, the only possible suggestions for now are
 * "media" and "keyframes" respectively, although "media" can have suggestions
 * like "max-width", "orientation" etc. Similarly "value" state can also have
 * much better logical suggestions if we fine grain identify a sub state just
 * like we do for the "selector" state.
 */

// Autocompletion types.

const CSS_STATES = {
  "null": "null",
  property: "property",    // foo { bar|: … }
  value: "value",          // foo {bar: baz|}
  selector: "selector",    // f| {bar: baz}
  media: "media",          // @med| , or , @media scr| { }
  keyframes: "keyframes",  // @keyf|
  frame: "frame",          // @keyframs foobar { t|
};

const SELECTOR_STATES = {
  "null": "null",
  id: "id",                // #f|
  class: "class",          // #foo.b|
  tag: "tag",              // fo|
  pseudo: "pseudo",        // foo:|
  attribute: "attribute",  // foo[b|
  value: "value",          // foo[bar=b|
};

const { properties, propertyNames } = getCSSKeywords();

/**
 * Constructor for the autocompletion object.
 *
 * @param options {Object} An options object containing the following options:
 *        - walker {Object} The object used for query selecting from the current
 *                 target's DOM.
 *        - maxEntries {Number} Maximum selectors suggestions to display.
 */
function CSSCompleter(options = {}) {
  this.walker = options.walker;
  this.maxEntries = options.maxEntries || 15;

  // Array containing the [line, ch, scopeStack] for the locations where the
  // CSS state is "null"
  this.nullStates = [];
}

CSSCompleter.prototype = {

  /**
   * Returns a list of suggestions based on the caret position.
   *
   * @param source {String} String of the source code.
   * @param caret {Object} Cursor location with line and ch properties.
   *
   * @returns [{object}] A sorted list of objects containing the following
   *          peroperties:
   *          - label {String} Full keyword for the suggestion
   *          - preLabel {String} Already entered part of the label
   */
  complete: function(source, caret) {
    // Getting the context from the caret position.
    if (!this.resolveState(source, caret)) {
      // We couldn't resolve the context, we won't be able to complete.
      return Promise.resolve([]);
    }

    // Properly suggest based on the state.
    switch(this.state) {
      case CSS_STATES.property:
        return this.completeProperties(this.completing);

      case CSS_STATES.value:
        return this.completeValues(this.propertyName, this.completing);

      case CSS_STATES.selector:
        return this.suggestSelectors();

      case CSS_STATES.media:
      case CSS_STATES.keyframes:
        if ("media".startsWith(this.completing)) {
          return Promise.resolve([{
            label: "media",
            preLabel: this.completing,
            text: "media"
          }]);
        } else if ("keyframes".startsWith(this.completing)) {
          return Promise.resolve([{
            label: "keyframes",
            preLabel: this.completing,
            text: "keyframes"
          }]);
        }
    }
    return Promise.resolve([]);
  },

  /**
   * Resolves the state of CSS at the cursor location. This method implements a
   * custom written CSS state machine. The various switch statements provide the
   * transition rules for the state. It also finds out various informatino about
   * the nearby CSS like the property name being completed, the complete
   * selector, etc.
   *
   * @param source {String} String of the source code.
   * @param caret {Object} Cursor location with line and ch properties.
   *
   * @returns CSS_STATE
   *          One of CSS_STATE enum or null if the state cannot be resolved.
   */
  resolveState: function(source, {line, ch}) {
    // Function to return the last element of an array
    let peek = arr => arr[arr.length - 1];
    // _state can be one of CSS_STATES;
    let _state = CSS_STATES.null;
    let selector = "";
    let selectorState = SELECTOR_STATES.null;
    let propertyName = null;
    let scopeStack = [];
    let selectors = [];

    // Fetch the closest null state line, ch from cached null state locations
    let matchedStateIndex = this.findNearestNullState(line);
    if (matchedStateIndex > -1) {
      let state = this.nullStates[matchedStateIndex];
      line -= state[0];
      if (line == 0)
        ch -= state[1];
      source = source.split("\n").slice(state[0]);
      source[0] = source[0].slice(state[1]);
      source = source.join("\n");
      scopeStack = [...state[2]];
      this.nullStates.length = matchedStateIndex + 1;
    }
    else {
      this.nullStates = [];
    }
    let tokens = cssTokenizerWithLineColumn(source);
    let tokIndex = tokens.length - 1;
    if (tokIndex >=0 &&
        (tokens[tokIndex].loc.end.line < line ||
         (tokens[tokIndex].loc.end.line === line &&
          tokens[tokIndex].loc.end.column < ch))) {
      // If the last token ends before the cursor location, we didn't
      // tokenize it correctly.  This special case can happen if the
      // final token is a comment.
      return null;
    }

    let cursor = 0;
    // This will maintain a stack of paired elements like { & }, @m & }, : & ; etc
    let token = null;
    let selectorBeforeNot = null;
    while (cursor <= tokIndex && (token = tokens[cursor++])) {
      switch (_state) {
        case CSS_STATES.property:
          // From CSS_STATES.property, we can either go to CSS_STATES.value state
          // when we hit the first ':' or CSS_STATES.selector if "}" is reached.
          if (token.tokenType === "symbol") {
            switch(token.text) {
            case ":":
              scopeStack.push(":");
              if (tokens[cursor - 2].tokenType != "whitespace")
                propertyName = tokens[cursor - 2].text;
              else
                propertyName = tokens[cursor - 3].text;
              _state = CSS_STATES.value;
              break;

            case "}":
              if (/[{f]/.test(peek(scopeStack))) {
                let popped = scopeStack.pop();
                if (popped == "f") {
                  _state = CSS_STATES.frame;
                } else {
                  selector = "";
                  selectors = [];
                  _state = CSS_STATES.null;
                }
              }
              break;
            }
          }
          break;

        case CSS_STATES.value:
          // From CSS_STATES.value, we can go to one of CSS_STATES.property,
          // CSS_STATES.frame, CSS_STATES.selector and CSS_STATES.null
          if (token.tokenType === "symbol") {
            switch(token.text) {
            case ";":
              if (/[:]/.test(peek(scopeStack))) {
                scopeStack.pop();
                _state = CSS_STATES.property;
              }
              break;

            case "}":
              if (peek(scopeStack) == ":")
                scopeStack.pop();

              if (/[{f]/.test(peek(scopeStack))) {
                let popped = scopeStack.pop();
                if (popped == "f") {
                  _state = CSS_STATES.frame;
                } else {
                  selector = "";
                  selectors = [];
                  _state = CSS_STATES.null;
                }
              }
              break;
            }
          }
          break;

        case CSS_STATES.selector:
          // From CSS_STATES.selector, we can only go to CSS_STATES.property when
          // we hit "{"
          if (token.tokenType === "symbol" && token.text == "{") {
            scopeStack.push("{");
            _state = CSS_STATES.property;
            selectors.push(selector);
            selector = "";
            break;
          }
          switch(selectorState) {
            case SELECTOR_STATES.id:
            case SELECTOR_STATES.class:
            case SELECTOR_STATES.tag:
              switch(token.tokenType) {
                case "hash":
                case "id":
                  selectorState = SELECTOR_STATES.id;
                  selector += "#" + token.text;
                  break;

                case "symbol":
                  if (token.text == ".") {
                    selectorState = SELECTOR_STATES.class;
                    selector += ".";
                    if (cursor <= tokIndex &&
                        tokens[cursor].tokenType == "ident") {
                      token = tokens[cursor++];
                      selector += token.text;
                    }
                  } else if (token.text == "#") {
                    selectorState = SELECTOR_STATES.id;
                    selector += "#";
                  } else if (/[>~+]/.test(token.text)) {
                    selectorState = SELECTOR_STATES.null;
                    selector += token.text;
                  } else if (token.text == ",") {
                    selectorState = SELECTOR_STATES.null;
                    selectors.push(selector);
                    selector = "";
                  } else if (token.text == ":") {
                    selectorState = SELECTOR_STATES.pseudo;
                    selector += ":";
                    if (cursor > tokIndex)
                      break;

                    token = tokens[cursor++];
                    switch(token.tokenType) {
                      case "function":
                        if (token.text == "not") {
                          selectorBeforeNot = selector;
                          selector = "";
                          scopeStack.push("(");
                        } else {
                          selector += token.text + "(";
                        }
                        selectorState = SELECTOR_STATES.null;
                        break;

                      case "ident":
                        selector += token.text;
                        break;
                    }
                  } else if (token.text == "[") {
                    selectorState = SELECTOR_STATES.attribute;
                    scopeStack.push("[");
                    selector += "[";
                  } else if (token.text == ")") {
                    if (peek(scopeStack) == "(") {
                      scopeStack.pop();
                      selector = selectorBeforeNot + "not(" + selector + ")";
                      selectorBeforeNot = null;
                    } else {
                      selector += ")";
                    }
                    selectorState = SELECTOR_STATES.null;
                  }
                  break;

                case "whitespace":
                  selectorState = SELECTOR_STATES.null;
                  selector && (selector += " ");
                  break;
              }
              break;

            case SELECTOR_STATES.null:
              // From SELECTOR_STATES.null state, we can go to one of
              // SELECTOR_STATES.id, SELECTOR_STATES.class or SELECTOR_STATES.tag
              switch(token.tokenType) {
                case "hash":
                case "id":
                  selectorState = SELECTOR_STATES.id;
                  selector += "#" + token.text;
                  break;

                case "ident":
                  selectorState = SELECTOR_STATES.tag;
                  selector += token.text;
                  break;

                case "symbol":
                  if (token.text == ".") {
                    selectorState = SELECTOR_STATES.class;
                    selector += ".";
                    if (cursor <= tokIndex &&
                        tokens[cursor].tokenType == "ident") {
                      token = tokens[cursor++];
                      selector += token.text;
                    }
                  } else if (token.text == "#") {
                    selectorState = SELECTOR_STATES.id;
                    selector += "#";
                  } else if (token.text == "*") {
                    selectorState = SELECTOR_STATES.tag;
                    selector += "*";
                  } else if (/[>~+]/.test(token.text)) {
                    selector += token.text;
                  } else if (token.text == ",") {
                    selectorState = SELECTOR_STATES.null;
                    selectors.push(selector);
                    selector = "";
                  } else if (token.text == ":") {
                    selectorState = SELECTOR_STATES.pseudo;
                    selector += ":";
                    if (cursor > tokIndex)
                      break;

                    token = tokens[cursor++];
                    switch(token.tokenType) {
                      case "function":
                        if (token.text == "not") {
                          selectorBeforeNot = selector;
                          selector = "";
                          scopeStack.push("(");
                        } else {
                          selector += token.text + "(";
                        }
                        selectorState = SELECTOR_STATES.null;
                        break;

                      case "ident":
                        selector += token.text;
                        break;
                    }
                  } else if (token.text == "[") {
                    selectorState = SELECTOR_STATES.attribute;
                    scopeStack.push("[");
                    selector += "[";
                  } else if (token.text == ")") {
                    if (peek(scopeStack) == "(") {
                      scopeStack.pop();
                      selector = selectorBeforeNot + "not(" + selector + ")";
                      selectorBeforeNot = null;
                    } else {
                      selector += ")";
                    }
                    selectorState = SELECTOR_STATES.null;
                  }
                  break;

                case "whitespace":
                  selector && (selector += " ");
                  break;
              }
              break;

            case SELECTOR_STATES.pseudo:
              switch(token.tokenType) {
                case "symbol":
                  if (/[>~+]/.test(token.text)) {
                    selectorState = SELECTOR_STATES.null;
                    selector += token.text;
                  } else if (token.text == ",") {
                    selectorState = SELECTOR_STATES.null;
                    selectors.push(selector);
                    selector = "";
                  } else if (token.text == ":") {
                    selectorState = SELECTOR_STATES.pseudo;
                    selector += ":";
                    if (cursor > tokIndex)
                      break;

                    token = tokens[cursor++];
                    switch(token.tokenType) {
                      case "function":
                        if (token.text == "not") {
                          selectorBeforeNot = selector;
                          selector = "";
                          scopeStack.push("(");
                        } else {
                          selector += token.text + "(";
                        }
                        selectorState = SELECTOR_STATES.null;
                        break;

                      case "ident":
                        selector += token.text;
                        break;
                    }
                  } else if (token.text == "[") {
                    selectorState = SELECTOR_STATES.attribute;
                    scopeStack.push("[");
                    selector += "[";
                  }
                  break;

                case "whitespace":
                  selectorState = SELECTOR_STATES.null;
                  selector && (selector += " ");
                  break;
              }
              break;

            case SELECTOR_STATES.attribute:
              switch(token.tokenType) {
                case "symbol":
                  if (/[~|^$*]/.test(token.text)) {
                    selector += token.text;
                    token = tokens[cursor++];
                  } else if (token.text == "=") {
                    selectorState = SELECTOR_STATES.value;
                    selector += token.text;
                  } else if (token.text == "]") {
                    if (peek(scopeStack) == "[")
                      scopeStack.pop();

                    selectorState = SELECTOR_STATES.null;
                    selector += "]";
                  }
                  break;

                case "ident":
                case "string":
                  selector += token.text;
                  break;

                case "whitespace":
                  selector && (selector += " ");
                  break;
              }
              break;

            case SELECTOR_STATES.value:
              switch(token.tokenType) {
                case "string":
                case "ident":
                  selector += token.text;
                  break;

                case "symbol":
                  if (token.text == "]") {
                    if (peek(scopeStack) == "[")
                      scopeStack.pop();

                    selectorState = SELECTOR_STATES.null;
                    selector += "]";
                  }
                  break;

                case "whitespace":
                  selector && (selector += " ");
                  break;
              }
              break;
          }
          break;

        case CSS_STATES.null:
          // From CSS_STATES.null state, we can go to either CSS_STATES.media or
          // CSS_STATES.selector.
          switch(token.tokenType) {
            case "hash":
            case "id":
              selectorState = SELECTOR_STATES.id;
              selector = "#" + token.text;
              _state = CSS_STATES.selector;
              break;

            case "ident":
              selectorState = SELECTOR_STATES.tag;
              selector = token.text;
              _state = CSS_STATES.selector;
              break;

            case "symbol":
              if (token.text == ".") {
                selectorState = SELECTOR_STATES.class;
                selector = ".";
                _state = CSS_STATES.selector;
                if (cursor <= tokIndex &&
                    tokens[cursor].tokenType == "ident") {
                  token = tokens[cursor++];
                  selector += token.text;
                }
              } else if (token.text == "#") {
                selectorState = SELECTOR_STATES.id;
                selector = "#";
                _state = CSS_STATES.selector;
              } else if (token.text == "*") {
                selectorState = SELECTOR_STATES.tag;
                selector = "*";
                _state = CSS_STATES.selector;
              } else if (token.text == ":") {
                _state = CSS_STATES.selector;
                selectorState = SELECTOR_STATES.pseudo;
                selector += ":";
                if (cursor > tokIndex)
                  break;

                token = tokens[cursor++];
                switch(token.tokenType) {
                  case "function":
                    if (token.text == "not") {
                      selectorBeforeNot = selector;
                      selector = "";
                      scopeStack.push("(");
                    } else {
                      selector += token.text + "(";
                    }
                    selectorState = SELECTOR_STATES.null;
                    break;

                  case "ident":
                    selector += token.text;
                    break;
                }
              } else if (token.text == "[") {
                _state = CSS_STATES.selector;
                selectorState = SELECTOR_STATES.attribute;
                scopeStack.push("[");
                selector += "[";
              } else if (token.text == "}") {
                if (peek(scopeStack) == "@m")
                  scopeStack.pop();
              }
              break;

            case "at":
              _state = token.text.startsWith("m") ? CSS_STATES.media
                                                   : CSS_STATES.keyframes;
              break;
          }
          break;

        case CSS_STATES.media:
          // From CSS_STATES.media, we can only go to CSS_STATES.null state when
          // we hit the first '{'
          if (token.tokenType == "symbol" && token.text == "{") {
            scopeStack.push("@m");
            _state = CSS_STATES.null;
          }
          break;

        case CSS_STATES.keyframes:
          // From CSS_STATES.keyframes, we can only go to CSS_STATES.frame state
          // when we hit the first '{'
          if (token.tokenType == "symbol" && token.text == "{") {
            scopeStack.push("@k");
            _state = CSS_STATES.frame;
          }
          break;

        case CSS_STATES.frame:
          // From CSS_STATES.frame, we can either go to CSS_STATES.property state
          // when we hit the first '{' or to CSS_STATES.selector when we hit '}'
          if (token.tokenType == "symbol") {
            if (token.text == "{") {
              scopeStack.push("f");
              _state = CSS_STATES.property;
            } else if (token.text == "}") {
              if (peek(scopeStack) == "@k")
                scopeStack.pop();

              _state = CSS_STATES.null;
            }
          }
          break;
      }
      if (_state == CSS_STATES.null) {
        if (this.nullStates.length == 0) {
          this.nullStates.push([token.loc.end.line, token.loc.end.column,
                                [...scopeStack]]);
          continue;
        }
        let tokenLine = token.loc.end.line;
        let tokenCh = token.loc.end.column;
        if (tokenLine == 0)
          continue;
        if (matchedStateIndex > -1)
          tokenLine += this.nullStates[matchedStateIndex][0];
        this.nullStates.push([tokenLine, tokenCh, [...scopeStack]]);
      }
    }
    this.state = _state;
    this.propertyName = _state == CSS_STATES.value ? propertyName : null;
    this.selectorState = _state == CSS_STATES.selector ? selectorState : null;
    this.selectorBeforeNot = selectorBeforeNot == null ? null: selectorBeforeNot;
    if (token) {
      selector = selector.slice(0, selector.length + token.loc.end.column - ch);
      this.selector = selector;
    }
    else {
      this.selector = "";
    }
    this.selectors = selectors;

    if (token && token.tokenType != "whitespace") {
      let text;
      if (token.tokenType == "dimension" || !token.text)
        text = source.substring(token.startOffset, token.endOffset);
      else
        text = token.text;
      this.completing = (text.slice(0, ch - token.loc.start.column)
                         .replace(/^[.#]$/, ""));
    } else {
      this.completing = "";
    }
    // Special case the situation when the user just entered ":" after typing a
    // property name.
    if (this.completing == ":" && _state == CSS_STATES.value)
      this.completing = "";

    // Special check for !important; case.
    if (token && tokens[cursor - 2] && tokens[cursor - 2].text == "!" &&
        this.completing == "important".slice(0, this.completing.length)) {
      this.completing = "!" + this.completing;
    }
    return _state;
  },

  /**
   * Queries the DOM Walker actor for suggestions regarding the selector being
   * completed
   */
  suggestSelectors: function () {
    let walker = this.walker;
    if (!walker)
      return Promise.resolve([]);

    let query = this.selector;
    // Even though the selector matched atleast one node, there is still
    // possibility of suggestions.
    switch(this.selectorState) {
      case SELECTOR_STATES.null:
        if (this.completing === ",") {
          return Promise.resolve([]);
        }

        query += "*";
        break;

      case SELECTOR_STATES.tag:
        query = query.slice(0, query.length - this.completing.length);
        break;

      case SELECTOR_STATES.id:
      case SELECTOR_STATES.class:
      case SELECTOR_STATES.pseudo:
        if (/^[.:#]$/.test(this.completing)) {
          query = query.slice(0, query.length - this.completing.length);
          this.completing = "";
        } else {
          query = query.slice(0, query.length - this.completing.length - 1);
        }
        break;
    }

    if (/[\s+>~]$/.test(query) &&
        this.selectorState != SELECTOR_STATES.attribute &&
        this.selectorState != SELECTOR_STATES.value) {
      query += "*";
    }

    // Set the values that this request was supposed to suggest to.
    this._currentQuery = query;
    return walker.getSuggestionsForQuery(query, this.completing, this.selectorState)
                 .then(result => this.prepareSelectorResults(result));
  },

 /**
  * Prepares the selector suggestions returned by the walker actor.
  */
  prepareSelectorResults: function(result) {
    if (this._currentQuery != result.query)
      return [];

    result = result.suggestions;
    let query = this.selector;
    let completion = [];
    for (let [value, count, state] of result) {
      switch(this.selectorState) {
        case SELECTOR_STATES.id:
        case SELECTOR_STATES.class:
        case SELECTOR_STATES.pseudo:
          if (/^[.:#]$/.test(this.completing)) {
            value = query.slice(0, query.length - this.completing.length) +
                       value;
          } else {
            value = query.slice(0, query.length - this.completing.length - 1) +
                       value;
          }
          break;

        case SELECTOR_STATES.tag:
          value = query.slice(0, query.length - this.completing.length) +
                     value;
          break;

        case SELECTOR_STATES.null:
          value = query + value;
          break;

        default:
         value = query.slice(0, query.length - this.completing.length) +
                    value;
      }

      let item = {
        label: value,
        preLabel: query,
        text: value,
        score: count
      };

      // In case the query's state is tag and the item's state is id or class
      // adjust the preLabel
      if (this.selectorState === SELECTOR_STATES.tag &&
          state === SELECTOR_STATES.class) {
        item.preLabel = "." + item.preLabel;
      }
      if (this.selectorState === SELECTOR_STATES.tag &&
          state === SELECTOR_STATES.id) {
        item.preLabel = "#" + item.preLabel;
      }

      completion.push(item);

      if (completion.length > this.maxEntries - 1)
        break;
    }
    return completion;
  },

  /**
   * Returns CSS property name suggestions based on the input.
   *
   * @param startProp {String} Initial part of the property being completed.
   */
  completeProperties: function(startProp) {
    let finalList = [];
    if (!startProp)
      return Promise.resolve(finalList);

    let length = propertyNames.length;
    let i = 0, count = 0;
    for (; i < length && count < this.maxEntries; i++) {
      if (propertyNames[i].startsWith(startProp)) {
        count++;
        let propName = propertyNames[i];
        finalList.push({
          preLabel: startProp,
          label: propName,
          text: propName + ": "
        });
      } else if (propertyNames[i] > startProp) {
        // We have crossed all possible matches alphabetically.
        break;
      }
    }
    return Promise.resolve(finalList);
  },

  /**
   * Returns CSS value suggestions based on the corresponding property.
   *
   * @param propName {String} The property to which the value being completed
   *        belongs.
   * @param startValue {String} Initial part of the value being completed.
   */
  completeValues: function(propName, startValue) {
    let finalList = [];
    let list = ["!important;", ...(properties[propName] || [])];
    // If there is no character being completed, we are showing an initial list
    // of possible values. Skipping '!important' in this case.
    if (!startValue)
      list.splice(0, 1);

    let length = list.length;
    let i = 0, count = 0;
    for (; i < length && count < this.maxEntries; i++) {
      if (list[i].startsWith(startValue)) {
        count++;
        let value = list[i];
        finalList.push({
          preLabel: startValue,
          label: value,
          text: value
        });
      } else if (list[i] > startValue) {
        // We have crossed all possible matches alphabetically.
        break;
      }
    }
    return Promise.resolve(finalList);
  },

  /**
   * A biased binary search in a sorted array where the middle element is
   * calculated based on the values at the lower and the upper index in each
   * iteration.
   *
   * This method returns the index of the closest null state from the passed
   * `line` argument. Once we have the closest null state, we can start applying
   * the state machine logic from that location instead of the absolute starting
   * of the CSS source. This speeds up the tokenizing and the state machine a
   * lot while using autocompletion at high line numbers in a CSS source.
   */
  findNearestNullState: function(line) {
    let arr = this.nullStates;
    let high = arr.length - 1;
    let low = 0;
    let target = 0;

    if (high < 0)
      return -1;
    if (arr[high][0] <= line)
      return high;
    if (arr[low][0] > line)
      return -1;

    while (high > low) {
      if (arr[low][0] <= line && arr[low [0]+ 1] > line)
        return low;
      if (arr[high][0] > line && arr[high - 1][0] <= line)
        return high - 1;

      target = (((line - arr[low][0]) / (arr[high][0] - arr[low][0])) *
                (high - low)) | 0;

      if (arr[target][0] <= line && arr[target + 1][0] > line) {
        return target;
      } else if (line > arr[target][0]) {
        low = target + 1;
        high--;
      } else {
        high = target - 1;
        low++;
      }
    }

    return -1;
  },

  /**
   * Invalidates the state cache for and above the line.
   */
  invalidateCache: function(line) {
    this.nullStates.length = this.findNearestNullState(line) + 1;
  },

  /**
   * Get the state information about a token surrounding the {line, ch} position
   *
   * @param {string} source
   *        The complete source of the CSS file. Unlike resolve state method,
   *        this method requires the full source.
   * @param {object} caret
   *        The line, ch position of the caret.
   *
   * @returns {object}
   *          An object containing the state of token covered by the caret.
   *          The object has following properties when the the state is
   *          "selector", "value" or "property", null otherwise:
   *           - state {string} one of CSS_STATES - "selector", "value" etc.
   *           - selector {string} The selector at the caret when `state` is
   *                      selector. OR
   *           - selectors {[string]} Array of selector strings in case when
   *                       `state` is "value" or "property"
   *           - propertyName {string} The property name at the current caret or
   *                          the property name corresponding to the value at
   *                          the caret.
   *           - value {string} The css value at the current caret.
   *           - loc {object} An object containing the starting and the ending
   *                 caret position of the whole selector, value or property.
   *                  - { start: {line, ch}, end: {line, ch}}
   */
  getInfoAt: function(source, caret) {
    // Limits the input source till the {line, ch} caret position
    function limit(source, {line, ch}) {
      line++;
      let list = source.split("\n");
      if (list.length < line)
        return source;
      if (line == 1)
        return list[0].slice(0, ch);
      return [...list.slice(0, line - 1), list[line - 1].slice(0, ch)].join("\n");
    }

    // Get the state at the given line, ch
    let state = this.resolveState(limit(source, caret), caret);
    let propertyName = this.propertyName;
    let {line, ch} = caret;
    let sourceArray = source.split("\n");
    let limitedSource = limit(source, caret);

    /**
     * Method to traverse forwards from the caret location to figure out the
     * ending point of a selector or css value.
     *
     * @param {function} check
     *        A method which takes the current state as an input and determines
     *        whether the state changed or not.
     */
    let traverseForward = check => {
      let location;
      // Backward loop to determine the beginning location of the selector.
      do {
        let lineText = sourceArray[line];
        if (line == caret.line)
          lineText = lineText.substring(caret.ch);

        let prevToken = undefined;
        let tokens = cssTokenizer(lineText);
        let found = false;
        let ech = line == caret.line ? caret.ch : 0;
        for (let token of tokens) {
          // If the line is completely spaces, handle it differently
          if (lineText.trim() == "") {
            limitedSource += lineText;
          } else {
            limitedSource += sourceArray[line]
                              .substring(ech + token.startOffset,
                                         ech + token.endOffset);
          }

          // Whitespace cannot change state.
          if (token.tokenType == "whitespace") {
            prevToken = token;
            continue;
          }

          let state = this.resolveState(limitedSource, {
            line: line,
            ch: token.endOffset + ech
          });
          if (check(state)) {
            if (prevToken && prevToken.tokenType == "whitespace")
              token = prevToken;
            location = {
              line: line,
              ch: token.startOffset + ech
            };
            found = true;
            break;
          }
          prevToken = token;
        }
        limitedSource += "\n";
        if (found)
          break;
      } while (line++ < sourceArray.length);
      return location;
    };

    /**
     * Method to traverse backwards from the caret location to figure out the
     * starting point of a selector or css value.
     *
     * @param {function} check
     *        A method which takes the current state as an input and determines
     *        whether the state changed or not.
     * @param {boolean} isValue
     *        true if the traversal is being done for a css value state.
     */
    let traverseBackwards = (check, isValue) => {
      let location;
      // Backward loop to determine the beginning location of the selector.
      do {
        let lineText = sourceArray[line];
        if (line == caret.line)
          lineText = lineText.substring(0, caret.ch);

        let tokens = Array.from(cssTokenizer(lineText));
        let found = false;
        let ech = 0;
        for (let i = tokens.length - 1; i >= 0; i--) {
          let token = tokens[i];
          // If the line is completely spaces, handle it differently
          if (lineText.trim() == "") {
            limitedSource = limitedSource.slice(0, -1 * lineText.length);
          } else {
            let length = token.endOffset - token.startOffset;
            limitedSource = limitedSource.slice(0, -1 * length);
          }

          // Whitespace cannot change state.
          if (token.tokenType == "whitespace")
            continue;

          let state = this.resolveState(limitedSource, {
            line: line,
            ch: token.startOffset
          });
          if (check(state)) {
            if (tokens[i + 1] && tokens[i + 1].tokenType == "whitespace")
              token = tokens[i + 1];
            location = {
              line: line,
              ch: isValue ? token.endOffset: token.startOffset
            };
            found = true;
            break;
          }
        }
        limitedSource = limitedSource.slice(0, -1);
        if (found)
          break;
      } while (line-- >= 0);
      return location;
    };

    if (state == CSS_STATES.selector) {
      // For selector state, the ending and starting point of the selector is
      // either when the state changes or the selector becomes empty and a
      // single selector can span multiple lines.
      // Backward loop to determine the beginning location of the selector.
      let start = traverseBackwards(state => {
        return (state != CSS_STATES.selector ||
               (this.selector == "" && this.selectorBeforeNot == null));
      });

      line = caret.line;
      limitedSource = limit(source, caret);
      // Forward loop to determine the ending location of the selector.
      let end = traverseForward(state => {
        return (state != CSS_STATES.selector ||
               (this.selector == "" && this.selectorBeforeNot == null));
      });

      // Since we have start and end positions, figure out the whole selector.
      let selector = source.split("\n").slice(start.line, end.line + 1);
      selector[selector.length - 1] =
        selector[selector.length - 1].substring(0, end.ch);
      selector[0] = selector[0].substring(start.ch);
      selector = selector.join("\n");
      return {
        state: state,
        selector: selector,
        loc: {
          start: start,
          end: end
        }
      };
    }
    else if (state == CSS_STATES.property) {
      // A property can only be a single word and thus very easy to calculate.
      let tokens = cssTokenizer(sourceArray[line]);
      for (let token of tokens) {
        // Note that, because we're tokenizing a single line, the
        // token's offset is also the column number.
        if (token.startOffset <= ch && token.endOffset >= ch) {
          return {
            state: state,
            propertyName: token.text,
            selectors: this.selectors,
            loc: {
              start: {
                line: line,
                ch: token.startOffset
              },
              end: {
                line: line,
                ch: token.endOffset
              }
            }
          };
        }
      }
    }
    else if (state == CSS_STATES.value) {
      // CSS value can be multiline too, so we go forward and backwards to
      // determine the bounds of the value at caret
      let start = traverseBackwards(state => state != CSS_STATES.value, true);

      line = caret.line;
      limitedSource = limit(source, caret);
      let end = traverseForward(state => state != CSS_STATES.value);

      let value = source.split("\n").slice(start.line, end.line + 1);
      value[value.length - 1] = value[value.length - 1].substring(0, end.ch);
      value[0] = value[0].substring(start.ch);
      value = value.join("\n");
      return {
        state: state,
        propertyName: propertyName,
        selectors: this.selectors,
        value: value,
        loc: {
          start: start,
          end: end
        }
      };
    }
    return null;
  }
}

/**
 * Returns a list of all property names and a map of property name vs possible
 * CSS values provided by the Gecko engine.
 *
 * @return {Object} An object with following properties:
 *         - propertyNames {Array} Array of string containing all the possible
 *                         CSS property names.
 *         - properties {Object|Map} A map where key is the property name and
 *                      value is an array of string containing all the possible
 *                      CSS values the property can have.
 */
function getCSSKeywords() {
  let domUtils = Cc["@mozilla.org/inspector/dom-utils;1"]
                   .getService(Ci.inIDOMUtils);
  let props = {};
  let propNames = domUtils.getCSSPropertyNames(domUtils.INCLUDE_ALIASES);
  propNames.forEach(prop => {
    props[prop] = domUtils.getCSSValuesForProperty(prop).sort();
  });
  return {
    properties: props,
    propertyNames: propNames.sort()
  };
}

module.exports = CSSCompleter;
