// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */

/*
 * Copyright (C) 2008 Apple Inc.  All rights reserved.
 * Copyright (C) 2011 Google Inc.  All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * 1.  Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 * 2.  Redistributions in binary form must reproduce the above copyright
 *     notice, this list of conditions and the following disclaimer in the
 *     documentation and/or other materials provided with the distribution.
 * 3.  Neither the name of Apple Computer, Inc. ("Apple") nor the names of
 *     its contributors may be used to endorse or promote products derived
 *     from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

import * as Common from '../../core/common/common.js';
import * as Platform from '../../core/platform/platform.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as VisualLogging from '../visual_logging/visual_logging.js';

import * as ARIAUtils from './ARIAUtils.js';
import {appendStyle, rangeOfWord} from './DOMUtilities.js';
import {SuggestBox, type SuggestBoxDelegate, type Suggestion} from './SuggestBox.js';
import textPromptStyles from './textPrompt.css.js';
import {Tooltip} from './Tooltip.js';
import {cloneCustomElement, ElementFocusRestorer} from './UIUtils.js';

/**
 * A custom element wrapper around TextPrompt that allows text-editing contents in-place.
 *
 * ## Usage ##
 *
 * ```
 * <devtools-prompt>
 *  <b>Structured</b> content
 * </devtools-prompt>
 *
 * ```
 *
 * @property completionTimeout Sets the delay for showing the autocomplete suggestion box.
 * @event commit Editing is done and the result was accepted.
 * @event cancel Editing was canceled.
 * @event beforeautocomplete This is sent before the autocomplete suggestion box is triggered and before the <datalist>
 *                           is read.
 * @attribute editing Setting/removing this attribute starts/stops editing.
 * @attribute completions Sets the `id` of the <datalist> containing the autocomplete options.
 * @attribute placeholder Sets a placeholder that's shown in place of the text contents when editing if the text is too
 *            large.
 */
export class TextPromptElement extends HTMLElement {
  static readonly observedAttributes = ['editing', 'completions', 'placeholder'];
  readonly #shadow = this.attachShadow({mode: 'open'});
  readonly #entrypoint = this.#shadow.createChild('span');
  readonly #slot = this.#entrypoint.createChild('slot');
  readonly #textPrompt = new TextPrompt();
  #completionTimeout: number|null = null;
  #completionObserver = new MutationObserver(this.#onMutate.bind(this));

  constructor() {
    super();
    this.#textPrompt.initialize(this.#willAutoComplete.bind(this));
  }

  #onMutate(changes: MutationRecord[]): void {
    const listId = this.getAttribute('completions');
    if (!listId) {
      return;
    }
    const checkIfNodeIsInCompletionList = (node: Node): boolean => {
      if (node instanceof HTMLDataListElement) {
        return node.id === listId;
      }
      if (node instanceof HTMLOptionElement) {
        return Boolean(node.parentElement && checkIfNodeIsInCompletionList(node.parentElement));
      }
      return false;
    };
    const affectsCompletionList = (change: MutationRecord): boolean =>
        change.addedNodes.values().some(checkIfNodeIsInCompletionList) ||
        change.removedNodes.values().some(checkIfNodeIsInCompletionList) ||
        checkIfNodeIsInCompletionList(change.target);

