/* 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 AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js");

loader.lazyRequireGetter(
  this,
  "KeyCodes",
  "resource://devtools/client/shared/keycodes.js",
  true
);
loader.lazyRequireGetter(
  this,
  "CSSCompleter",
  "resource://devtools/client/shared/sourceeditor/css-autocompleter.js"
);

const autocompleteMap = new WeakMap();

/**
 * Prepares an editor instance for autocompletion.
 */
function initializeAutoCompletion(ctx, options = {}) {
  const { cm, ed, Editor } = ctx;
  if (autocompleteMap.has(ed)) {
    return;
  }

  const win = ed.container.contentWindow.wrappedJSObject;
  const { CodeMirror } = win;

  let completer = null;
  const autocompleteKey =
    "Ctrl-" + Editor.keyFor("autocompletion", { noaccel: true });
  if (ed.config.mode == Editor.modes.css) {
    completer = new CSSCompleter({
      walker: options.walker,
      cssProperties: options.cssProperties,
    });
  }

  function insertSelectedPopupItem() {
    const autocompleteState = autocompleteMap.get(ed);
    if (!popup || !popup.isOpen || !autocompleteState) {
      return false;
    }

    if (!autocompleteState.suggestionInsertedOnce && popup.selectedItem) {
      autocompleteMap.get(ed).insertingSuggestion = true;
      insertPopupItem(ed, popup.selectedItem);
    }

    popup.once("popup-closed", () => {
      // This event is used in tests.
      ed.emit("popup-hidden");
    });
    popup.hidePopup();
    return true;
  }

  // Give each popup a new name to avoid sharing the elements.

  let popup = new AutocompletePopup(win.parent.document, {
    position: "bottom",
    autoSelect: true,
    onClick: insertSelectedPopupItem,
  });

  const cycle = reverse => {
    if (popup?.isOpen) {
      // eslint-disable-next-line mozilla/no-compare-against-boolean-literals
      cycleSuggestions(ed, reverse == true);
      return null;
    }

    return CodeMirror.Pass;
  };

  let keyMap = {
    Tab: cycle,
    Down: cycle,
    "Shift-Tab": cycle.bind(null, true),
    Up: cycle.bind(null, true),
    Enter: () => {
      const wasHandled = insertSelectedPopupItem();
      return wasHandled ? true : CodeMirror.Pass;
    },
  };

  const autoCompleteCallback = autoComplete.bind(null, ctx);
  const keypressCallback = onEditorKeypress.bind(null, ctx);
  keyMap[autocompleteKey] = autoCompleteCallback;
  cm.addKeyMap(keyMap);

  cm.on("keydown", keypressCallback);
  ed.on("change", autoCompleteCallback);
  ed.on("destroy", destroy);

  function destroy() {
    ed.off("destroy", destroy);
    cm.off("keydown", keypressCallback);
    ed.off("change", autoCompleteCallback);
    cm.removeKeyMap(keyMap);
    popup.destroy();
    keyMap = popup = completer = null;
    autocompleteMap.delete(ed);
  }

  autocompleteMap.set(ed, {
    popup,
    completer,
    keyMap,
    destroy,
    insertingSuggestion: false,
    suggestionInsertedOnce: false,
  });
}

/**
 * Destroy autocompletion on an editor instance.
 */
function destroyAutoCompletion(ctx) {
  const { ed } = ctx;
  if (!autocompleteMap.has(ed)) {
    return;
  }

  const { destroy } = autocompleteMap.get(ed);
  destroy();
}

/**
 * Provides suggestions to autocomplete the current token/word being typed.
 */
function autoComplete({ ed, cm }) {
  const autocompleteOpts = autocompleteMap.get(ed);
  const { completer, popup } = autocompleteOpts;
  if (
    !completer ||
    autocompleteOpts.insertingSuggestion ||
    autocompleteOpts.doNotAutocomplete
  ) {
    autocompleteOpts.insertingSuggestion = false;
    return;
  }
  const cur = ed.getCursor();
  completer
    .complete(cm.getRange({ line: 0, ch: 0 }, cur), cur)
    .then(suggestions => {
      if (
        !suggestions ||
        !suggestions.length ||
        suggestions[0].preLabel == null
      ) {
        autocompleteOpts.suggestionInsertedOnce = false;
        popup.once("popup-closed", () => {
          // This event is used in tests.
          ed.emit("after-suggest");
        });
        popup.hidePopup();
        return;
      }
      // The cursor is at the end of the currently entered part of the token,
      // like "backgr|" but we need to open the popup at the beginning of the
      // character "b". Thus we need to calculate the width of the entered part
      // of the token ("backgr" here).

      const cursorElement =
        cm.display.cursorDiv.querySelector(".CodeMirror-cursor");
      const left = suggestions[0].preLabel.length * cm.defaultCharWidth();
      popup.hidePopup();
      popup.setItems(suggestions);

      popup.once("popup-opened", () => {
        // This event is used in tests.
        ed.emit("after-suggest");
      });
      popup.openPopup(cursorElement, -1 * left, 0);
      autocompleteOpts.suggestionInsertedOnce = false;
    })
    .catch(console.error);
}

/**
 * Inserts a popup item into the current cursor location
 * in the editor.
 */
