/* 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 { debounce } = require("resource://devtools/shared/debounce.js");
const isMacOS = Services.appinfo.OS === "Darwin";

const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  getFocusableElements: "resource://devtools/client/shared/focus.mjs",
});

loader.lazyRequireGetter(this, "Debugger", "Debugger");
loader.lazyRequireGetter(
  this,
  "EventEmitter",
  "resource://devtools/shared/event-emitter.js"
);
loader.lazyRequireGetter(
  this,
  "AutocompletePopup",
  "resource://devtools/client/shared/autocomplete-popup.js"
);

loader.lazyRequireGetter(
  this,
  "PropTypes",
  "resource://devtools/client/shared/vendor/react-prop-types.js"
);
loader.lazyRequireGetter(
  this,
  "KeyCodes",
  "resource://devtools/client/shared/keycodes.js",
  true
);
loader.lazyRequireGetter(
  this,
  "Editor",
  "resource://devtools/client/shared/sourceeditor/editor.js"
);
loader.lazyRequireGetter(
  this,
  "l10n",
  "resource://devtools/client/webconsole/utils/messages.js",
  true
);
loader.lazyRequireGetter(
  this,
  "saveAs",
  "resource://devtools/shared/DevToolsUtils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "beautify",
  "resource://devtools/shared/jsbeautify/beautify.js"
);

// React & Redux
const {
  Component,
  createFactory,
} = require("resource://devtools/client/shared/vendor/react.mjs");
const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
const {
  connect,
} = require("resource://devtools/client/shared/vendor/react-redux.js");

// History Modules
const {
  getHistory,
  getHistoryValue,
} = require("resource://devtools/client/webconsole/selectors/history.js");
const {
  getAutocompleteState,
} = require("resource://devtools/client/webconsole/selectors/autocomplete.js");
const actions = require("resource://devtools/client/webconsole/actions/index.js");

const EvaluationContextSelector = createFactory(
  require("resource://devtools/client/webconsole/components/Input/EvaluationContextSelector.js")
);

// Constants used for defining the direction of JSTerm input history navigation.
const {
  HISTORY_BACK,
  HISTORY_FORWARD,
} = require("resource://devtools/client/webconsole/constants.js");

const JSTERM_CODEMIRROR_ORIGIN = "jsterm";

/**
 * Create a JSTerminal (a JavaScript command line). This is attached to an
 * existing HeadsUpDisplay (a Web Console instance). This code is responsible
 * with handling command line input and code evaluation.
 */
class JSTerm extends Component {
  static get propTypes() {
    return {
      // Returns previous or next value from the history
      // (depending on direction argument).
      getValueFromHistory: PropTypes.func.isRequired,
      // History of executed expression (state).
      history: PropTypes.object.isRequired,
      // Console object.
      webConsoleUI: PropTypes.object.isRequired,
      // Needed for opening context menu
      serviceContainer: PropTypes.object.isRequired,
      // Handler for clipboard 'paste' event (also used for 'drop' event, callback).
      onPaste: PropTypes.func,
      // Evaluate provided expression.
      evaluateExpression: PropTypes.func.isRequired,
      // Update position in the history after executing an expression (action).
      updateHistoryPosition: PropTypes.func.isRequired,
      // Update autocomplete popup state.
      autocompleteUpdate: PropTypes.func.isRequired,
      autocompleteClear: PropTypes.func.isRequired,
      // Data to be displayed in the autocomplete popup.
      autocompleteData: PropTypes.object.isRequired,
      // Toggle the editor mode.
      editorToggle: PropTypes.func.isRequired,
      // Dismiss the editor onboarding UI.
      editorOnboardingDismiss: PropTypes.func.isRequired,
      // Set the last JS input value.
      terminalInputChanged: PropTypes.func.isRequired,
      // Is the input in editor mode.
      editorMode: PropTypes.bool,
      editorWidth: PropTypes.number,
      editorPrettifiedAt: PropTypes.number,
      showEditorOnboarding: PropTypes.bool,
      autocomplete: PropTypes.bool,
      autocompletePopupPosition: PropTypes.string,
      inputEnabled: PropTypes.bool,
    };
  }

  constructor(props) {
    super(props);

    const { webConsoleUI } = props;

    this.webConsoleUI = webConsoleUI;
    this.hudId = this.webConsoleUI.hudId;

    this._onEditorChanges = this._onEditorChanges.bind(this);
    this._onEditorBeforeChange = this._onEditorBeforeChange.bind(this);
    this._onEditorKeyHandled = this._onEditorKeyHandled.bind(this);
    this.onContextMenu = this.onContextMenu.bind(this);
    this.imperativeUpdate = this.imperativeUpdate.bind(this);

    // We debounce the autocompleteUpdate so we don't send too many requests to the server
    // as the user is typing.
    // The delay should be small enough to be unnoticed by the user.
    this.autocompleteUpdate = debounce(this.props.autocompleteUpdate, 75, this);

    // Updates to the terminal input which can trigger eager evaluations are
    // similarly debounced.
    this.terminalInputChanged = debounce(
      this.props.terminalInputChanged,
      75,
      this
    );

    // Because the autocomplete has a slight delay (75ms), there can be time where the
    // codeMirror completion text is out-of-date, which might lead to issue when the user
    // accept the autocompletion while the update of the completion text is still pending.
    // In order to account for that, we put any future value of the completion text in
    // this property.
    this.pendingCompletionText = null;

    /**
     * Last input value.
     *
     * @type string
     */
    this.lastInputValue = "";

    this.autocompletePopup = null;

    EventEmitter.decorate(this);
    webConsoleUI.jsterm = this;
  }