    if (changes.some(affectsCompletionList)) {
      this.#updateCompletions();
    }
  }

  attributeChangedCallback(name: string, oldValue: string|null, newValue: string|null): void {
    if (oldValue === newValue) {
      return;
    }

    switch (name) {
      case 'editing':
        if (this.isConnected) {
          if (newValue !== null && newValue !== 'false' && oldValue === null) {
            this.#startEditing();
          } else {
            this.#stopEditing();
          }
        }
        break;
      case 'completions':
        if (this.getAttribute('completions')) {
          this.#completionObserver.observe(this, {childList: true, subtree: true});
          this.#updateCompletions();
        } else {
          this.#textPrompt.clearAutocomplete();
          this.#completionObserver.disconnect();
        }
        break;
    }
  }

  #updateCompletions(): void {
    if (this.isConnected) {
      void this.#textPrompt.complete(/* force=*/ true);
    }
  }

  async #willAutoComplete(expression: string, filter: string, force: boolean): Promise<Suggestion[]> {
    this.dispatchEvent(new TextPromptElement.BeforeAutoCompleteEvent({expression, filter, force}));

    const listId = this.getAttribute('completions');
    if (!listId) {
      return [];
    }

    const datalist = this.getComponentRoot()?.querySelectorAll<HTMLOptionElement>(`datalist#${listId} > option`);
    if (!datalist?.length) {
      return [];
    }

    return datalist.values()
        .filter(option => option.textContent.startsWith(filter.toLowerCase()))
        .map(option => ({text: option.textContent}))
        .toArray();
  }

  #startEditing(): void {
    const truncatedTextPlaceholder = this.getAttribute('placeholder');
    const placeholder = this.#entrypoint.createChild('span');
    if (truncatedTextPlaceholder === null) {
      placeholder.textContent = this.#slot.deepInnerText();
    } else {
      placeholder.setTextContentTruncatedIfNeeded(this.#slot.deepInnerText(), truncatedTextPlaceholder);
    }
    this.#slot.remove();

    const proxy = this.#textPrompt.attachAndStartEditing(placeholder, e => this.#done(e, /* commit=*/ true));
    proxy.addEventListener('keydown', this.#editingValueKeyDown.bind(this));
    placeholder.getComponentSelection()?.selectAllChildren(placeholder);
  }

  #stopEditing(): void {
    this.#entrypoint.removeChildren();
    this.#entrypoint.appendChild(this.#slot);
    this.#textPrompt.detach();
  }

  connectedCallback(): void {
    if (this.hasAttribute('editing')) {
      this.attributeChangedCallback('editing', null, '');
    }
  }

  #done(e: Event, commit: boolean): void {
    const target = e.target as HTMLElement;
    const text = target.textContent || '';
    if (commit) {
      this.dispatchEvent(new TextPromptElement.CommitEvent(text));
    } else {
      this.dispatchEvent(new TextPromptElement.CancelEvent());
    }
    e.consume();
  }

  #editingValueKeyDown(event: Event): void {
    if (event.handled || !(event instanceof KeyboardEvent)) {
      return;
    }

    if (event.key === 'Enter') {
      this.#done(event, /* commit=*/ true);
    } else if (Platform.KeyboardUtilities.isEscKey(event)) {
      this.#done(event, /* commit=*/ false);
    }
  }

  set completionTimeout(timeout: number) {
    this.#completionTimeout = timeout;
    this.#textPrompt.setAutocompletionTimeout(timeout);
  }

  override cloneNode(): Node {
    const clone = cloneCustomElement(this);
    if (this.#completionTimeout !== null) {
      clone.completionTimeout = this.#completionTimeout;
    }
    return clone;
  }
}

export namespace TextPromptElement {
  export class CommitEvent extends CustomEvent<string> {
    constructor(detail: string) {
      super('commit', {detail});
    }
  }
  export class CancelEvent extends CustomEvent<string> {
    constructor() {
      super('cancel');
    }
  }
  export class BeforeAutoCompleteEvent extends CustomEvent<{expression: string, filter: string, force: boolean}> {
    constructor(detail: {expression: string, filter: string, force: boolean}) {
      super('beforeautocomplete', {detail});
    }
  }
}

customElements.define('devtools-prompt', TextPromptElement);

declare global {
  interface HTMLElementTagNameMap {
    'devtools-prompt': TextPromptElement;
  }
}

