/* 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 {
  cssTokenizer,
  cssTokenizerWithLineColumn,
} = require("resource://devtools/shared/css/parsing-utils.js");

/**
 * 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 (e.g. 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 `completing`
 *  - 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.
 */

class CSSCompleter {
  // Autocompletion types.

  // These can be read _a lot_ in a hotpath, so keep those as individual constants using
  // a Symbol as a value so the lookup is faster.
  static CSS_STATE_NULL = Symbol("state_null");
  // foo { bar|: … };
  static CSS_STATE_PROPERTY = Symbol("state_property");
  // foo {bar: baz|};
  static CSS_STATE_VALUE = Symbol("state_value");
  // f| {bar: baz};
  static CSS_STATE_SELECTOR = Symbol("state_selector");
  // @med| , or , @media scr| { };
  static CSS_STATE_MEDIA = Symbol("state_media");
  // @keyf|;
  static CSS_STATE_KEYFRAMES = Symbol("state_keyframes");
  // @keyframs foobar { t|;
  static CSS_STATE_FRAME = Symbol("state_frame");

  static CSS_SELECTOR_STATE_NULL = Symbol("selector_state_null");
  // #f|
  static CSS_SELECTOR_STATE_ID = Symbol("selector_state_id");
  // #foo.b|
  static CSS_SELECTOR_STATE_CLASS = Symbol("selector_state_class");
  // fo|
  static CSS_SELECTOR_STATE_TAG = Symbol("selector_state_tag");
  // foo:|
  static CSS_SELECTOR_STATE_PSEUDO = Symbol("selector_state_pseudo");
  // foo[b|
  static CSS_SELECTOR_STATE_ATTRIBUTE = Symbol("selector_state_attribute");
  // foo[bar=b|
  static CSS_SELECTOR_STATE_VALUE = Symbol("selector_state_value");

  static SELECTOR_STATE_STRING_BY_SYMBOL = new Map([
    [CSSCompleter.CSS_SELECTOR_STATE_NULL, "null"],
    [CSSCompleter.CSS_SELECTOR_STATE_ID, "id"],
    [CSSCompleter.CSS_SELECTOR_STATE_CLASS, "class"],
    [CSSCompleter.CSS_SELECTOR_STATE_TAG, "tag"],
    [CSSCompleter.CSS_SELECTOR_STATE_PSEUDO, "pseudo"],
    [CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE, "attribute"],
    [CSSCompleter.CSS_SELECTOR_STATE_VALUE, "value"],
  ]);