  componentDidMount() {
    if (this.props.editorMode) {
      this.setEditorWidth(this.props.editorWidth);
    }

    const autocompleteOptions = {
      onSelect: this.onAutocompleteSelect.bind(this),
      onClick: this.acceptProposedCompletion.bind(this),
      listId: "webConsole_autocompletePopupListBox",
      position: this.props.autocompletePopupPosition,
      autoSelect: true,
      useXulWrapper: true,
    };

    const doc = this.webConsoleUI.document;
    const { toolbox } = this.webConsoleUI.wrapper;
    const tooltipDoc = toolbox ? toolbox.doc : doc;
    // The popup will be attached to the toolbox document or HUD document in the case
    // such as the browser console which doesn't have a toolbox.
    this.autocompletePopup = new AutocompletePopup(
      tooltipDoc,
      autocompleteOptions
    );

    if (this.node) {
      const onArrowUp = () => {
        let inputUpdated;
        if (this.autocompletePopup.isOpen) {
          this.autocompletePopup.selectPreviousItem();
          return null;
        }

        if (this.props.editorMode === false && this.canCaretGoPrevious()) {
          inputUpdated = this.historyPeruse(HISTORY_BACK);
        }

        return inputUpdated ? null : "CodeMirror.Pass";
      };

      const onArrowDown = () => {
        let inputUpdated;
        if (this.autocompletePopup.isOpen) {
          this.autocompletePopup.selectNextItem();
          return null;
        }

        if (this.props.editorMode === false && this.canCaretGoNext()) {
          inputUpdated = this.historyPeruse(HISTORY_FORWARD);
        }

        return inputUpdated ? null : "CodeMirror.Pass";
      };

      const onArrowLeft = () => {
        if (this.autocompletePopup.isOpen || this.getAutoCompletionText()) {
          this.clearCompletion();
        }
        return "CodeMirror.Pass";
      };

      const onArrowRight = () => {
        // We only want to complete on Right arrow if the completion text is
        // displayed.
        if (this.getAutoCompletionText()) {
          this.acceptProposedCompletion();
          return null;
        }

        this.clearCompletion();
        return "CodeMirror.Pass";
      };

      const onCtrlCmdEnter = () => {
        if (this.hasAutocompletionSuggestion()) {
          return this.acceptProposedCompletion();
        }

        this._execute();
        return null;
      };

      this.editor = new Editor({
        autofocus: true,
        enableCodeFolding: this.props.editorMode,
        lineNumbers: this.props.editorMode,
        lineWrapping: true,
        mode: {
          name: "javascript",
          globalVars: true,
        },
        styleActiveLine: false,
        tabIndex: "0",
        viewportMargin: Infinity,
        disableSearchAddon: true,
        extraKeys: {
          Enter: () => {
            // No need to handle shift + Enter as it's natively handled by CodeMirror.

            const hasSuggestion = this.hasAutocompletionSuggestion();
            if (
              !hasSuggestion &&
              !Debugger.isCompilableUnit(this._getValue())
            ) {
              // incomplete statement
              return "CodeMirror.Pass";
            }

            if (hasSuggestion) {
              return this.acceptProposedCompletion();
            }

            if (!this.props.editorMode) {
              this._execute();
              return null;
            }
            return "CodeMirror.Pass";
          },

          "Cmd-Enter": onCtrlCmdEnter,
          "Ctrl-Enter": onCtrlCmdEnter,

          [Editor.accel("S")]: () => {
            const value = this._getValue();
            if (!value) {
              return null;
            }

            const date = new Date();
            const suggestedName =
              `console-input-${date.getFullYear()}-` +
              `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` +
              `${date.getMinutes()}-${date.getSeconds()}.js`;
            const data = new TextEncoder().encode(value);
            return saveAs(window, data, suggestedName, [
              {
                pattern: "*.js",
                label: l10n.getStr("webconsole.input.openJavaScriptFileFilter"),
              },
            ]);
          },

          [Editor.accel("O")]: async () => this._openFile(),

          Tab: () => {
            if (this.hasEmptyInput()) {
              this.editor.codeMirror.getInputField().blur();
              return false;
            }

            if (
              this.props.autocompleteData &&
              this.props.autocompleteData.getterPath
            ) {
              this.props.autocompleteUpdate(
                true,
                this.props.autocompleteData.getterPath
              );
              return false;
            }

            const isSomethingSelected = this.editor.somethingSelected();
            const hasSuggestion = this.hasAutocompletionSuggestion();

            if (hasSuggestion && !isSomethingSelected) {
              this.acceptProposedCompletion();
              return false;
            }

            if (!isSomethingSelected) {
              this.insertStringAtCursor("\t");
              return false;
            }

            // Something is selected, let the editor handle the indent.
            return true;
          },

          "Shift-Tab": () => {
            if (this.hasEmptyInput()) {
              this.focusPreviousElement();
              return false;
            }

            const hasSuggestion = this.hasAutocompletionSuggestion();

            if (hasSuggestion) {
              return false;
            }

            return "CodeMirror.Pass";
          },

          Up: onArrowUp,
          "Cmd-Up": onArrowUp,

          Down: onArrowDown,
          "Cmd-Down": onArrowDown,

          Left: onArrowLeft,
          "Ctrl-Left": onArrowLeft,
          "Cmd-Left": onArrowLeft,
          "Alt-Left": onArrowLeft,
          // On OSX, Ctrl-A navigates to the beginning of the line.
          "Ctrl-A": isMacOS ? onArrowLeft : undefined,

          Right: onArrowRight,
          "Ctrl-Right": onArrowRight,
          "Cmd-Right": onArrowRight,
          "Alt-Right": onArrowRight,

          "Ctrl-N": () => {
            // Control-N differs from down arrow: it ignores autocomplete state.
            // Note that we preserve the default 'down' navigation within
            // multiline text.
            if (
              Services.appinfo.OS === "Darwin" &&
              this.props.editorMode === false &&
              this.canCaretGoNext() &&
              this.historyPeruse(HISTORY_FORWARD)
            ) {
              return null;
            }

            this.clearCompletion();
            return "CodeMirror.Pass";
          },

          "Ctrl-P": () => {
            // Control-P differs from up arrow: it ignores autocomplete state.
            // Note that we preserve the default 'up' navigation within
            // multiline text.
            if (
              Services.appinfo.OS === "Darwin" &&
              this.props.editorMode === false &&
              this.canCaretGoPrevious() &&
              this.historyPeruse(HISTORY_BACK)
            ) {
              return null;
            }

            this.clearCompletion();
            return "CodeMirror.Pass";
          },

          PageUp: () => {
            if (this.autocompletePopup.isOpen) {
              this.autocompletePopup.selectPreviousPageItem();
            } else {
              const { outputScroller } = this.webConsoleUI;
              const { scrollTop, clientHeight } = outputScroller;
              outputScroller.scrollTop = Math.max(0, scrollTop - clientHeight);
            }

            return null;
          },

          PageDown: () => {
            if (this.autocompletePopup.isOpen) {
              this.autocompletePopup.selectNextPageItem();
            } else {
              const { outputScroller } = this.webConsoleUI;
              const { scrollTop, scrollHeight, clientHeight } = outputScroller;
              outputScroller.scrollTop = Math.min(
                scrollHeight,
                scrollTop + clientHeight
              );
            }

            return null;
          },

          Home: () => {
            if (this.autocompletePopup.isOpen) {
              this.autocompletePopup.selectItemAtIndex(0);
              return null;
            }

            if (!this._getValue()) {
              this.webConsoleUI.outputScroller.scrollTop = 0;
              return null;
            }

            if (this.getAutoCompletionText()) {
              this.clearCompletion();
            }

            return "CodeMirror.Pass";
          },

          End: () => {
            if (this.autocompletePopup.isOpen) {
              this.autocompletePopup.selectItemAtIndex(
                this.autocompletePopup.itemCount - 1
              );
              return null;
            }

            if (!this._getValue()) {
              const { outputScroller } = this.webConsoleUI;
              outputScroller.scrollTop = outputScroller.scrollHeight;
              return null;
            }

            if (this.getAutoCompletionText()) {
              this.clearCompletion();
            }

            return "CodeMirror.Pass";
          },

          "Ctrl-Space": () => {
            if (!this.autocompletePopup.isOpen) {
              this.props.autocompleteUpdate(
                true,
                null,
                this._getExpressionVariables()
              );
              return null;
            }

            return "CodeMirror.Pass";
          },

          Esc: false,
          // Don't handle Ctrl/Cmd + F so it can be listened by a parent node
          [Editor.accel("F")]: false,
        },
      });

      this.editor.on("changes", this._onEditorChanges);
      this.editor.on("beforeChange", this._onEditorBeforeChange);
      this.editor.on("blur", this._onEditorBlur);
      this.editor.on("keyHandled", this._onEditorKeyHandled);

      this.editor.appendToLocalElement(this.node);
      const cm = this.editor.codeMirror;
      cm.on("paste", (_, event) => this.props.onPaste(event));
      cm.on("drop", (_, event) => this.props.onPaste(event));

      this.#abortController = new AbortController();
      const signal = this.#abortController.signal;
      doc.addEventListener(
        "visibilitychange",
        () => {
          if (
            doc.visibilityState == "hidden" &&
            this.autocompletePopup.isOpen
          ) {
            this.autocompletePopup.hidePopup();
          }
        },
        { signal }
      );
      this.node.addEventListener(
        "keydown",
        event => {
          if (event.keyCode === KeyCodes.DOM_VK_ESCAPE) {
            if (this.autocompletePopup.isOpen) {
              this.clearCompletion();
              event.preventDefault();
              event.stopPropagation();
            }

            if (
              this.props.autocompleteData &&
              this.props.autocompleteData.getterPath
            ) {
              this.props.autocompleteClear();
              event.preventDefault();
              event.stopPropagation();
            }
          }
        },
        { signal }
      );

      this.resizeObserver = new ResizeObserver(() => {
        // If we don't have the node reference, or if the node isn't connected
        // anymore, we disconnect the resize observer (componentWillUnmount is never
        // called on this component, so we have to do it here).
        if (!this.node || !this.node.isConnected) {
          this.resizeObserver.disconnect();
          return;
        }
        // Calling `refresh` will update the cursor position, and all the selection blocks.
        this.editor.codeMirror.refresh();
      });
      this.resizeObserver.observe(this.node);

      // Update the character width needed for the popup offset calculations.
      this._inputCharWidth = this._getInputCharWidth();
      this.lastInputValue && this._setValue(this.lastInputValue);
    }
  }

  // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
  UNSAFE_componentWillReceiveProps(nextProps) {
    this.imperativeUpdate(nextProps);
  }

  shouldComponentUpdate(nextProps) {
    return (
      this.props.showEditorOnboarding !== nextProps.showEditorOnboarding ||
      this.props.editorMode !== nextProps.editorMode
    );
  }

  // AbortController to cancel all event listener on destroy.
  #abortController = null;

  /**
   * Do all the imperative work needed after a Redux store update.
   *
   * @param {object} nextProps: props passed from shouldComponentUpdate.
   */
  imperativeUpdate(nextProps) {
    if (!nextProps) {
      return;
    }

    if (
      nextProps.autocompleteData !== this.props.autocompleteData &&
      nextProps.autocompleteData.pendingRequestId === null
    ) {
      this.updateAutocompletionPopup(nextProps.autocompleteData);
    }

    if (nextProps.editorMode !== this.props.editorMode) {
      if (this.editor) {
        this.editor.setOption("lineNumbers", nextProps.editorMode);
        this.editor.setOption("enableCodeFolding", nextProps.editorMode);
      }

      if (nextProps.editorMode && nextProps.editorWidth) {
        this.setEditorWidth(nextProps.editorWidth);
      } else {
        this.setEditorWidth(null);
      }

      if (this.autocompletePopup.isOpen) {
        this.autocompletePopup.hidePopup();
      }
    }

    if (
      nextProps.autocompletePopupPosition !==
        this.props.autocompletePopupPosition &&
      this.autocompletePopup
    ) {
      this.autocompletePopup.position = nextProps.autocompletePopupPosition;
    }

    if (
      nextProps.editorPrettifiedAt &&
      nextProps.editorPrettifiedAt !== this.props.editorPrettifiedAt
    ) {
      this._setValue(
        beautify.js(this._getValue(), {
          // Read directly from prefs because this.editor.config.indentUnit and
          // this.editor.getOption('indentUnit') are not really synced with
          // prefs.
          indent_size: Services.prefs.getIntPref("devtools.editor.tabsize"),
          indent_with_tabs: !Services.prefs.getBoolPref(
            "devtools.editor.expandtab"
          ),
        })
      );
    }
  }

