// Copyright 2016 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 */

import * as Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Badges from '../../models/badges/badges.js';
import * as Formatter from '../../models/formatter/formatter.js';
import * as SourceMapScopes from '../../models/source_map_scopes/source_map_scopes.js';
import * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import * as TextEditor from '../../ui/components/text_editor/text_editor.js';
import {Icon} from '../../ui/kit/kit.js';
import * as ObjectUI from '../../ui/legacy/components/object_ui/object_ui.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';

import {ConsolePanel} from './ConsolePanel.js';
import consolePromptStyles from './consolePrompt.css.js';

const {Direction} = TextEditor.TextEditorHistory;

const UIStrings = {
  /**
   * @description Text in Console Prompt of the Console panel
   */
  consolePrompt: 'Console prompt',
  /**
   * @description Warning shown to users when pasting text into the DevTools console. IMPORTANT: keep double quotes around PH1 and do not use single quotes.
   * @example {allow pasting} PH1
   */
  selfXssWarning:
      'Warning: Don’t paste code into the DevTools Console that you don’t understand or haven’t reviewed yourself. This could allow attackers to steal your identity or take control of your computer. Please type “{PH1}” below and press Enter to allow pasting.',
  /**
   * @description Text a user needs to type in order to confirm that they are aware of the danger of pasting code into the DevTools console.
   */
  allowPasting: 'allow pasting',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/console/ConsolePrompt.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export class ConsolePrompt extends Common.ObjectWrapper.eventMixin<EventTypes, typeof UI.Widget.Widget>(
    UI.Widget.Widget) {
  private addCompletionsFromHistory: boolean;
  #history: TextEditor.AutocompleteHistory.AutocompleteHistory;
  private initialText: string;
  private editor: TextEditor.TextEditor.TextEditor;
  private readonly eagerPreviewElement: HTMLDivElement;
  private textChangeThrottler: Common.Throttler.Throttler;
  private requestPreviewBound: () => Promise<void>;
  private requestPreviewCurrent = 0;
  private readonly innerPreviewElement: HTMLElement;
  private readonly promptIcon: Icon;
  private readonly iconThrottler: Common.Throttler.Throttler;
  private readonly eagerEvalSetting: Common.Settings.Setting<boolean>;
  protected previewRequestForTest: Promise<void>|null;
  private highlightingNode: boolean;
  // The CodeMirror state field that controls whether the argument hints are showing.
  // If they are, the escape key will clear them. However, if they aren't, then the
  // console drawer should be hidden as a whole.
  #argumentHintsState: CodeMirror.StateField<CodeMirror.Tooltip|null>;

  #editorHistory: TextEditor.TextEditorHistory.TextEditorHistory;
  #selfXssWarningShown = false;
  #javaScriptCompletionCompartment: CodeMirror.Compartment = new CodeMirror.Compartment();

  private aiCodeCompletionConfig?: TextEditor.AiCodeCompletionProvider.AiCodeCompletionConfig;
  private aiCodeCompletionProvider?: TextEditor.AiCodeCompletionProvider.AiCodeCompletionProvider;

  #getJavaScriptCompletionExtensions(): CodeMirror.Extension {
    if (this.#selfXssWarningShown) {
      // No (JavaScript) completions at all while showing the self-XSS warning.
      return [];
    }
    if (Root.Runtime.Runtime.queryParam('noJavaScriptCompletion') !== 'true') {
      return [
        CodeMirror.javascript.javascript(),
        TextEditor.JavaScript.completion(),
      ];
    }
    return [CodeMirror.javascript.javascriptLanguage];
  }

  #updateJavaScriptCompletionCompartment(): void {
    const extensions = this.#getJavaScriptCompletionExtensions();
    const effects = this.#javaScriptCompletionCompartment.reconfigure(extensions);
    this.editor.dispatch({effects});
  }

  constructor(aiCodeCompletionConfig?: TextEditor.AiCodeCompletionProvider.AiCodeCompletionConfig) {
    super({
      jslog: `${VisualLogging.textField('console-prompt').track({
        change: true,
        keydown: 'Enter|ArrowUp|ArrowDown|PageUp',
      })}`,
    });
    this.registerRequiredCSS(consolePromptStyles);
    this.addCompletionsFromHistory = true;
    this.#history = new TextEditor.AutocompleteHistory.AutocompleteHistory(
        Common.Settings.Settings.instance().createLocalSetting('console-history', []));

    this.initialText = '';
    this.eagerPreviewElement = document.createElement('div');
    this.eagerPreviewElement.classList.add('console-eager-preview');
    this.textChangeThrottler = new Common.Throttler.Throttler(150);
    this.requestPreviewBound = this.requestPreview.bind(this);
    this.innerPreviewElement = this.eagerPreviewElement.createChild('div', 'console-eager-inner-preview');
    const previewIcon = new Icon();
    previewIcon.name = 'chevron-left-dot';
    previewIcon.classList.add('preview-result-icon', 'medium');
    this.eagerPreviewElement.appendChild(previewIcon);

    const editorContainerElement = this.element.createChild('div', 'console-prompt-editor-container');
    this.element.appendChild(this.eagerPreviewElement);

    this.promptIcon = new Icon();
    this.promptIcon.name = 'chevron-right';
    this.promptIcon.style.color = 'var(--icon-action)';
    this.promptIcon.classList.add('console-prompt-icon', 'medium');
    this.element.appendChild(this.promptIcon);
    this.iconThrottler = new Common.Throttler.Throttler(0);

    this.eagerEvalSetting = Common.Settings.Settings.instance().moduleSetting('console-eager-eval');
    this.eagerEvalSetting.addChangeListener(this.eagerSettingChanged.bind(this));
    this.eagerPreviewElement.classList.toggle('hidden', !this.eagerEvalSetting.get());

    this.element.tabIndex = 0;
    this.previewRequestForTest = null;
    this.highlightingNode = false;
    const argumentHints = TextEditor.JavaScript.argumentHints();
    this.#argumentHintsState = argumentHints[0];

    const autocompleteOnEnter = TextEditor.Config.DynamicSetting.bool(
        'console-autocomplete-on-enter', [], TextEditor.Config.conservativeCompletion);

    const extensions = [
      CodeMirror.keymap.of(this.editorKeymap()),
      CodeMirror.EditorView.updateListener.of(update => this.editorUpdate(update)),
      argumentHints,
      autocompleteOnEnter.instance(),
      TextEditor.Config.showCompletionHint,
      TextEditor.Config.baseConfiguration(this.initialText),
      TextEditor.Config.autocompletion.instance(),
      CodeMirror.javascript.javascriptLanguage.data.of({
        autocomplete: (context: CodeMirror.CompletionContext) =>
            this.addCompletionsFromHistory ? this.#editorHistory.historyCompletions(context) : null,
      }),
      CodeMirror.EditorView.contentAttributes.of({'aria-label': i18nString(UIStrings.consolePrompt)}),
      CodeMirror.EditorView.lineWrapping,
      CodeMirror.autocompletion({aboveCursor: true}),
      this.#javaScriptCompletionCompartment.of(this.#getJavaScriptCompletionExtensions()),
    ];

    this.aiCodeCompletionConfig = aiCodeCompletionConfig;
    if (this.aiCodeCompletionConfig) {
      this.aiCodeCompletionProvider =
          TextEditor.AiCodeCompletionProvider.AiCodeCompletionProvider.createInstance(this.aiCodeCompletionConfig);
      extensions.push(...this.aiCodeCompletionProvider.extension());
    }

    const doc = this.initialText;
    const editorState = CodeMirror.EditorState.create({doc, extensions});

    this.editor = new TextEditor.TextEditor.TextEditor(editorState);

    if (this.aiCodeCompletionProvider) {
      this.aiCodeCompletionProvider.editorInitialized(this.editor);
      this.editor.editor.dispatch({
        effects: TextEditor.AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
            TextEditor.AiCodeCompletionProvider.AiCodeCompletionTeaserMode.ONLY_SHOW_ON_EMPTY)
      });
    }

    this.editor.addEventListener('keydown', event => {
      if (event.defaultPrevented) {
        event.stopPropagation();
      }
    });
    editorContainerElement.appendChild(this.editor);
    this.#editorHistory = new TextEditor.TextEditorHistory.TextEditorHistory(this.editor, this.#history);

    if (this.hasFocus()) {
      this.focus();
    }
    this.element.removeAttribute('tabindex');

    this.editorSetForTest();

    // Record the console tool load time after the console prompt constructor is complete.
    Host.userMetrics.panelLoaded('console', 'DevTools.Launch.Console');
  }

  private eagerSettingChanged(): void {
    const enabled = this.eagerEvalSetting.get();
    this.eagerPreviewElement.classList.toggle('hidden', !enabled);
    if (enabled) {
      void this.requestPreview();
    }
  }

  belowEditorElement(): Element {
    return this.eagerPreviewElement;
  }

  private onTextChanged(): void {
    // ConsoleView and prompt both use a throttler, so we clear the preview
    // ASAP to avoid inconsistency between a fresh viewport and stale preview.
    if (this.eagerEvalSetting.get()) {
      const asSoonAsPossible = !TextEditor.Config.contentIncludingHint(this.editor.editor);
      this.previewRequestForTest = this.textChangeThrottler.schedule(
          this.requestPreviewBound,
          asSoonAsPossible ? Common.Throttler.Scheduling.AS_SOON_AS_POSSIBLE : Common.Throttler.Scheduling.DEFAULT);
    }
    this.updatePromptIcon();
    this.dispatchEventToListeners(Events.TEXT_CHANGED);
  }

  private async requestPreview(): Promise<void> {
    const id = ++this.requestPreviewCurrent;
    const text = TextEditor.Config.contentIncludingHint(this.editor.editor).trim();
    const executionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext);
    const {preview, result} = await ObjectUI.JavaScriptREPL.JavaScriptREPL.evaluateAndBuildPreview(
        text, true /* throwOnSideEffect */, true /* replMode */, 500 /* timeout */);
    if (this.requestPreviewCurrent !== id) {
      return;
    }
    this.innerPreviewElement.removeChildren();
    if (preview.deepTextContent() !== TextEditor.Config.contentIncludingHint(this.editor.editor).trim()) {
      this.innerPreviewElement.appendChild(preview);
    }
    if (result && 'object' in result && result.object?.subtype === 'node') {
      this.highlightingNode = true;
      SDK.OverlayModel.OverlayModel.highlightObjectAsDOMNode(result.object);
    } else if (this.highlightingNode) {
      this.highlightingNode = false;
      SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
    }
    if (result && executionContext) {
      executionContext.runtimeModel.releaseEvaluationResult(result);
    }
  }

  override willHide(): void {
    super.willHide();
    if (this.highlightingNode) {
      this.highlightingNode = false;
      SDK.OverlayModel.OverlayModel.hideDOMNodeHighlight();
    }
  }

  history(): TextEditor.AutocompleteHistory.AutocompleteHistory {
    return this.#history;
  }

  clearAutocomplete(): void {
    CodeMirror.closeCompletion(this.editor.editor);
  }

  clearAiCodeCompletionCache(): void {
    this.aiCodeCompletionProvider?.clearCache();
  }

  moveCaretToEndOfPrompt(): void {
    this.editor.dispatch({
      selection: CodeMirror.EditorSelection.cursor(this.editor.state.doc.length),
    });
  }

  clear(): void {
    this.editor.dispatch({
      changes: {from: 0, to: this.editor.state.doc.length},
    });
  }

  text(): string {
    return this.editor.state.doc.toString();
  }

  setAddCompletionsFromHistory(value: boolean): void {
    this.addCompletionsFromHistory = value;
  }

  private editorKeymap(): readonly CodeMirror.KeyBinding[] {
    return [
      {
        // Handle the KeyboardEvent manually.
        any: (_view, event) => {
          // Events with `repeat=true` are excluded from altering the history state because
          // they are often not intended as such. Example:
          // Scrolling through long snippets.
          if (event.repeat) {
            return false;
          }

          if (event.key === 'ArrowUp') {
            return this.#editorHistory.moveHistory(Direction.BACKWARD);
          }

          if (event.key === 'ArrowDown') {
            return this.#editorHistory.moveHistory(Direction.FORWARD);
          }

          return false;
        },
      },

      {mac: 'Ctrl-p', run: () => this.#editorHistory.moveHistory(Direction.BACKWARD, true)},
      {mac: 'Ctrl-n', run: () => this.#editorHistory.moveHistory(Direction.FORWARD, true)},
      {
        key: 'Escape',
        run: () => {
          return TextEditor.JavaScript.closeArgumentsHintsTooltip(this.editor.editor, this.#argumentHintsState);
        },
      },
      {
        key: 'Ctrl-Enter',
        run: () => {
          void this.handleEnter(/* forceEvaluate */ true);
          return true;
        },
      },
      {
        key: 'Enter',
        run: () => {
          void this.handleEnter();
          return true;
        },
        shift: CodeMirror.insertNewlineAndIndent,
      },
    ];
  }

  private async enterWillEvaluate(forceEvaluate?: boolean): Promise<boolean> {
    const {doc, selection} = this.editor.state;
    if (!doc.length) {
      return false;
    }
    if (forceEvaluate || selection.main.head < doc.length) {
      return true;
    }
    const currentExecutionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext);
    const isExpressionComplete = await TextEditor.JavaScript.isExpressionComplete(doc.toString());
    if (currentExecutionContext !== UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext)) {
      // We should not evaluate if the current context has changed since user action
      return false;
    }
    return isExpressionComplete;
  }

  showSelfXssWarning(): void {
    Common.Console.Console.instance().warn(
        i18nString(UIStrings.selfXssWarning, {PH1: i18nString(UIStrings.allowPasting)}),
        Common.Console.FrontendMessageSource.SELF_XSS);
    this.#selfXssWarningShown = true;
    Host.userMetrics.actionTaken(Host.UserMetrics.Action.SelfXssWarningConsoleMessageShown);
    this.#updateJavaScriptCompletionCompartment();
  }

  private async handleEnter(forceEvaluate?: boolean): Promise<void> {
    if (this.#selfXssWarningShown &&
        (this.text() === i18nString(UIStrings.allowPasting) ||
         this.text() === `'${i18nString(UIStrings.allowPasting)}'`)) {
      Common.Console.Console.instance().log(this.text());
      this.editor.dispatch({
        changes: {from: 0, to: this.editor.state.doc.length},
        scrollIntoView: true,
      });
      Common.Settings.Settings.instance()
          .createSetting('disable-self-xss-warning', false, Common.Settings.SettingStorageType.SYNCED)
          .set(true);
      this.#selfXssWarningShown = false;
      Host.userMetrics.actionTaken(Host.UserMetrics.Action.SelfXssAllowPastingInConsole);
      this.#updateJavaScriptCompletionCompartment();
      return;
    }

    if (await this.enterWillEvaluate(forceEvaluate)) {
      this.appendCommand(this.text(), true);
      TextEditor.JavaScript.closeArgumentsHintsTooltip(this.editor.editor, this.#argumentHintsState);
      this.editor.dispatch({
        changes: {from: 0, to: this.editor.state.doc.length},
        scrollIntoView: true,
      });
      if (this.aiCodeCompletionProvider) {
        const teaserMode =
            this.editor.editor.state.field(TextEditor.AiCodeCompletionProvider.aiCodeCompletionTeaserModeState);
        if (teaserMode !== TextEditor.AiCodeCompletionProvider.AiCodeCompletionTeaserMode.OFF) {
          this.editor.editor.dispatch({
            effects: TextEditor.AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
                TextEditor.AiCodeCompletionProvider.AiCodeCompletionTeaserMode.OFF)
          });
        }
      }
    } else if (this.editor.state.doc.length) {
      CodeMirror.insertNewlineAndIndent(this.editor.editor);
    } else {
      this.editor.dispatch({scrollIntoView: true});
    }
  }

  private updatePromptIcon(): void {
    void this.iconThrottler.schedule(async () => {
      this.promptIcon.classList.toggle('console-prompt-incomplete', !(await this.enterWillEvaluate()));
    });
  }

  private appendCommand(text: string, useCommandLineAPI: boolean): void {
    const currentExecutionContext = UI.Context.Context.instance().flavor(SDK.RuntimeModel.ExecutionContext);
    if (currentExecutionContext) {
      const executionContext = currentExecutionContext;
      const consoleModel = executionContext.target().model(SDK.ConsoleModel.ConsoleModel);
      if (consoleModel) {
        const message = consoleModel.addCommandMessage(executionContext, text);
        const expression = ObjectUI.JavaScriptREPL.JavaScriptREPL.wrapObjectLiteral(text);
        void this.evaluateCommandInConsole(executionContext, message, expression, useCommandLineAPI);
        if (ConsolePanel.instance().isShowing()) {
          Host.userMetrics.actionTaken(Host.UserMetrics.Action.CommandEvaluatedInConsolePanel);
          Badges.UserBadges.instance().recordAction(Badges.BadgeAction.CONSOLE_PROMPT_EXECUTED);
        }
      }
    }
  }

  private async evaluateCommandInConsole(
      executionContext: SDK.RuntimeModel.ExecutionContext, message: SDK.ConsoleModel.ConsoleMessage, expression: string,
      useCommandLineAPI: boolean): Promise<void> {
    const callFrame = executionContext.debuggerModel.selectedCallFrame();
    if (callFrame?.script.isJavaScript()) {
      const nameMap = await SourceMapScopes.NamesResolver.allVariablesInCallFrame(callFrame);
      expression = await this.substituteNames(expression, nameMap);
    }

    await executionContext.target()
        .model(SDK.ConsoleModel.ConsoleModel)
        ?.evaluateCommandInConsole(executionContext, message, expression, useCommandLineAPI);
  }

  private async substituteNames(expression: string, mapping: Map<string, string|null>): Promise<string> {
    try {
      return await Formatter.FormatterWorkerPool.formatterWorkerPool().javaScriptSubstitute(expression, mapping);
    } catch {
      return expression;
    }
  }

  private editorUpdate(update: CodeMirror.ViewUpdate): void {
    if (update.docChanged ||
        CodeMirror.selectedCompletion(update.state) !== CodeMirror.selectedCompletion(update.startState)) {
      this.onTextChanged();
    } else if (update.selectionSet) {
      this.updatePromptIcon();
    }
  }

  override focus(): void {
    this.editor.focus();
  }

  private editorSetForTest(): void {
  }
}

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

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