function insertPopupItem(ed, popupItem) {
  const { preLabel, text } = popupItem;
  const cur = ed.getCursor();
  const textBeforeCursor = ed.getText(cur.line).substring(0, cur.ch);
  const backwardsTextBeforeCursor = textBeforeCursor
    .split("")
    .reverse()
    .join("");
  const backwardsPreLabel = preLabel.split("").reverse().join("");

  // If there is additional text in the preLabel vs the line, then
  // just insert the entire autocomplete text.  An example:
  // if you type 'a' and select '#about' from the autocomplete menu,
  // then the final text needs to the end up as '#about'.
  if (backwardsPreLabel.indexOf(backwardsTextBeforeCursor) === 0) {
    ed.replaceText(text, { line: cur.line, ch: 0 }, cur);
  } else {
    ed.replaceText(text.slice(preLabel.length), cur, cur);
  }
}

/**
 * Cycles through provided suggestions by the popup in a top to bottom manner
 * when `reverse` is not true. Opposite otherwise.
 */
function cycleSuggestions(ed, reverse) {
  const autocompleteOpts = autocompleteMap.get(ed);
  const { popup } = autocompleteOpts;
  const cur = ed.getCursor();
  autocompleteOpts.insertingSuggestion = true;
  if (!autocompleteOpts.suggestionInsertedOnce) {
    autocompleteOpts.suggestionInsertedOnce = true;
    let firstItem;
    if (reverse) {
      firstItem = popup.getItemAtIndex(popup.itemCount - 1);
      popup.selectPreviousItem();
    } else {
      firstItem = popup.getItemAtIndex(0);
      if (firstItem.label == firstItem.preLabel && popup.itemCount > 1) {
        firstItem = popup.getItemAtIndex(1);
        popup.selectNextItem();
      }
    }
    if (popup.itemCount == 1) {
      popup.hidePopup();
    }
    insertPopupItem(ed, firstItem);
  } else {
    const fromCur = {
      line: cur.line,
      ch: cur.ch - popup.selectedItem.text.length,
    };
    if (reverse) {
      popup.selectPreviousItem();
    } else {
      popup.selectNextItem();
    }
    ed.replaceText(popup.selectedItem.text, fromCur, cur);
  }
  // This event is used in tests.
  ed.emit("suggestion-entered");
}

/**
 * onkeydown handler for the editor instance to prevent autocompleting on some
 * keypresses.
 */
function onEditorKeypress({ ed, Editor }, cm, event) {
  const autocompleteOpts = autocompleteMap.get(ed);

  // Do not try to autocomplete with multiple selections.
  if (ed.hasMultipleSelections()) {
    autocompleteOpts.doNotAutocomplete = true;
    autocompleteOpts.popup.hidePopup();
    return;
  }

  if (
    (event.ctrlKey || event.metaKey) &&
    event.keyCode == KeyCodes.DOM_VK_SPACE
  ) {
    // When Ctrl/Cmd + Space is pressed, two simultaneous keypresses are emitted
    // first one for just the Ctrl/Cmd and second one for combo. The first one
    // leave the autocompleteOpts.doNotAutocomplete as true, so we have to make
    // it false
    autocompleteOpts.doNotAutocomplete = false;
    return;
  }

  if (event.ctrlKey || event.metaKey || event.altKey) {
    autocompleteOpts.doNotAutocomplete = true;
    autocompleteOpts.popup.hidePopup();
    return;
  }

  switch (event.keyCode) {
    case KeyCodes.DOM_VK_RETURN:
      autocompleteOpts.doNotAutocomplete = true;
      break;
    case KeyCodes.DOM_VK_ESCAPE:
      if (autocompleteOpts.popup.isOpen) {
        // Prevent the Console input to open, but still remove the autocomplete popup.
        autocompleteOpts.doNotAutocomplete = true;
        autocompleteOpts.popup.hidePopup();
        event.preventDefault();
      }
      break;
    case KeyCodes.DOM_VK_LEFT:
    case KeyCodes.DOM_VK_RIGHT:
    case KeyCodes.DOM_VK_HOME:
    case KeyCodes.DOM_VK_END:
      autocompleteOpts.doNotAutocomplete = true;
      autocompleteOpts.popup.hidePopup();
      break;
    case KeyCodes.DOM_VK_BACK_SPACE:
    case KeyCodes.DOM_VK_DELETE:
      if (ed.config.mode == Editor.modes.css) {
        autocompleteOpts.completer.invalidateCache(ed.getCursor().line);
      }
      autocompleteOpts.doNotAutocomplete = true;
      autocompleteOpts.popup.hidePopup();
      break;
    default:
      autocompleteOpts.doNotAutocomplete = false;
  }
}

/**
 * Returns the private popup. This method is used by tests to test the feature.
 */
function getPopup({ ed }) {
  if (autocompleteMap.has(ed)) {
    return autocompleteMap.get(ed).popup;
  }

  return null;
}

/**
 * Returns contextual information about the token covered by the caret if the
 * implementation of completer supports it.
 */
function getInfoAt({ ed }, caret) {
  if (autocompleteMap.has(ed)) {
    const completer = autocompleteMap.get(ed).completer;
    if (completer?.getInfoAt) {
      return completer.getInfoAt(ed.getText(), caret);
    }
  }

  return null;
}

/**
 * Returns whether autocompletion is enabled for this editor.
 * Used for testing
 */
function isAutocompletionEnabled({ ed }) {
  return autocompleteMap.has(ed);
}

// Export functions

module.exports.initializeAutoCompletion = initializeAutoCompletion;
module.exports.destroyAutoCompletion = destroyAutoCompletion;
module.exports.getAutocompletionPopup = getPopup;
module.exports.getInfoAt = getInfoAt;
module.exports.isAutocompletionEnabled = isAutocompletionEnabled;