  /**
   *
   * @param {number | null} editorWidth: The width to set the node to. If null, removes any
   *                                   `width` property on node style.
   */
  setEditorWidth(editorWidth) {
    if (!this.node) {
      return;
    }

    if (editorWidth) {
      this.node.style.width = `${editorWidth}px`;
    } else {
      this.node.style.removeProperty("width");
    }
  }

  focus() {
    if (this.editor) {
      this.editor.focus();
    }
  }

  focusPreviousElement() {
    const inputField = this.editor.codeMirror.getInputField();

    const findPreviousFocusableElement = el => {
      if (!el || !el.querySelectorAll) {
        return null;
      }

      // We only want to get visible focusable element, and for that we can assert that
      // the offsetParent isn't null. We can do that because we don't have fixed position
      // element in the console.
      const items = lazy
        .getFocusableElements(el)
        .filter(({ offsetParent }) => offsetParent !== null);
      const inputIndex = items.indexOf(inputField);

      if (items.length === 0 || (inputIndex > -1 && items.length === 1)) {
        return findPreviousFocusableElement(el.parentNode);
      }

      const index = inputIndex > 0 ? inputIndex - 1 : items.length - 1;
      return items[index];
    };

    const focusableEl = findPreviousFocusableElement(this.node.parentNode);
    if (focusableEl) {
      focusableEl.focus();
    }
  }

  /**
   * Execute a string. Execution happens asynchronously in the content process.
   */
  _execute() {
    const value = this._getValue();
    // In editor mode, we only evaluate the text selection if there's one. The feature isn't
    // enabled in inline mode as it can be confusing since input is cleared when evaluating.
    const executeString = this.props.editorMode
      ? this.getSelectedText() || value
      : value;

    if (!executeString) {
      return;
    }

    if (!this.props.editorMode) {
      // Calling this.props.terminalInputChanged instead of this.terminalInputChanged
      // because we want to instantly hide the instant evaluation result, and don't want
      // the delay we have in this.terminalInputChanged.
      this.props.terminalInputChanged("");
      this._setValue("");
    }
    this.clearCompletion();
    this.props.evaluateExpression(executeString);
  }

  /**
   * Sets the value of the input field.
   *
   * @param string newValue
   *        The new value to set.
   * @returns void
   */
  _setValue(newValue = "") {
    this.lastInputValue = newValue;
    this.terminalInputChanged(newValue);

    if (this.editor) {
      // In order to get the autocomplete popup to work properly, we need to set the
      // editor text and the cursor in the same operation. If we don't, the text change
      // is done before the cursor is moved, and the autocompletion call to the server
      // sends an erroneous query.
      this.editor.codeMirror.operation(() => {
        this.editor.setText(newValue);

        // Set the cursor at the end of the input.
        const lines = newValue.split("\n");
        this.editor.setCursor({
          line: lines.length - 1,
          ch: lines[lines.length - 1].length,
        });
        this.editor.setAutoCompletionText();
      });
    }

    this.emitForTests("set-input-value");
  }

  /**
   * Gets the value from the input field
   *
   * @returns string
   */
  _getValue() {
    return this.editor ? this.editor.getText() || "" : "";
  }

  /**
   * Open the file picker for the user to select a javascript file and open it.
   *
   */
  async _openFile() {
    const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    fp.init(
      this.webConsoleUI.document.defaultView.browsingContext,
      l10n.getStr("webconsole.input.openJavaScriptFile"),
      Ci.nsIFilePicker.modeOpen
    );

    // Append file filters
    fp.appendFilter(
      l10n.getStr("webconsole.input.openJavaScriptFileFilter"),
      "*.js"
    );

    function readFile(file) {
      return new Promise(resolve => {
        IOUtils.read(file.path).then(data => {
          const decoder = new TextDecoder();
          resolve(decoder.decode(data));
        });
      });
    }

    const content = await new Promise(resolve => {
      fp.open(rv => {
        if (rv == Ci.nsIFilePicker.returnOK) {
          const file = Cc["@mozilla.org/file/local;1"].createInstance(
            Ci.nsIFile
          );
          file.initWithPath(fp.file.path);
          readFile(file).then(resolve);
        }
      });
    });

    this._setValue(content);
  }

  getSelectionStart() {
    return this.getInputValueBeforeCursor().length;
  }

  getSelectedText() {
    return this.editor.getSelection();
  }