export class TextPrompt extends Common.ObjectWrapper.ObjectWrapper<EventTypes> implements SuggestBoxDelegate {
  private proxyElement!: HTMLElement|undefined;
  private proxyElementDisplay: string;
  private autocompletionTimeout: number;
  #title: string;
  private queryRange: TextUtils.TextRange.TextRange|null;
  private previousText: string;
  private currentSuggestion: Suggestion|null;
  private completionRequestId: number;
  private ghostTextElement: HTMLSpanElement;
  private leftParenthesesIndices: number[];
  private loadCompletions!: (
      this: null,
      arg1: string,
      arg2: string,
      arg3: boolean,
      ) => Promise<Suggestion[]>;
  private completionStopCharacters!: string;
  private usesSuggestionBuilder!: boolean;
  #element?: Element;
  private boundOnKeyDown?: ((ev: KeyboardEvent) => void);
  private boundOnInput?: ((ev: Event) => void);
  private boundOnMouseWheel?: ((event: Event) => void);
  private boundClearAutocomplete?: (() => void);
  private boundOnBlur?: ((ev: Event) => void);
  private contentElement?: HTMLElement;
  protected suggestBox?: SuggestBox;
  private isEditing?: boolean;
  private focusRestorer?: ElementFocusRestorer;
  private blurListener?: ((arg0: Event) => void);
  private oldTabIndex?: number;
  private completeTimeout?: number;
  #disableDefaultSuggestionForEmptyInput?: boolean;
  jslogContext: string|undefined = undefined;

  constructor() {
    super();
    this.proxyElementDisplay = 'inline-block';
    this.autocompletionTimeout = DefaultAutocompletionTimeout;
    this.#title = '';
    this.queryRange = null;
    this.previousText = '';
    this.currentSuggestion = null;
    this.completionRequestId = 0;
    this.ghostTextElement = document.createElement('span');
    this.ghostTextElement.classList.add('auto-complete-text');
    this.ghostTextElement.setAttribute('contenteditable', 'false');
    this.leftParenthesesIndices = [];
    ARIAUtils.setHidden(this.ghostTextElement, true);
  }

  initialize(
      completions: (this: null, expression: string, filter: string, force: boolean) => Promise<Suggestion[]>,
      stopCharacters?: string, usesSuggestionBuilder?: boolean): void {
    this.loadCompletions = completions;
    this.completionStopCharacters = stopCharacters || ' =:[({;,!+-*/&|^<>.';
    this.usesSuggestionBuilder = usesSuggestionBuilder || false;
  }

  setAutocompletionTimeout(timeout: number): void {
    this.autocompletionTimeout = timeout;
  }

  renderAsBlock(): void {
    this.proxyElementDisplay = 'block';
  }

  /**
   * Clients should never attach any event listeners to the |element|. Instead,
   * they should use the result of this method to attach listeners for bubbling events.
   */
  attach(element: Element): Element {
    return this.#attach(element);
  }

  /**
   * Clients should never attach any event listeners to the |element|. Instead,
   * they should use the result of this method to attach listeners for bubbling events
   * or the |blurListener| parameter to register a "blur" event listener on the |element|
   * (since the "blur" event does not bubble.)
   */
  attachAndStartEditing(element: Element, blurListener?: (arg0: Event) => void): Element {
    const proxyElement = this.#attach(element);
    this.startEditing(blurListener);
    return proxyElement;
  }

  #attach(element: Element): Element {
    if (this.proxyElement) {
      throw new Error('Cannot attach an attached TextPrompt');
    }
    this.#element = element;

    this.boundOnKeyDown = this.onKeyDown.bind(this);
    this.boundOnInput = this.onInput.bind(this);
    this.boundOnMouseWheel = this.onMouseWheel.bind(this);
    this.boundClearAutocomplete = this.clearAutocomplete.bind(this);
    this.boundOnBlur = this.onBlur.bind(this);
    this.proxyElement = element.ownerDocument.createElement('span');
    appendStyle(this.proxyElement, textPromptStyles);
    this.contentElement = this.proxyElement.createChild('div', 'text-prompt-root');
    this.proxyElement.style.display = this.proxyElementDisplay;
    if (element.parentElement) {
      element.parentElement.insertBefore(this.proxyElement, element);
    }
    this.contentElement.appendChild(element);
    let jslog = VisualLogging.textField().track({
      keydown: 'ArrowLeft|ArrowUp|PageUp|Home|PageDown|ArrowRight|ArrowDown|End|Space|Tab|Enter|Escape',
      change: true,
    });