  /**
   * @constructor
   * @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.
   *        - cssProperties {Object} The database of CSS properties.
   */
  constructor(options = {}) {
    this.walker = options.walker;
    this.maxEntries = options.maxEntries || 15;
    this.cssProperties = options.cssProperties;

    this.propertyNames = this.cssProperties.getNames().sort();

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

  /**
   * Returns a list of suggestions based on the caret position.
   *
   * @param source {String} String of the source code.
   * @param cursor {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(source, cursor) {
    // Getting the context from the caret position.
    if (!this.resolveState({ source, line: cursor.line, column: cursor.ch })) {
      // 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 CSSCompleter.CSS_STATE_PROPERTY:
        return this.completeProperties(this.completing);

      case CSSCompleter.CSS_STATE_VALUE:
        return this.completeValues(this.propertyName, this.completing);

      case CSSCompleter.CSS_STATE_SELECTOR:
        return this.suggestSelectors();

      case CSSCompleter.CSS_STATE_MEDIA:
      case CSSCompleter.CSS_STATE_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 given a source and a cursor location, or an array of tokens.
   * 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
   * information about the nearby CSS like the property name being completed, the complete
   * selector, etc.
   *
   * @param options {Object}
   * @param sourceTokens {Array<InspectorCSSToken>} Optional array of the tokens representing
   *                     a CSS source. When this is defined, `source`, `line` and `column`
   *                     shouldn't be passed.
   * @param options.source {String} Optional string of the source code. When this is defined,
   *                       `sourceTokens` shouldn't be passed.
   * @param options.line {Number} Cursor line. Mandatory when source is passed.
   * @param options.column {Number} Cursor column. Mandatory when source is passed
   *
   * @returns CSS_STATE
   *          One of CSS_STATE enum or null if the state cannot be resolved.
   */
  // eslint-disable-next-line complexity
  resolveState({ sourceTokens, source, line, column }) {
    if (sourceTokens && source) {
      throw new Error(
        "This function only accepts sourceTokens or source, not both"
      );
    }

    // _state can be one of CSS_STATES;
    let _state = CSSCompleter.CSS_STATE_NULL;
    let selector = "";
    let selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
    let propertyName = null;
    let scopeStack = [];
    let selectors = [];

    // If we need to retrieve the tokens, fetch the closest null state line/ch from cached
    // null state locations to save some cycle.
    const matchedStateIndex = !sourceTokens
      ? this.findNearestNullState(line)
      : -1;
    if (matchedStateIndex > -1) {
      const state = this.nullStates[matchedStateIndex];
      line -= state[0];
      if (line == 0) {
        column -= 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 = [];
    }

    const tokens = sourceTokens || cssTokenizerWithLineColumn(source);
    const tokIndex = tokens.length - 1;

    if (
      !sourceTokens &&
      tokIndex >= 0 &&
      (tokens[tokIndex].loc.end.line < line ||
        (tokens[tokIndex].loc.end.line === line &&
          tokens[tokIndex].loc.end.column < column))
    ) {
      // 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 CSSCompleter.CSS_STATE_PROPERTY:
          // From CSS_STATE_PROPERTY, we can either go to CSS_STATE_VALUE
          // state when we hit the first ':' or CSS_STATE_SELECTOR if "}" is
          // reached.
          if (token.tokenType === "Colon") {
            scopeStack.push(":");
            if (tokens[cursor - 2].tokenType != "WhiteSpace") {
              propertyName = tokens[cursor - 2].text;
            } else {
              propertyName = tokens[cursor - 3].text;
            }
            _state = CSSCompleter.CSS_STATE_VALUE;
          }

          if (token.tokenType === "CloseCurlyBracket") {
            if (/[{f]/.test(scopeStack.at(-1))) {
              const popped = scopeStack.pop();
              if (popped == "f") {
                _state = CSSCompleter.CSS_STATE_FRAME;
              } else {
                selector = "";
                selectors = [];
                _state = CSSCompleter.CSS_STATE_NULL;
              }
            }
          }
          break;

        case CSSCompleter.CSS_STATE_VALUE:
          // From CSS_STATE_VALUE, we can go to one of CSS_STATE_PROPERTY,
          // CSS_STATE_FRAME, CSS_STATE_SELECTOR and CSS_STATE_NULL
          if (token.tokenType === "Semicolon") {
            if (/[:]/.test(scopeStack.at(-1))) {
              scopeStack.pop();
              _state = CSSCompleter.CSS_STATE_PROPERTY;
            }
          }

          if (token.tokenType === "CloseCurlyBracket") {
            if (scopeStack.at(-1) == ":") {
              scopeStack.pop();
            }

            if (/[{f]/.test(scopeStack.at(-1))) {
              const popped = scopeStack.pop();
              if (popped == "f") {
                _state = CSSCompleter.CSS_STATE_FRAME;
              } else {
                selector = "";
                selectors = [];
                _state = CSSCompleter.CSS_STATE_NULL;
              }
            }
          }
          break;

        case CSSCompleter.CSS_STATE_SELECTOR:
          // From CSS_STATE_SELECTOR, we can only go to CSS_STATE_PROPERTY
          // when we hit "{"
          if (token.tokenType === "CurlyBracketBlock") {
            scopeStack.push("{");
            _state = CSSCompleter.CSS_STATE_PROPERTY;
            selectors.push(selector);
            selector = "";
            break;
          }

          switch (selectorState) {
            case CSSCompleter.CSS_SELECTOR_STATE_ID:
            case CSSCompleter.CSS_SELECTOR_STATE_CLASS:
            case CSSCompleter.CSS_SELECTOR_STATE_TAG:
              switch (token.tokenType) {
                case "Hash":
                case "IDHash":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
                  selector += token.text;
                  break;

                case "Delim":
                  if (token.text == ".") {
                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_CLASS;
                    selector += ".";
                    if (
                      cursor <= tokIndex &&
                      tokens[cursor].tokenType == "Ident"
                    ) {
                      token = tokens[cursor++];
                      selector += token.text;
                    }
                  } else if (token.text == "#") {
                    // Lonely # char, that doesn't produce a Hash nor IDHash
                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
                    selector += "#";
                  } else if (
                    token.text == "+" ||
                    token.text == "~" ||
                    token.text == ">"
                  ) {
                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                    selector += token.text;
                  }
                  break;

                case "Comma":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  selectors.push(selector);
                  selector = "";
                  break;

                case "Colon":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_PSEUDO;
                  selector += ":";
                  if (cursor > tokIndex) {
                    break;
                  }

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

                    case "Ident":
                      selector += token.text;
                      break;
                  }
                  break;

                case "SquareBracketBlock":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE;
                  scopeStack.push("[");
                  selector += "[";
                  break;

                case "CloseParenthesis":
                  if (scopeStack.at(-1) == "(") {
                    scopeStack.pop();
                    selector = selectorBeforeNot + "not(" + selector + ")";
                    selectorBeforeNot = null;
                  } else {
                    selector += ")";
                  }
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  break;

                case "WhiteSpace":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  selector && (selector += " ");
                  break;
              }
              break;

            case CSSCompleter.CSS_SELECTOR_STATE_NULL:
              // From CSS_SELECTOR_STATE_NULL state, we can go to one of
              // CSS_SELECTOR_STATE_ID, CSS_SELECTOR_STATE_CLASS or
              // CSS_SELECTOR_STATE_TAG
              switch (token.tokenType) {
                case "Hash":
                case "IDHash":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
                  selector += token.text;
                  break;

                case "Ident":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_TAG;
                  selector += token.text;
                  break;

                case "Delim":
                  if (token.text == ".") {
                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_CLASS;
                    selector += ".";
                    if (
                      cursor <= tokIndex &&
                      tokens[cursor].tokenType == "Ident"
                    ) {
                      token = tokens[cursor++];
                      selector += token.text;
                    }
                  } else if (token.text == "#") {
                    // Lonely # char, that doesn't produce a Hash nor IDHash
                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
                    selector += "#";
                  } else if (token.text == "*") {
                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_TAG;
                    selector += "*";
                  } else if (
                    token.text == "+" ||
                    token.text == "~" ||
                    token.text == ">"
                  ) {
                    selector += token.text;
                  }
                  break;

                case "Comma":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  selectors.push(selector);
                  selector = "";
                  break;

                case "Colon":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_PSEUDO;
                  selector += ":";
                  if (cursor > tokIndex) {
                    break;
                  }

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

                    case "Ident":
                      selector += token.text;
                      break;
                  }
                  break;

                case "SquareBracketBlock":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE;
                  scopeStack.push("[");
                  selector += "[";
                  break;

                case "CloseParenthesis":
                  if (scopeStack.at(-1) == "(") {
                    scopeStack.pop();
                    selector = selectorBeforeNot + "not(" + selector + ")";
                    selectorBeforeNot = null;
                  } else {
                    selector += ")";
                  }
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  break;

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

            case CSSCompleter.CSS_SELECTOR_STATE_PSEUDO:
              switch (token.tokenType) {
                case "Delim":
                  if (
                    token.text == "+" ||
                    token.text == "~" ||
                    token.text == ">"
                  ) {
                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                    selector += token.text;
                  }
                  break;

                case "Comma":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  selectors.push(selector);
                  selector = "";
                  break;

                case "Colon":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_PSEUDO;
                  selector += ":";
                  if (cursor > tokIndex) {
                    break;
                  }

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

                    case "Ident":
                      selector += token.text;
                      break;
                  }
                  break;
                case "SquareBracketBlock":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE;
                  scopeStack.push("[");
                  selector += "[";
                  break;

                case "WhiteSpace":
                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  selector && (selector += " ");
                  break;
              }
              break;

            case CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE:
              switch (token.tokenType) {
                case "IncludeMatch":
                case "DashMatch":
                case "PrefixMatch":
                case "IncludeSuffixMatchMatch":
                case "SubstringMatch":
                  selector += token.text;
                  token = tokens[cursor++];
                  break;

                case "Delim":
                  if (token.text == "=") {
                    selectorState = CSSCompleter.CSS_SELECTOR_STATE_VALUE;
                    selector += token.text;
                  }
                  break;

                case "CloseSquareBracket":
                  if (scopeStack.at(-1) == "[") {
                    scopeStack.pop();
                  }

                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  selector += "]";
                  break;

                case "Ident":
                  selector += token.text;
                  break;

                case "QuotedString":
                  selector += token.value;
                  break;

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

            case CSSCompleter.CSS_SELECTOR_STATE_VALUE:
              switch (token.tokenType) {
                case "Ident":
                  selector += token.text;
                  break;

                case "QuotedString":
                  selector += token.value;
                  break;

                case "CloseSquareBracket":
                  if (scopeStack.at(-1) == "[") {
                    scopeStack.pop();
                  }

                  selectorState = CSSCompleter.CSS_SELECTOR_STATE_NULL;
                  selector += "]";
                  break;

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

        case CSSCompleter.CSS_STATE_NULL:
          // From CSS_STATE_NULL state, we can go to either CSS_STATE_MEDIA or
          // CSS_STATE_SELECTOR.
          switch (token.tokenType) {
            case "Hash":
            case "IDHash":
              selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
              selector = token.text;
              _state = CSSCompleter.CSS_STATE_SELECTOR;
              break;

            case "Ident":
              selectorState = CSSCompleter.CSS_SELECTOR_STATE_TAG;
              selector = token.text;
              _state = CSSCompleter.CSS_STATE_SELECTOR;
              break;

            case "Delim":
              if (token.text == ".") {
                selectorState = CSSCompleter.CSS_SELECTOR_STATE_CLASS;
                selector = ".";
                _state = CSSCompleter.CSS_STATE_SELECTOR;
                if (cursor <= tokIndex && tokens[cursor].tokenType == "Ident") {
                  token = tokens[cursor++];
                  selector += token.text;
                }
              } else if (token.text == "#") {
                // Lonely # char, that doesn't produce a Hash nor IDHash
                selectorState = CSSCompleter.CSS_SELECTOR_STATE_ID;
                selector = "#";
                _state = CSSCompleter.CSS_STATE_SELECTOR;
              } else if (token.text == "*") {
                selectorState = CSSCompleter.CSS_SELECTOR_STATE_TAG;
                selector = "*";
                _state = CSSCompleter.CSS_STATE_SELECTOR;
              }
              break;

            case "Colon":
              _state = CSSCompleter.CSS_STATE_SELECTOR;
              selectorState = CSSCompleter.CSS_SELECTOR_STATE_PSEUDO;
              selector += ":";
              if (cursor > tokIndex) {
                break;
              }

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

                case "Ident":
                  selector += token.text;
                  break;
              }
              break;

            case "CloseSquareBracket":
              _state = CSSCompleter.CSS_STATE_SELECTOR;
              selectorState = CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE;
              scopeStack.push("[");
              selector += "[";
              break;

            case "CurlyBracketBlock":
              if (scopeStack.at(-1) == "@m") {
                scopeStack.pop();
              }
              break;

            case "AtKeyword":
              // XXX: We should probably handle other at-rules (@container, @property, …)
              _state = token.value.startsWith("m")
                ? CSSCompleter.CSS_STATE_MEDIA
                : CSSCompleter.CSS_STATE_KEYFRAMES;
              break;
          }
          break;

        case CSSCompleter.CSS_STATE_MEDIA:
          // From CSS_STATE_MEDIA, we can only go to CSS_STATE_NULL state when
          // we hit the first '{'
          if (token.tokenType == "CurlyBracketBlock") {
            scopeStack.push("@m");
            _state = CSSCompleter.CSS_STATE_NULL;
          }
          break;

        case CSSCompleter.CSS_STATE_KEYFRAMES:
          // From CSS_STATE_KEYFRAMES, we can only go to CSS_STATE_FRAME state
          // when we hit the first '{'
          if (token.tokenType == "CurlyBracketBlock") {
            scopeStack.push("@k");
            _state = CSSCompleter.CSS_STATE_FRAME;
          }
          break;

        case CSSCompleter.CSS_STATE_FRAME:
          // From CSS_STATE_FRAME, we can either go to CSS_STATE_PROPERTY
          // state when we hit the first '{' or to CSS_STATE_SELECTOR when we
          // hit '}'
          if (token.tokenType == "CurlyBracketBlock") {
            scopeStack.push("f");
            _state = CSSCompleter.CSS_STATE_PROPERTY;
          } else if (token.tokenType == "CloseCurlyBracket") {
            if (scopeStack.at(-1) == "@k") {
              scopeStack.pop();
            }

            _state = CSSCompleter.CSS_STATE_NULL;
          }
          break;
      }
      if (_state == CSSCompleter.CSS_STATE_NULL) {
        if (!this.nullStates.length) {
          this.nullStates.push([
            token.loc.end.line,
            token.loc.end.column,
            [...scopeStack],
          ]);
          continue;
        }
        let tokenLine = token.loc.end.line;
        const tokenCh = token.loc.end.column;
        if (tokenLine == 0) {
          continue;
        }
        if (matchedStateIndex > -1) {
          tokenLine += this.nullStates[matchedStateIndex][0];
        }
        this.nullStates.push([tokenLine, tokenCh, [...scopeStack]]);
      }
    }
    // ^ while loop end

    this.state = _state;
    this.propertyName =
      _state == CSSCompleter.CSS_STATE_VALUE ? propertyName : null;
    this.selectorState =
      _state == CSSCompleter.CSS_STATE_SELECTOR ? selectorState : null;
    this.selectorBeforeNot =
      selectorBeforeNot == null ? null : selectorBeforeNot;
    if (token) {
      // If the source text is passed, we need to remove the part of the computed selector
      // after the caret (when sourceTokens are passed, the last token is already sliced,
      // so we'll get the expected value)
      if (!sourceTokens) {
        selector = selector.slice(
          0,
          selector.length + token.loc.end.column - column
        );
      }
      this.selector = selector;
    } else {
      this.selector = "";
    }
    this.selectors = selectors;

    if (token && token.tokenType != "WhiteSpace") {
      let text;
      if (
        token.tokenType === "IDHash" ||
        token.tokenType === "Hash" ||
        token.tokenType === "AtKeyword" ||
        token.tokenType === "Function" ||
        token.tokenType === "QuotedString"
      ) {
        text = token.value;
      } else {
        text = token.text;
      }
      this.completing = (
        sourceTokens
          ? text
          : // If the source text is passed, we need to remove the text after the caret
            // (when sourceTokens are passed, the last token is already sliced, so we'll
            // get the expected value)
            text.slice(0, column - 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 == CSSCompleter.CSS_STATE_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() {
    const 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 CSSCompleter.CSS_SELECTOR_STATE_NULL:
        if (this.completing === ",") {
          return Promise.resolve([]);
        }

        query += "*";
        break;

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

      case CSSCompleter.CSS_SELECTOR_STATE_ID:
      case CSSCompleter.CSS_SELECTOR_STATE_CLASS:
      case CSSCompleter.CSS_SELECTOR_STATE_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 != CSSCompleter.CSS_SELECTOR_STATE_ATTRIBUTE &&
      this.selectorState != CSSCompleter.CSS_SELECTOR_STATE_VALUE
    ) {
      query += "*";
    }

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

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

    const { suggestions } = result;
    const query = this.selector;
    const completion = [];

    for (const suggestion of suggestions) {
      let [value, state] = suggestion;

      switch (this.selectorState) {
        case CSSCompleter.CSS_SELECTOR_STATE_ID:
        case CSSCompleter.CSS_SELECTOR_STATE_CLASS:
        case CSSCompleter.CSS_SELECTOR_STATE_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 CSSCompleter.CSS_SELECTOR_STATE_TAG:
          value = query.slice(0, query.length - this.completing.length) + value;
          break;

        case CSSCompleter.CSS_SELECTOR_STATE_NULL:
          value = query + value;
          break;

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

      const item = {
        label: value,
        preLabel: query,
        text: value,
      };

      // In case the query's state is tag and the item's state is id or class
      // adjust the preLabel
      if (
        this.selectorState === CSSCompleter.CSS_SELECTOR_STATE_TAG &&
        state === CSSCompleter.CSS_SELECTOR_STATE_CLASS
      ) {
        item.preLabel = "." + item.preLabel;
      }
      if (
        this.selectorState === CSSCompleter.CSS_SELECTOR_STATE_TAG &&
        state === CSSCompleter.CSS_SELECTOR_STATE_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(startProp) {
    const finalList = [];
    if (!startProp) {
      return Promise.resolve(finalList);
    }

    const length = this.propertyNames.length;
    let i = 0,
      count = 0;
    for (; i < length && count < this.maxEntries; i++) {
      if (this.propertyNames[i].startsWith(startProp)) {
        count++;
        const propName = this.propertyNames[i];
        finalList.push({
          preLabel: startProp,
          label: propName,
          text: propName + ": ",
        });
      } else if (this.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(propName, startValue) {
    const finalList = [];
    const list = ["!important;", ...this.cssProperties.getValues(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);
    }

    const length = list.length;
    let i = 0,
      count = 0;
    for (; i < length && count < this.maxEntries; i++) {
      if (list[i].startsWith(startValue)) {
        count++;
        const 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(line) {
    const 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(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(source, caret) {
    const { line, ch } = caret;
    const sourceArray = source.split("\n");

    // Limits the input source till the {line, ch} caret position
    const limit = function () {
      // `line` is 0-based
      if (sourceArray.length <= line) {
        return source;
      }
      const list = sourceArray.slice(0, line + 1);
      list[line] = list[line].slice(0, ch);
      return list.join("\n");
    };

    const limitedSource = limit(source);

    // Ideally we should be using `cssTokenizer`, which parse incrementaly and returns a generator.
    // `cssTokenizerWithLineColumn` parses the whole `limitedSource` content right away
    // and returns an array of tokens. This can be a performance bottleneck,
    // but `resolveState` would go through all the tokens anyway, as well as `traverseBackward`,
    // which starts from the last token.
    const limitedSourceTokens = cssTokenizerWithLineColumn(limitedSource);
    const state = this.resolveState({
      sourceTokens: limitedSourceTokens,
    });
    const propertyName = this.propertyName;

    /**
     * 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.
     */
    const traverseForward = check => {
      let forwardCurrentLine = line;
      let forwardCurrentSource = limitedSource;

      // loop to determine the end location of the property name/value/selector.
      do {
        let lineText = sourceArray[forwardCurrentLine];
        if (forwardCurrentLine == line) {
          lineText = lineText.substring(ch);
        }

        let prevToken = undefined;
        const tokensIterator = cssTokenizer(lineText);

        const ech = forwardCurrentLine == line ? ch : 0;
        for (let token of tokensIterator) {
          forwardCurrentSource += sourceArray[forwardCurrentLine].substring(
            ech + token.startOffset,
            ech + token.endOffset
          );

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

          const forwState = this.resolveState({
            source: forwardCurrentSource,
            line: forwardCurrentLine,
            column: token.endOffset + ech,
          });
          if (check(forwState)) {
            if (prevToken && prevToken.tokenType == "WhiteSpace") {
              token = prevToken;
            }
            return {
              line: forwardCurrentLine,
              ch: token.startOffset + ech,
            };
          }
          prevToken = token;
        }
        forwardCurrentSource += "\n";
      } while (++forwardCurrentLine < sourceArray.length);
      return null;
    };

    /**
     * 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.
     */
    const traverseBackwards = (check, isValue) => {
      let token;
      let previousToken;
      const remainingTokens = Array.from(limitedSourceTokens);

      // Backward loop to determine the beginning location of the selector.
      while (((previousToken = token), (token = remainingTokens.pop()))) {
        // WhiteSpace cannot change state.
        if (token.tokenType == "WhiteSpace") {
          continue;
        }

        const backState = this.resolveState({
          sourceTokens: remainingTokens,
        });
        if (check(backState)) {
          if (previousToken?.tokenType == "WhiteSpace") {
            token = previousToken;
          }

          const loc = isValue ? token.loc.end : token.loc.start;
          return {
            line: loc.line,
            ch: loc.column,
          };
        }
      }
      return null;
    };

    if (state == CSSCompleter.CSS_STATE_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.
      const start = traverseBackwards(backState => {
        return (
          backState != CSSCompleter.CSS_STATE_SELECTOR ||
          (this.selector == "" && this.selectorBeforeNot == null)
        );
      });

      // Forward loop to determine the ending location of the selector.
      const end = traverseForward(forwState => {
        return (
          forwState != CSSCompleter.CSS_STATE_SELECTOR ||
          (this.selector == "" && this.selectorBeforeNot == null)
        );
      });

      // Since we have start and end positions, figure out the whole selector.
      let selector = sourceArray.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,
        selector,
        loc: {
          start,
          end,
        },
      };
    } else if (state == CSSCompleter.CSS_STATE_PROPERTY) {
      // A property can only be a single word and thus very easy to calculate.
      const tokensIterator = cssTokenizer(sourceArray[line]);
      for (const token of tokensIterator) {
        // 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,
            propertyName: token.text,
            selectors: this.selectors,
            loc: {
              start: {
                line,
                ch: token.startOffset,
              },
              end: {
                line,
                ch: token.endOffset,
              },
            },
          };
        }
      }
    } else if (state == CSSCompleter.CSS_STATE_VALUE) {
      // CSS value can be multiline too, so we go forward and backwards to
      // determine the bounds of the value at caret
      const start = traverseBackwards(
        backState => backState != CSSCompleter.CSS_STATE_VALUE,
        true
      );

      // Find the end of the value using a simple forward scan.
      const remainingSource = source.substring(limitedSource.length);
      const parser = new InspectorCSSParser(remainingSource);
      let end;
      while (true) {
        const token = parser.nextToken();
        if (
          !token ||
          token.tokenType === "Semicolon" ||
          token.tokenType === "CloseCurlyBracket"
        ) {
          // Done.  We're guaranteed to exit the loop once we reach
          // the end of the string.
          end = {
            line: parser.lineNumber + line,
            ch: parser.columnNumber,
          };
          if (end.line === line) {
            end.ch = end.ch + ch;
          }
          break;
        }
      }

      let value = sourceArray.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,
        propertyName,
        selectors: this.selectors,
        value,
        loc: {
          start,
          end,
        },
      };
    }
    return null;
  }
}

module.exports = CSSCompleter;