  /**
   * Even handler for the "beforeChange" event fired by codeMirror. This event is fired
   * when codeMirror is about to make a change to its DOM representation.
   */
  _onEditorBeforeChange(cm, change) {
    // If the user did not type a character that matches the completion text, then we
    // clear it before the change is done to prevent a visual glitch.
    // See Bugs 1491776 & 1558248.
    const { from, to, origin, text } = change;
    const isAddedText =
      from.line === to.line && from.ch === to.ch && origin === "+input";

    // if there was no changes (hitting delete on an empty input, or suppr when at the end
    // of the input), we bail out.
    if (
      !isAddedText &&
      origin === "+delete" &&
      from.line === to.line &&
      from.ch === to.ch
    ) {
      return;
    }

    const addedText = text.join("");
    const completionText = this.getAutoCompletionText();

    const addedCharacterMatchCompletion =
      isAddedText && completionText.startsWith(addedText);

    const addedCharacterMatchPopupItem =
      isAddedText &&
      this.autocompletePopup.items.some(({ preLabel, label }) =>
        label.startsWith(preLabel + addedText)
      );
    const nextSelectedAutocompleteItemIndex =
      addedCharacterMatchPopupItem &&
      this.autocompletePopup.items.findIndex(({ preLabel, label }) =>
        label.startsWith(preLabel + addedText)
      );

    if (addedCharacterMatchPopupItem) {
      this.autocompletePopup.selectItemAtIndex(
        nextSelectedAutocompleteItemIndex,
        { preventSelectCallback: true }
      );
    }

    if (!completionText || change.canceled || !addedCharacterMatchCompletion) {
      this.setAutoCompletionText("");
    }

    if (!addedCharacterMatchCompletion && !addedCharacterMatchPopupItem) {
      this.autocompletePopup.hidePopup();
    } else if (
      !change.canceled &&
      (completionText ||
        addedCharacterMatchCompletion ||
        addedCharacterMatchPopupItem)
    ) {
      // The completion text will be updated when the debounced autocomplete update action
      // is done, so in the meantime we set the pending value to pendingCompletionText.
      // See Bug 1595068 for more information.
      this.pendingCompletionText = completionText.substring(text.length);
      // And we update the preLabel of the matching autocomplete items that may be used
      // in the acceptProposedAutocompletion function.
      this.autocompletePopup.items.forEach(item => {
        if (item.label.startsWith(item.preLabel + addedText)) {
          item.preLabel += addedText;
        }
      });
    }
  }

  /**
   * Even handler for the "blur" event fired by codeMirror.
   */
  _onEditorBlur(cm) {
    if (cm.somethingSelected()) {
      // If there's a selection when the input is blurred, then we remove it by setting
      // the cursor at the position that matches the start of the first selection.
      const [{ head }] = cm.listSelections();
      cm.setCursor(head, { scroll: false });
    }
  }

  /**
   * Fired after a key is handled through a key map.
   *
   * @param {CodeMirror} cm: codeMirror instance
   * @param {string} key: The key that was handled
   */
  _onEditorKeyHandled(cm, key) {
    // The autocloseBracket addon handle closing brackets keys when they're typed, but
    // there's already an existing closing bracket.
    // ex:
    //  1. input is `foo(x|)` (where | represents the cursor)
    //  2. user types `)`
    //  3. input is now `foo(x)|` (i.e. the typed character wasn't inserted)
    // In such case, _onEditorBeforeChange isn't triggered, so we need to hide the popup
    // here. We can do that because this function won't be called when codeMirror _do_
    // insert the closing char.
    const closingKeys = [`']'`, `')'`, "'}'"];
    if (this.autocompletePopup.isOpen && closingKeys.includes(key)) {
      this.clearCompletion();
    }
  }

  /**
   * Retrieve variable declared in the expression from the CodeMirror state, in order
   * to display them in the autocomplete popup.
   */
  _getExpressionVariables() {
    const cm = this.editor.codeMirror;
    const { state } = cm.getTokenAt(cm.getCursor());
    const variables = [];

    if (state.context) {
      for (let c = state.context; c; c = c.prev) {
        for (let v = c.vars; v; v = v.next) {
          if (v.name) {
            variables.push(v.name);
          }
        }
      }
    }

    const keys = ["localVars", "globalVars"];
    for (const key of keys) {
      if (state[key]) {
        for (let v = state[key]; v; v = v.next) {
          if (v.name) {
            variables.push(v.name);
          }
        }
      }
    }

    return variables;
  }

  /**
   * The editor "changes" event handler.
   */
  _onEditorChanges(cm, changes) {
    const value = this._getValue();

    if (this.lastInputValue !== value) {
      // We don't autocomplete if the changes were made by JsTerm (e.g. autocomplete was
      // accepted).
      const isJsTermChangeOnly = changes.every(
        ({ origin }) => origin === JSTERM_CODEMIRROR_ORIGIN
      );

      if (
        !isJsTermChangeOnly &&
        (this.props.autocomplete || this.hasAutocompletionSuggestion())
      ) {
        this.autocompleteUpdate(false, null, this._getExpressionVariables());
      }
      this.lastInputValue = value;
      this.terminalInputChanged(value);
    }
  }

  /**
   * Go up/down the history stack of input values.
   *
   * @param number direction
   *        History navigation direction: HISTORY_BACK or HISTORY_FORWARD.
   *
   * @returns boolean
   *          True if the input value changed, false otherwise.
   */
  historyPeruse(direction) {
    const { history, updateHistoryPosition, getValueFromHistory } = this.props;

    if (!history.entries.length) {
      return false;
    }

    const newInputValue = getValueFromHistory(direction);
    const expression = this._getValue();
    updateHistoryPosition(direction, expression);

    if (newInputValue != null) {
      this._setValue(newInputValue);
      return true;
    }

    return false;
  }

  /**
   * Test for empty input.
   *
   * @return boolean
   */
  hasEmptyInput() {
    return this._getValue() === "";
  }

  /**
   * Check if the caret is at a location that allows selecting the previous item
   * in history when the user presses the Up arrow key.
   *
   * @return boolean
   *         True if the caret is at a location that allows selecting the
   *         previous item in history when the user presses the Up arrow key,
   *         otherwise false.
   */
  canCaretGoPrevious() {
    if (!this.editor) {
      return false;
    }

    const inputValue = this._getValue();
    const { line, ch } = this.editor.getCursor();
    return (line === 0 && ch === 0) || (line === 0 && ch === inputValue.length);
  }

  /**
   * Check if the caret is at a location that allows selecting the next item in
   * history when the user presses the Down arrow key.
   *
   * @return boolean
   *         True if the caret is at a location that allows selecting the next
   *         item in history when the user presses the Down arrow key, otherwise
   *         false.
   */
  canCaretGoNext() {
    if (!this.editor) {
      return false;
    }

    const inputValue = this._getValue();
    const multiline = /[\r\n]/.test(inputValue);

    const { line, ch } = this.editor.getCursor();
    return (
      (!multiline && ch === 0) ||
      this.editor.getDoc().getRange({ line: 0, ch: 0 }, { line, ch }).length ===
        inputValue.length
    );
  }