    if (this.jslogContext) {
      jslog = jslog.context(this.jslogContext);
    }
    if (!this.#element.hasAttribute('jslog')) {
      this.#element.setAttribute('jslog', `${jslog}`);
    }
    this.#element.classList.add('text-prompt');
    ARIAUtils.markAsTextBox(this.#element);
    ARIAUtils.setAutocomplete(this.#element, ARIAUtils.AutocompleteInteractionModel.BOTH);
    ARIAUtils.setHasPopup(this.#element, ARIAUtils.PopupRole.LIST_BOX);
    this.#element.setAttribute('contenteditable', 'plaintext-only');
    this.element().addEventListener('keydown', this.boundOnKeyDown, false);
    this.#element.addEventListener('input', this.boundOnInput, false);
    this.#element.addEventListener('wheel', this.boundOnMouseWheel, false);
    this.#element.addEventListener('selectstart', this.boundClearAutocomplete, false);
    this.#element.addEventListener('blur', this.boundOnBlur, false);

    this.suggestBox = new SuggestBox(this, 20);

    if (this.#title) {
      Tooltip.install(this.proxyElement, this.#title);
    }

    return this.proxyElement;
  }

  element(): HTMLElement {
    if (!this.#element) {
      throw new Error('Expected an already attached element!');
    }
    return this.#element as HTMLElement;
  }

  detach(): void {
    this.removeFromElement();
    if (this.focusRestorer) {
      this.focusRestorer.restore();
    }
    if (this.proxyElement?.parentElement) {
      this.proxyElement.parentElement.insertBefore(this.element(), this.proxyElement);
      this.proxyElement.remove();
    }
    delete this.proxyElement;
    this.element().classList.remove('text-prompt');
    this.element().removeAttribute('contenteditable');
    this.element().removeAttribute('role');
    ARIAUtils.clearAutocomplete(this.element());
    ARIAUtils.setHasPopup(this.element(), ARIAUtils.PopupRole.FALSE);
  }

  textWithCurrentSuggestion(): string {
    const text = this.text();
    if (!this.queryRange || !this.currentSuggestion) {
      return text;
    }
    const suggestion = this.currentSuggestion.text;
    return text.substring(0, this.queryRange.startColumn) + suggestion + text.substring(this.queryRange.endColumn);
  }

  text(): string {
    let text: string = this.element().textContent || '';
    if (this.ghostTextElement.parentNode) {
      const addition = this.ghostTextElement.textContent || '';
      text = text.substring(0, text.length - addition.length);
    }
    return text;
  }

  setText(text: string): void {
    this.clearAutocomplete();
    this.element().textContent = text;
    this.previousText = this.text();
    if (this.element().hasFocus()) {
      this.moveCaretToEndOfPrompt();
      this.element().scrollIntoView();
    }
  }

  setSelectedRange(startIndex: number, endIndex: number): void {
    if (startIndex < 0) {
      throw new RangeError('Selected range start must be a nonnegative integer');
    }
    const textContent = this.element().textContent;
    const textContentLength = textContent ? textContent.length : 0;
    if (endIndex > textContentLength) {
      endIndex = textContentLength;
    }
    if (endIndex < startIndex) {
      endIndex = startIndex;
    }

    const textNode = (this.element().childNodes[0] as Node);
    const range = new Range();
    range.setStart(textNode, startIndex);
    range.setEnd(textNode, endIndex);
    const selection = window.getSelection();
    if (selection) {
      selection.removeAllRanges();
      selection.addRange(range);
    }
  }

  focus(): void {
    this.element().focus();
  }

  title(): string {
    return this.#title;
  }

  setTitle(title: string): void {
    this.#title = title;
    if (this.proxyElement) {
      Tooltip.install(this.proxyElement, title);
    }
  }

  setPlaceholder(placeholder: string, ariaPlaceholder?: string): void {
    if (placeholder) {
      this.element().setAttribute('data-placeholder', placeholder);
      // TODO(https://github.com/nvaccess/nvda/issues/10164): Remove ariaPlaceholder once the NVDA bug is fixed
      // ariaPlaceholder and placeholder may differ, like in case the placeholder contains a '?'
      ARIAUtils.setPlaceholder(this.element(), ariaPlaceholder || placeholder);
    } else {
      this.element().removeAttribute('data-placeholder');
      ARIAUtils.setPlaceholder(this.element(), null);
    }
  }

  setEnabled(enabled: boolean): void {
    if (enabled) {
      this.element().setAttribute('contenteditable', 'plaintext-only');
    } else {
      this.element().removeAttribute('contenteditable');
    }
    this.element().classList.toggle('disabled', !enabled);
  }

  private removeFromElement(): void {
    this.clearAutocomplete();
    this.element().removeEventListener(
        'keydown', (this.boundOnKeyDown as (this: HTMLElement, arg1: Event) => void), false);
    this.element().removeEventListener('input', (this.boundOnInput as (this: HTMLElement, arg1: Event) => void), false);
    this.element().removeEventListener(
        'selectstart', (this.boundClearAutocomplete as (this: HTMLElement, arg1: Event) => void), false);
    this.element().removeEventListener('blur', (this.boundOnBlur as (this: HTMLElement, arg1: Event) => void), false);
    if (this.isEditing) {
      this.stopEditing();
    }
    if (this.suggestBox) {
      this.suggestBox.hide();
    }
  }

  private startEditing(blurListener?: ((arg0: Event) => void)): void {
    this.isEditing = true;
    if (this.contentElement) {
      this.contentElement.classList.add('text-prompt-editing');
    }
    this.focusRestorer = new ElementFocusRestorer(this.element());
    if (blurListener) {
      this.blurListener = blurListener;
      this.element().addEventListener('blur', this.blurListener, false);
    }
    this.oldTabIndex = this.element().tabIndex;
    if (this.element().tabIndex < 0) {
      this.element().tabIndex = 0;
    }
    if (!this.text()) {
      this.autoCompleteSoon();
    }
  }

  private stopEditing(): void {
    this.element().tabIndex = (this.oldTabIndex as number);
    if (this.blurListener) {
      this.element().removeEventListener('blur', this.blurListener, false);
    }
    if (this.contentElement) {
      this.contentElement.classList.remove('text-prompt-editing');
    }
    delete this.isEditing;
  }

  onMouseWheel(_event: Event): void {
    // Subclasses can implement.
  }

  onKeyDown(event: KeyboardEvent): void {
    let handled = false;
    if (this.isSuggestBoxVisible() && this.suggestBox?.keyPressed(event)) {
      void VisualLogging.logKeyDown(this.suggestBox.element, event);
      event.consume(true);
      return;
    }

    switch (event.key) {
      case 'Tab':
        handled = this.tabKeyPressed(event);
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
      case 'PageUp':
      case 'Home':
        this.clearAutocomplete();
        break;
      case 'PageDown':
      case 'ArrowRight':
      case 'ArrowDown':
      case 'End':
        if (this.isCaretAtEndOfPrompt()) {
          handled = this.acceptAutoComplete();
        } else {
          this.clearAutocomplete();
        }
        break;
      case 'Escape':
        if (this.isSuggestBoxVisible() || this.currentSuggestion) {
          this.clearAutocomplete();
          handled = true;
        }
        break;
      case ' ':  // Space
        if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
          this.autoCompleteSoon(true);
          handled = true;
        }
        break;
    }

    if (event.key === 'Enter') {
      event.preventDefault();
    }

    if (handled) {
      event.consume(true);
    }
  }

  private acceptSuggestionOnStopCharacters(key: string): boolean {
    if (!this.currentSuggestion || !this.queryRange || key.length !== 1 ||
        !this.completionStopCharacters?.includes(key)) {
      return false;
    }

    const query = this.text().substring(this.queryRange.startColumn, this.queryRange.endColumn);
    if (query && this.currentSuggestion.text.startsWith(query + key)) {
      this.queryRange.endColumn += 1;
      return this.acceptAutoComplete();
    }
    return false;
  }

  onInput(ev: Event): void {
    const event = (ev as InputEvent);
    let text = this.text();
    const currentEntry = event.data;

    if (event.inputType === 'insertFromPaste' && text.includes('\n')) {
      /* Ensure that we remove any linebreaks from copied/pasted content
       * to avoid breaking the rendering of the filter bar.
       * See crbug.com/849563.
       * We don't let users enter linebreaks when
       * typing manually, so we should escape them if copying text in.
       */
      text = Platform.StringUtilities.stripLineBreaks(text);
      this.setText(text);
    }

    // Skip the current ')' entry if the caret is right before a ')' and there's an unmatched '('.
    const caretPosition = this.getCaretPosition();
    if (currentEntry === ')' && caretPosition >= 0 && this.leftParenthesesIndices.length > 0) {
      const nextCharAtCaret = text[caretPosition];
      if (nextCharAtCaret === ')' && this.tryMatchingLeftParenthesis(caretPosition)) {
        text = text.substring(0, caretPosition) + text.substring(caretPosition + 1);
        this.setText(text);
        return;
      }
    }

    if (currentEntry && !this.acceptSuggestionOnStopCharacters(currentEntry)) {
      const hasCommonPrefix = text.startsWith(this.previousText) || this.previousText.startsWith(text);
      if (this.queryRange && hasCommonPrefix) {
        this.queryRange.endColumn += text.length - this.previousText.length;
      }
    }
    this.refreshGhostText();
    this.previousText = text;
    this.dispatchEventToListeners(Events.TEXT_CHANGED);

    this.autoCompleteSoon();
  }

  acceptAutoComplete(): boolean {
    let result = false;
    if (this.isSuggestBoxVisible() && this.suggestBox) {
      result = this.suggestBox.acceptSuggestion();
    }
    if (!result) {
      result = this.#acceptSuggestion();
    }
    if (this.usesSuggestionBuilder && result) {
      // Trigger autocompletions for text prompts using suggestion builders
      this.autoCompleteSoon();
    }
    return result;
  }

  clearAutocomplete(): void {
    const beforeText = this.textWithCurrentSuggestion();

    if (this.isSuggestBoxVisible() && this.suggestBox) {
      this.suggestBox.hide();
    }
    this.clearAutocompleteTimeout();
    this.queryRange = null;
    this.refreshGhostText();

    if (beforeText !== this.textWithCurrentSuggestion()) {
      this.dispatchEventToListeners(Events.TEXT_CHANGED);
    }
    this.currentSuggestion = null;
  }

  private onBlur(): void {
    this.clearAutocomplete();
  }

  private refreshGhostText(): void {
    if (this.currentSuggestion?.hideGhostText) {
      this.ghostTextElement.remove();
      return;
    }
    if (this.queryRange && this.currentSuggestion && this.isCaretAtEndOfPrompt() &&
        this.currentSuggestion.text.startsWith(this.text().substring(this.queryRange.startColumn))) {
      this.ghostTextElement.textContent =
          this.currentSuggestion.text.substring(this.queryRange.endColumn - this.queryRange.startColumn);
      this.element().appendChild(this.ghostTextElement);
    } else {
      this.ghostTextElement.remove();
    }
  }

  private clearAutocompleteTimeout(): void {
    if (this.completeTimeout) {
      clearTimeout(this.completeTimeout);
      delete this.completeTimeout;
    }
    this.completionRequestId++;
  }

  autoCompleteSoon(force?: boolean): void {
    const immediately = this.isSuggestBoxVisible() || force;
    if (!this.completeTimeout) {
      this.completeTimeout =
          window.setTimeout(this.complete.bind(this, force), immediately ? 0 : this.autocompletionTimeout);
    }
  }

  async complete(force?: boolean): Promise<void> {
    this.clearAutocompleteTimeout();
    if (!this.element().isConnected) {
      return;
    }

    const selection = this.element().getComponentSelection();
    if (!selection || selection.rangeCount === 0) {
      return;
    }
    const selectionRange = selection.getRangeAt(0);

    let shouldExit;

    if (!force && !this.isCaretAtEndOfPrompt() && !this.isSuggestBoxVisible()) {
      shouldExit = true;
    } else if (!selection.isCollapsed) {
      shouldExit = true;
    }

    if (shouldExit) {
      this.clearAutocomplete();
      return;
    }

    const wordQueryRange = rangeOfWord(
        selectionRange.startContainer, selectionRange.startOffset, this.completionStopCharacters, this.element(),
        'backward');

    const expressionRange = wordQueryRange.cloneRange();
    expressionRange.collapse(true);
    expressionRange.setStartBefore(this.element());
    const completionRequestId = ++this.completionRequestId;
    const completions =
        await this.loadCompletions.call(null, expressionRange.toString(), wordQueryRange.toString(), Boolean(force));
    this.completionsReady(completionRequestId, (selection), wordQueryRange, Boolean(force), completions);
  }

  disableDefaultSuggestionForEmptyInput(): void {
    this.#disableDefaultSuggestionForEmptyInput = true;
  }

  private boxForAnchorAtStart(selection: Selection, textRange: Range): AnchorBox {
    const rangeCopy = selection.getRangeAt(0).cloneRange();
    const anchorElement = document.createElement('span');
    anchorElement.textContent = '\u200B';
    textRange.insertNode(anchorElement);
    const box = anchorElement.boxInWindow(window);
    anchorElement.remove();
    selection.removeAllRanges();
    selection.addRange(rangeCopy);
    return box;
  }

  additionalCompletions(_query: string): Suggestion[] {
    return [];
  }

  private completionsReady(
      completionRequestId: number, selection: Selection, originalWordQueryRange: Range, force: boolean,
      completions: Suggestion[]): void {
    if (this.completionRequestId !== completionRequestId) {
      return;
    }

    const query = originalWordQueryRange.toString();

    // Filter out dupes.
    const store = new Set<string>();
    completions = completions.filter(item => !store.has(item.text) && Boolean(store.add(item.text)));

    if (query || force) {
      if (query) {
        completions = completions.concat(this.additionalCompletions(query));
      } else {
        completions = this.additionalCompletions(query).concat(completions);
      }
    }

    if (!completions.length) {
      this.clearAutocomplete();
      return;
    }

    const selectionRange = selection.getRangeAt(0);

    const fullWordRange = document.createRange();
    fullWordRange.setStart(originalWordQueryRange.startContainer, originalWordQueryRange.startOffset);
    fullWordRange.setEnd(selectionRange.endContainer, selectionRange.endOffset);

    if (query + selectionRange.toString() !== fullWordRange.toString()) {
      return;
    }

    const beforeRange = document.createRange();
    beforeRange.setStart(this.element(), 0);
    beforeRange.setEnd(fullWordRange.startContainer, fullWordRange.startOffset);
    this.queryRange = new TextUtils.TextRange.TextRange(
        0, beforeRange.toString().length, 0, beforeRange.toString().length + fullWordRange.toString().length);

    const shouldSelect = !this.#disableDefaultSuggestionForEmptyInput || Boolean(this.text());
    if (this.suggestBox) {
      this.suggestBox.updateSuggestions(
          this.boxForAnchorAtStart(selection, fullWordRange), completions, shouldSelect, !this.isCaretAtEndOfPrompt(),
          this.text());
    }
  }

  applySuggestion(suggestion: Suggestion|null, isIntermediateSuggestion?: boolean): void {
    this.currentSuggestion = suggestion;
    this.refreshGhostText();
    if (isIntermediateSuggestion) {
      this.dispatchEventToListeners(Events.TEXT_CHANGED);
    }
  }

  acceptSuggestion(): void {
    this.#acceptSuggestion();
  }

  #acceptSuggestion(): boolean {
    if (!this.queryRange) {
      return false;
    }

    const suggestionLength = this.currentSuggestion ? this.currentSuggestion.text.length : 0;
    const selectionRange = this.currentSuggestion ? this.currentSuggestion.selectionRange : null;
    const endColumn = selectionRange ? selectionRange.endColumn : suggestionLength;
    const startColumn = selectionRange ? selectionRange.startColumn : suggestionLength;
    this.element().textContent = this.textWithCurrentSuggestion();
    this.setDOMSelection(this.queryRange.startColumn + startColumn, this.queryRange.startColumn + endColumn);
    this.updateLeftParenthesesIndices();

    this.clearAutocomplete();
    this.dispatchEventToListeners(Events.TEXT_CHANGED);

    return true;
  }

  ownerElement(): Element {
    return this.element();
  }

  setDOMSelection(startColumn: number, endColumn: number): void {
    this.element().normalize();
    const node = this.element().childNodes[0];
    if (!node || node === this.ghostTextElement) {
      return;
    }
    const range = document.createRange();
    range.setStart(node, startColumn);
    range.setEnd(node, endColumn);
    const selection = this.element().getComponentSelection();
    if (selection) {
      selection.removeAllRanges();
      selection.addRange(range);
    }
  }

  isSuggestBoxVisible(): boolean {
    return this.suggestBox?.visible() ?? false;
  }

  private isCaretAtEndOfPrompt(): boolean {
    const selection = this.element().getComponentSelection();
    if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
      return false;
    }

    const selectionRange = selection.getRangeAt(0);
    let node: (Node|null)|Node = selectionRange.startContainer;
    if (!node.isSelfOrDescendant(this.element())) {
      return false;
    }

    if (this.ghostTextElement.isAncestor(node)) {
      return true;
    }

    if (node.nodeType === Node.TEXT_NODE && selectionRange.startOffset < (node.nodeValue || '').length) {
      return false;
    }

    let foundNextText = false;
    while (node) {
      if (node.nodeType === Node.TEXT_NODE && node.nodeValue?.length) {
        if (foundNextText && !this.ghostTextElement.isAncestor(node)) {
          return false;
        }
        foundNextText = true;
      }

      node = node.traverseNextNode(this.#element);
    }

    return true;
  }

  moveCaretToEndOfPrompt(): void {
    const selection = this.element().getComponentSelection();
    const selectionRange = document.createRange();

    let container: Node = this.element();
    while (container.lastChild) {
      container = container.lastChild;
    }
    let offset = 0;
    if (container.nodeType === Node.TEXT_NODE) {
      const textNode = (container as Text);
      offset = (textNode.textContent || '').length;
    }
    selectionRange.setStart(container, offset);
    selectionRange.setEnd(container, offset);

    if (selection) {
      selection.removeAllRanges();
      selection.addRange(selectionRange);
    }
  }

  /**
   * -1 if no caret can be found in text prompt
   */
  private getCaretPosition(): number {
    if (!this.element().hasFocus()) {
      return -1;
    }

    const selection = this.element().getComponentSelection();
    if (!selection || selection.rangeCount === 0 || !selection.isCollapsed) {
      return -1;
    }
    const selectionRange = selection.getRangeAt(0);
    if (selectionRange.startOffset !== selectionRange.endOffset) {
      return -1;
    }
    return selectionRange.startOffset;
  }

  tabKeyPressed(_event: Event): boolean {
    return this.acceptAutoComplete();
  }

  /**
   * Try matching the most recent open parenthesis with the given right
   * parenthesis, and closes the matched left parenthesis if found.
   * Return the result of the matching.
   */
  private tryMatchingLeftParenthesis(rightParenthesisIndex: number): boolean {
    const leftParenthesesIndices = this.leftParenthesesIndices;
    if (leftParenthesesIndices.length === 0 || rightParenthesisIndex < 0) {
      return false;
    }

    for (let i = leftParenthesesIndices.length - 1; i >= 0; --i) {
      if (leftParenthesesIndices[i] < rightParenthesisIndex) {
        leftParenthesesIndices.splice(i, 1);
        return true;
      }
    }

    return false;
  }

  private updateLeftParenthesesIndices(): void {
    const text = this.text();
    const leftParenthesesIndices: number[] = this.leftParenthesesIndices = [];
    for (let i = 0; i < text.length; ++i) {
      if (text[i] === '(') {
        leftParenthesesIndices.push(i);
      }
    }
  }

  suggestBoxForTest(): SuggestBox|undefined {
    return this.suggestBox;
  }
}

const DefaultAutocompletionTimeout = 250;

export const enum Events {
  TEXT_CHANGED = 'TextChanged',
}

export interface EventTypes {
  [Events.TEXT_CHANGED]: void;
}