  /**
   * Takes the data returned by the server and update the autocomplete popup state (i.e.
   * its visibility and items).
   *
   * @param {object} data
   *        The autocompletion data as returned by the webconsole actor's autocomplete
   *        service. Should be of the following shape:
   *        {
   *          matches: {Array} array of the properties matching the input,
   *          matchProp: {String} The string used to filter the properties,
   *          isElementAccess: {Boolean} True when the input is an element access,
   *                           i.e. `document["addEve`.
   *        }
   * @fires autocomplete-updated
   */
  async updateAutocompletionPopup(data) {
    if (!this.editor) {
      return;
    }

    const { matches, matchProp, isElementAccess } = data;
    if (!matches.length) {
      this.clearCompletion();
      return;
    }

    const inputUntilCursor = this.getInputValueBeforeCursor();

    const items = matches.map(label => {
      let preLabel = label.substring(0, matchProp.length);
      // If the user is performing an element access, and if they did not typed a quote,
      // then we need to adjust the preLabel to match the quote from the label + what
      // the user entered.
      if (isElementAccess && /^['"`]/.test(matchProp) === false) {
        preLabel = label.substring(0, matchProp.length + 1);
      }
      return { preLabel, label, isElementAccess };
    });

    if (items.length) {
      const { preLabel, label } = items[0];
      let suffix = label.substring(preLabel.length);
      if (isElementAccess) {
        if (!matchProp) {
          suffix = label;
        }
        const inputAfterCursor = this._getValue().substring(
          inputUntilCursor.length
        );
        // If there's not a bracket after the cursor, add it to the completionText.
        if (!inputAfterCursor.trimLeft().startsWith("]")) {
          suffix = suffix + "]";
        }
      }
      this.setAutoCompletionText(suffix);
    }

    const popup = this.autocompletePopup;
    // We don't want to trigger the onSelect callback since we already set the completion
    // text a few lines above.
    popup.setItems(items, 0, {
      preventSelectCallback: true,
    });

    const minimumAutoCompleteLength = 2;

    // We want to show the autocomplete popup if:
    // - there are at least 2 matching results
    // - OR, if there's 1 result, but whose label does not start like the input (this can
    //   happen with insensitive search: `num` will match `Number`).
    // - OR, if there's 1 result, but we can't show the completionText (because there's
    // some text after the cursor), unless the text in the popup is the same as the input.
    if (
      items.length >= minimumAutoCompleteLength ||
      (items.length === 1 && items[0].preLabel !== matchProp) ||
      (items.length === 1 &&
        !this.canDisplayAutoCompletionText() &&
        items[0].label !== matchProp)
    ) {
      // We need to show the popup at the "." or "[".
      const xOffset = -1 * matchProp.length * this._inputCharWidth;
      const yOffset = 5;
      const popupAlignElement =
        this.props.serviceContainer.getJsTermTooltipAnchor();
      this._openPopupPendingPromise = popup.openPopup(
        popupAlignElement,
        xOffset,
        yOffset,
        0,
        {
          preventSelectCallback: true,
        }
      );
      await this._openPopupPendingPromise;
      this._openPopupPendingPromise = null;
    } else if (
      items.length < minimumAutoCompleteLength &&
      (popup.isOpen || this._openPopupPendingPromise)
    ) {
      if (this._openPopupPendingPromise) {
        await this._openPopupPendingPromise;
      }
      popup.hidePopup();
    }

    // Eager evaluation results incorporate the current autocomplete item. We need to
    // trigger it here as well as in onAutocompleteSelect as we set the items with
    // preventSelectCallback (which means we won't trigger onAutocompleteSelect when the
    // popup is open).
    this.terminalInputChanged(
      this.getInputValueWithCompletionText().expression
    );

    this.emit("autocomplete-updated");
  }

  onAutocompleteSelect() {
    const { selectedItem } = this.autocompletePopup;
    if (selectedItem) {
      const { preLabel, label, isElementAccess } = selectedItem;
      let suffix = label.substring(preLabel.length);

      // If the user is performing an element access, we need to check if we should add
      // starting and ending quotes, as well as a closing bracket.
      if (isElementAccess) {
        const inputBeforeCursor = this.getInputValueBeforeCursor();
        if (inputBeforeCursor.trim().endsWith("[")) {
          suffix = label;
        }

        const inputAfterCursor = this._getValue().substring(
          inputBeforeCursor.length
        );
        // If there's no closing bracket after the cursor, add it to the completionText.
        if (!inputAfterCursor.trimLeft().startsWith("]")) {
          suffix = suffix + "]";
        }
      }
      this.setAutoCompletionText(suffix);
    } else {
      this.setAutoCompletionText("");
    }
    // Eager evaluation results incorporate the current autocomplete item.
    this.terminalInputChanged(
      this.getInputValueWithCompletionText().expression
    );
  }

  /**
   * Clear the current completion information, cancel any pending autocompletion update
   * and close the autocomplete popup, if needed.
   *
   * @fires autocomplete-updated
   */
  clearCompletion() {
    this.autocompleteUpdate.cancel();
    // Update Eager evaluation result as the completion text was removed.
    this.terminalInputChanged(this._getValue());

    this.setAutoCompletionText("");
    let onPopupClosed = Promise.resolve();
    if (this.autocompletePopup) {
      this.autocompletePopup.clearItems();

      if (this.autocompletePopup.isOpen || this._openPopupPendingPromise) {
        onPopupClosed = this.autocompletePopup.once("popup-closed");

        if (this._openPopupPendingPromise) {
          this._openPopupPendingPromise.then(() =>
            this.autocompletePopup.hidePopup()
          );
        } else {
          this.autocompletePopup.hidePopup();
        }
        onPopupClosed.then(() => this.focus());
      }
    }
    onPopupClosed.then(() => this.emit("autocomplete-updated"));
  }

  /**
   * Accept the proposed input completion.
   */
  acceptProposedCompletion() {
    const {
      completionText,
      numberOfCharsToMoveTheCursorForward,
      numberOfCharsToReplaceCharsBeforeCursor,
    } = this.getInputValueWithCompletionText();

    this.autocompleteUpdate.cancel();
    this.props.autocompleteClear();

    // If the code triggering the opening of the popup was already triggered but not yet
    // settled, then we need to wait until it's resolved in order to close the popup (See
    // Bug 1655406).
    if (this._openPopupPendingPromise) {
      this._openPopupPendingPromise.then(() =>
        this.autocompletePopup.hidePopup()
      );
    }

    if (completionText) {
      this.insertStringAtCursor(
        completionText,
        numberOfCharsToReplaceCharsBeforeCursor
      );

      if (numberOfCharsToMoveTheCursorForward) {
        const { line, ch } = this.editor.getCursor();
        this.editor.setCursor({
          line,
          ch: ch + numberOfCharsToMoveTheCursorForward,
        });
      }
    }
  }

  /**
   * Returns an object containing the expression we would get if the user accepted the
   * current completion text. This is more than the current input + the completion text,
   * as there are special cases for element access and case-insensitive matches.
   *
   * @return {object}: An object of the following shape:
   *         - {String} expression: The complete expression
   *         - {String} completionText: the completion text only, which should be used
   *                    with the next property
   *         - {Integer} numberOfCharsToReplaceCharsBeforeCursor: The number of chars that
   *                     should be removed from the current input before the cursor to
   *                     cleanly apply the completionText. This is handy when we only want
   *                     to insert the completionText.
   *         - {Integer} numberOfCharsToMoveTheCursorForward: The number of chars that the
   *                     cursor should be moved after the completion is done. This can
   *                     be useful for element access where there's already a closing
   *                     quote and/or bracket.
   */
  getInputValueWithCompletionText() {
    const inputBeforeCursor = this.getInputValueBeforeCursor();
    const inputAfterCursor = this._getValue().substring(
      inputBeforeCursor.length
    );
    let completionText = this.getAutoCompletionText();
    let numberOfCharsToReplaceCharsBeforeCursor;
    let numberOfCharsToMoveTheCursorForward = 0;

    // If the autocompletion popup is open, we always get the selected element from there,
    // since the autocompletion text might not be enough (e.g. `dOcUmEn` should
    // autocomplete to `document`, but the autocompletion text only shows `t`).
    if (this.autocompletePopup.isOpen && this.autocompletePopup.selectedItem) {
      const { selectedItem } = this.autocompletePopup;
      const { label, preLabel, isElementAccess } = selectedItem;

      completionText = label;
      numberOfCharsToReplaceCharsBeforeCursor = preLabel.length;

      // If the user is performing an element access, we need to check if we should add
      // starting and ending quotes, as well as a closing bracket.
      if (isElementAccess) {
        const lastOpeningBracketIndex = inputBeforeCursor.lastIndexOf("[");
        if (lastOpeningBracketIndex > -1) {
          numberOfCharsToReplaceCharsBeforeCursor = inputBeforeCursor.substring(
            lastOpeningBracketIndex + 1
          ).length;
        }

        // If the autoclose bracket option is enabled, the input might be in a state where
        // there's already the closing quote and the closing bracket, e.g.
        // `document["activeEl|"]`, so we don't need to add
        // Let's retrieve the completionText last character, to see if it's a quote.
        const completionTextLastChar =
          completionText[completionText.length - 1];
        const endingQuote = [`"`, `'`, "`"].includes(completionTextLastChar)
          ? completionTextLastChar
          : "";
        if (
          endingQuote &&
          inputAfterCursor.trimLeft().startsWith(endingQuote)
        ) {
          completionText = completionText.substring(
            0,
            completionText.length - 1
          );
          numberOfCharsToMoveTheCursorForward++;
        }

        // If there's not a closing bracket already, we add one.
        if (
          !inputAfterCursor.trimLeft().match(new RegExp(`^${endingQuote}?]`))
        ) {
          completionText = completionText + "]";
        } else {
          // if there's already one, we want to move the cursor after the closing bracket.
          numberOfCharsToMoveTheCursorForward++;
        }
      }
    }

    const expression =
      inputBeforeCursor.substring(
        0,
        inputBeforeCursor.length -
          (numberOfCharsToReplaceCharsBeforeCursor || 0)
      ) +
      completionText +
      inputAfterCursor;

    return {
      completionText,
      expression,
      numberOfCharsToMoveTheCursorForward,
      numberOfCharsToReplaceCharsBeforeCursor,
    };
  }

  getInputValueBeforeCursor() {
    return this.editor
      ? this.editor
          .getDoc()
          .getRange({ line: 0, ch: 0 }, this.editor.getCursor())
      : null;
  }

  /**
   * Insert a string into the console at the cursor location,
   * moving the cursor to the end of the string.
   *
   * @param {string} str
   * @param {int} numberOfCharsToReplaceCharsBeforeCursor - defaults to 0
   */
  insertStringAtCursor(str, numberOfCharsToReplaceCharsBeforeCursor = 0) {
    if (!this.editor) {
      return;
    }

    const cursor = this.editor.getCursor();
    const from = {
      line: cursor.line,
      ch: cursor.ch - numberOfCharsToReplaceCharsBeforeCursor,
    };

    this.editor
      .getDoc()
      .replaceRange(str, from, cursor, JSTERM_CODEMIRROR_ORIGIN);
  }

  /**
   * Set the autocompletion text of the input.
   *
   * @param string suffix
   *        The proposed suffix for the input value.
   */
  setAutoCompletionText(suffix) {
    if (!this.editor) {
      return;
    }

    this.pendingCompletionText = null;

    if (suffix && !this.canDisplayAutoCompletionText()) {
      suffix = "";
    }

    this.editor.setAutoCompletionText(suffix);
  }

  getAutoCompletionText() {
    const renderedCompletionText =
      this.editor && this.editor.getAutoCompletionText();
    return typeof this.pendingCompletionText === "string"
      ? this.pendingCompletionText
      : renderedCompletionText;
  }

  /**
   * Indicate if the input has an autocompletion suggestion, i.e. that there is either
   * something in the autocompletion text or that there's a selected item in the
   * autocomplete popup.
   */
  hasAutocompletionSuggestion() {
    // We can have cases where the popup is opened but we can't display the autocompletion
    // text.
    return (
      this.getAutoCompletionText() ||
      (this.autocompletePopup.isOpen &&
        Number.isInteger(this.autocompletePopup.selectedIndex) &&
        this.autocompletePopup.selectedIndex > -1)
    );
  }

  /**
   * Returns a boolean indicating if we can display an autocompletion text in the input,
   * i.e. if there is no characters displayed on the same line of the cursor and after it.
   */
  canDisplayAutoCompletionText() {
    if (!this.editor) {
      return false;
    }

    const { ch, line } = this.editor.getCursor();
    const lineContent = this.editor.getLine(line);
    const textAfterCursor = lineContent.substring(ch);
    return textAfterCursor === "";
  }

  /**
   * Calculates and returns the width of a single character of the input box.
   * This will be used in opening the popup at the correct offset.
   *
   * @returns {number | null}: Width off the "x" char, or null if the input does not exist.
   */
  _getInputCharWidth() {
    return this.editor ? this.editor.defaultCharWidth() : null;
  }

  onContextMenu(e) {
    this.props.serviceContainer.openEditContextMenu(e);
  }

  destroy() {
    this.autocompleteUpdate.cancel();
    this.terminalInputChanged.cancel();
    this._openPopupPendingPromise = null;

    if (this.autocompletePopup) {
      this.autocompletePopup.destroy();
      this.autocompletePopup = null;
    }

    if (this.#abortController) {
      this.#abortController.abort();
      this.#abortController = null;
    }

    if (this.editor) {
      this.resizeObserver.disconnect();
      this.editor.destroy();
      this.editor = null;
    }

    this.webConsoleUI = null;
  }

  renderOpenEditorButton() {
    if (this.props.editorMode) {
      return null;
    }

    return dom.button({
      className:
        "devtools-button webconsole-input-openEditorButton" +
        (this.props.showEditorOnboarding ? " devtools-feature-callout" : ""),
      title: l10n.getFormatStr("webconsole.input.openEditorButton.tooltip2", [
        isMacOS ? "Cmd + B" : "Ctrl + B",
      ]),
      onClick: this.props.editorToggle,
    });
  }

  renderEvaluationContextSelector() {
    if (this.props.editorMode) {
      return null;
    }

    return EvaluationContextSelector(this.props);
  }

  renderEditorOnboarding() {
    if (!this.props.showEditorOnboarding) {
      return null;
    }

    // We deliberately use getStr, and not getFormatStr, because we want keyboard
    // shortcuts to be wrapped in their own span.
    const label = l10n.getStr("webconsole.input.editor.onboarding.label");
    let [prefix, suffix] = label.split("%1$S");
    suffix = suffix.split("%2$S");

    const enterString = l10n.getStr("webconsole.enterKey");

    return dom.header(
      { className: "editor-onboarding" },
      dom.img({
        className: "editor-onboarding-fox",
        src: "chrome://devtools/skin/images/fox-smiling.svg",
      }),
      dom.p(
        {},
        prefix,
        dom.span({ className: "editor-onboarding-shortcut" }, enterString),
        suffix[0],
        dom.span({ className: "editor-onboarding-shortcut" }, [
          isMacOS ? `Cmd+${enterString}` : `Ctrl+${enterString}`,
        ]),
        suffix[1]
      ),
      dom.button(
        {
          className: "editor-onboarding-dismiss-button",
          onClick: () => this.props.editorOnboardingDismiss(),
        },
        l10n.getStr("webconsole.input.editor.onboarding.dismiss.label")
      )
    );
  }

  render() {
    if (!this.props.inputEnabled) {
      return null;
    }

    return dom.div(
      {
        className: "jsterm-input-container devtools-input",
        key: "jsterm-container",
        "aria-live": "off",
        tabIndex: -1,
        onContextMenu: this.onContextMenu,
        ref: node => {
          this.node = node;
        },
      },
      dom.div(
        { className: "webconsole-input-buttons" },
        this.renderEvaluationContextSelector(),
        this.renderOpenEditorButton()
      ),
      this.renderEditorOnboarding()
    );
  }
}

// Redux connect

function mapStateToProps(state) {
  return {
    history: getHistory(state),
    getValueFromHistory: direction => getHistoryValue(state, direction),
    autocompleteData: getAutocompleteState(state),
    showEditorOnboarding: state.ui.showEditorOnboarding,
    autocompletePopupPosition: state.prefs.eagerEvaluation ? "top" : "bottom",
    editorPrettifiedAt: state.ui.editorPrettifiedAt,
  };
}

function mapDispatchToProps(dispatch) {
  return {
    updateHistoryPosition: (direction, expression) =>
      dispatch(actions.updateHistoryPosition(direction, expression)),
    autocompleteUpdate: (force, getterPath, expressionVars) =>
      dispatch(actions.autocompleteUpdate(force, getterPath, expressionVars)),
    autocompleteClear: () => dispatch(actions.autocompleteClear()),
    evaluateExpression: expression =>
      dispatch(actions.evaluateExpression(expression)),
    editorToggle: () => dispatch(actions.editorToggle()),
    editorOnboardingDismiss: () => dispatch(actions.editorOnboardingDismiss()),
    terminalInputChanged: value =>
      dispatch(actions.terminalInputChanged(value)),
  };
}

module.exports = connect(mapStateToProps, mapDispatchToProps)(JSTerm);
