/* 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";

loader.lazyRequireGetter(
  this,
  "Utils",
  "resource://devtools/client/webconsole/utils.js",
  true
);
loader.lazyRequireGetter(
  this,
  "WebConsoleUI",
  "resource://devtools/client/webconsole/webconsole-ui.js",
  true
);
loader.lazyRequireGetter(
  this,
  "gDevTools",
  "resource://devtools/client/framework/devtools.js",
  true
);
loader.lazyRequireGetter(
  this,
  "openDocLink",
  "resource://devtools/client/shared/link.js",
  true
);
loader.lazyRequireGetter(
  this,
  "DevToolsUtils",
  "resource://devtools/shared/DevToolsUtils.js"
);
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const Telemetry = require("resource://devtools/client/shared/telemetry.js");

var gHudId = 0;
const isMacOS = Services.appinfo.OS === "Darwin";

/**
 * A WebConsole instance is an interactive console initialized *per target*
 * that displays console log data as well as provides an interactive terminal to
 * manipulate the target's document content.
 *
 * This object only wraps the iframe that holds the Web Console UI. This is
 * meant to be an integration point between the Firefox UI and the Web Console
 * UI and features.
 */
class WebConsole extends EventEmitter {
  /**
   * @class
   * @param object toolbox
   *        The toolbox where the web console is displayed.
   * @param object commands
   *        The commands object with all interfaces defined from devtools/shared/commands/
   * @param nsIDOMWindow iframeWindow
   *        The window where the web console UI is already loaded.
   * @param nsIDOMWindow chromeWindow
   *        The window of the web console owner.
   * @param bool isBrowserConsole
   */
  constructor(
    toolbox,
    commands,
    iframeWindow,
    chromeWindow,
    isBrowserConsole = false
  ) {
    super();
    this.toolbox = toolbox;
    this.commands = commands;
    this.iframeWindow = iframeWindow;
    this.chromeWindow = chromeWindow;
    this.hudId = "hud_" + ++gHudId;
    this.browserWindow = DevToolsUtils.getTopWindow(this.chromeWindow);
    this.isBrowserConsole = isBrowserConsole;

    // On the browser console, where we don't have a toolbox, we instantiate a dedicated Telemetry instance.
    this.telemetry = toolbox?.telemetry || new Telemetry();

    const element = this.browserWindow.document.documentElement;
    if (element.getAttribute("windowtype") != gDevTools.chromeWindowType) {
      this.browserWindow = Services.wm.getMostRecentWindow(
        gDevTools.chromeWindowType
      );
    }
    this.ui = new WebConsoleUI(this);
    this._destroyer = null;
  }

  recordEvent(event, extra = {}) {
    this.telemetry.recordEvent(event, "webconsole", null, extra);
  }

  get currentTarget() {
    return this.commands.targetCommand.targetFront;
  }

  get resourceCommand() {
    return this.commands.resourceCommand;
  }

  /**
   * Getter for the window that can provide various utilities that the web
   * console makes use of, like opening links, managing popups, etc.  In
   * most cases, this will be |this.browserWindow|, but in some uses (such as
   * the Browser Toolbox), there is no browser window, so an alternative window
   * hosts the utilities there.
   *
   * @type nsIDOMWindow
   */
  get chromeUtilsWindow() {
    if (this.browserWindow) {
      return this.browserWindow;
    }
    return DevToolsUtils.getTopWindow(this.chromeWindow);
  }

  get gViewSourceUtils() {
    return this.chromeUtilsWindow.gViewSourceUtils;
  }

  getFrontByID(id) {
    return this.commands.client.getFrontByID(id);
  }

  /**
   * Initialize the Web Console instance.
   *
   * @param {boolean} emitCreatedEvent: Defaults to true. If false is passed,
   *        We won't be sending the 'web-console-created' event.
   *
   * @return object
   *         A promise for the initialization.
   */
  async init(emitCreatedEvent = true) {
    await this.ui.init();

    // This event needs to be fired later in the case of the BrowserConsole
    if (emitCreatedEvent) {
      const id = Utils.supportsString(this.hudId);
      Services.obs.notifyObservers(id, "web-console-created");
    }
  }

  /**
   * The JSTerm object that manages the console's input.
   *
   * @see webconsole.js::JSTerm
   * @type object
   */
  get jsterm() {
    return this.ui ? this.ui.jsterm : null;
  }

  /**
   * Get the value from the input field.
   *
   * @returns {string | null} returns null if there's no input.
   */
  getInputValue() {
    if (!this.jsterm) {
      return null;
    }

    return this.jsterm._getValue();
  }

  inputHasSelection() {
    const { editor } = this.jsterm || {};
    return editor && !!editor.getSelectedText();
  }

  getInputSelection() {
    if (!this.jsterm || !this.jsterm.editor) {
      return null;
    }
    return this.jsterm.editor.getSelectedText();
  }

  /**
   * Sets the value of the input field (command line)
   *
   * @param {string} newValue: The new value to set.
   */
  setInputValue(newValue) {
    if (!this.jsterm) {
      return;
    }

    this.jsterm._setValue(newValue);
  }

  focusInput() {
    return this.jsterm && this.jsterm.focus();
  }

  /**
   * Open a link in a new tab.
   *
   * @param string link
   *        The URL you want to open in a new tab.
   */
  openLink(link, e = {}) {
    openDocLink(link, {
      relatedToCurrent: true,
      inBackground: isMacOS ? e.metaKey : e.ctrlKey,
    });
    if (e && typeof e.stopPropagation === "function") {
      e.stopPropagation();
    }
  }

  /**
   * Open a link in Firefox's view source.
   *
   * @param string sourceURL
   *        The URL of the file.
   * @param integer sourceLine
   *        The line number which should be highlighted.
   */
  viewSource(sourceURL, sourceLine) {
    this.gViewSourceUtils.viewSource({
      URL: sourceURL,
      lineNumber: sourceLine || -1,
    });
  }

  /**
   * Tries to open a JavaScript file related to the web page for the web console
   * instance in the Script Debugger. If the file is not found, it is opened in
   * source view instead.
   *
   * Manually handle the case where toolbox does not exist (Browser Console).
   *
   * @param string sourceURL
   *        The URL of the file.
   * @param integer sourceLine
   *        The line number which you want to place the caret.
   * @param integer sourceColumn
   *        The column number which you want to place the caret.
   */
  async viewSourceInDebugger(sourceURL, sourceLine, sourceColumn) {
    const { toolbox } = this;
    if (!toolbox) {
      this.viewSource(sourceURL, sourceLine, sourceColumn);
      return;
    }

    await toolbox.viewSourceInDebugger(sourceURL, sourceLine, sourceColumn);
    this.ui.emitForTests("source-in-debugger-opened");
  }

  /**
   * Retrieve information about the JavaScript debugger's currently selected stackframe.
   * is used to allow the Web Console to evaluate code in the selected stackframe.
   *
   * @return {string}
   *         The Frame Actor ID.
   *         If the debugger is not open or if it's not paused, then |null| is
   *         returned.
   */
  getSelectedFrameActorID() {
    const { toolbox } = this;
    if (!toolbox) {
      return null;
    }
    const panel = toolbox.getPanel("jsdebugger");

    if (!panel) {
      return null;
    }

    return panel.getSelectedFrameActorID();
  }

  /**
   * Given an expression, returns an object containing a new expression, mapped by the
   * parser worker to provide additional feature for the user (top-level await,
   * original languages mapping, …).
   *
   * @param {string} expression: The input to maybe map.
   * @returns {object | null}
   *          Returns null if the input can't be mapped.
   *          If it can, returns an object containing the following:
   *            - {String} expression: The mapped expression
   *            - {Object} mapped: An object containing the different mapping that could
   *                               be done and if they were applied on the input.
   *                               At the moment, contains `await`, `bindings` and
   *                               `originalExpression`.
   */
  getMappedExpression(expression) {
    const { toolbox } = this;

    // We need to check if the debugger is open, since it may perform a variable name
    // substitution for sourcemapped script (i.e. evaluated `myVar.trim()` might need to
    // be transformed into `a.trim()`).
    const panel = toolbox && toolbox.getPanel("jsdebugger");
    if (panel) {
      return panel.getMappedExpression(expression);
    }

    if (expression.includes("await ")) {
      const shouldMapBindings = false;
      const shouldMapAwait = true;
      const res = this.parserWorker.mapExpression(
        expression,
        null,
        null,
        shouldMapBindings,
        shouldMapAwait
      );
      return res;
    }

    return null;
  }

  getMappedVariables() {
    const { toolbox } = this;
    return toolbox?.getPanel("jsdebugger")?.getMappedVariables();
  }

  get parserWorker() {
    // If we have a toolbox, we could reuse the parser already instantiated for the debugger.
    // Note that we won't have a toolbox when running the Browser Console...
    if (this.toolbox) {
      return this.toolbox.parserWorker;
    }

    if (this._parserWorker) {
      return this._parserWorker;
    }

    const {
      ParserDispatcher,
    } = require("resource://devtools/client/debugger/src/workers/parser/index.js");

    this._parserWorker = new ParserDispatcher();
    return this._parserWorker;
  }

  /**
   * Retrieves the current selection from the Inspector, if such a selection
   * exists. This is used to pass the ID of the selected actor to the Web
   * Console server for the $0 helper.
   *
   * @return object|null
   *         A Selection referring to the currently selected node in the
   *         Inspector.
   *         If the inspector was never opened, or no node was ever selected,
   *         then |null| is returned.
   */
  getInspectorSelection() {
    const { toolbox } = this;
    if (!toolbox) {
      return null;
    }
    const panel = toolbox.getPanel("inspector");
    if (!panel || !panel.selection) {
      return null;
    }
    return panel.selection;
  }

  async onViewSourceInDebugger({ id, url, line, column }) {
    if (this.toolbox) {
      await this.toolbox.viewSourceInDebugger(url, line, column, id);

      this.recordEvent("jump_to_source");
      this.emitForTests("source-in-debugger-opened");
    }
  }

  async onViewSourceInStyleEditor({ url, line, column }) {
    if (!this.toolbox) {
      return;
    }
    await this.toolbox.viewSourceInStyleEditorByURL(url, line, column);
    this.recordEvent("jump_to_source");
  }

  async openNetworkPanel(requestId) {
    if (!this.toolbox) {
      return;
    }
    const netmonitor = await this.toolbox.selectTool("netmonitor");
    await netmonitor.panelWin.Netmonitor.inspectRequest(requestId);
  }

  getHighlighter() {
    if (!this.toolbox) {
      return null;
    }

    if (this._highlighter) {
      return this._highlighter;
    }

    this._highlighter = this.toolbox.getHighlighter();
    return this._highlighter;
  }

  async resendNetworkRequest(requestId) {
    if (!this.toolbox) {
      return;
    }

    const api = await this.toolbox.getNetMonitorAPI();
    await api.resendRequest(requestId);
  }

  async openNodeInInspector(grip) {
    if (!this.toolbox) {
      return;
    }

    const onSelectInspector = this.toolbox.selectTool(
      "inspector",
      "inspect_dom"
    );

    const onNodeFront = this.toolbox.target
      .getFront("inspector")
      .then(inspectorFront => inspectorFront.getNodeFrontFromNodeGrip(grip));

    const [nodeFront, inspectorPanel] = await Promise.all([
      onNodeFront,
      onSelectInspector,
    ]);

    const onInspectorUpdated = inspectorPanel.once("inspector-updated");
    const onNodeFrontSet = this.toolbox.selection.setNodeFront(nodeFront, {
      reason: "console",
    });

    await Promise.all([onNodeFrontSet, onInspectorUpdated]);
  }

  /**
   * Destroy the object. Call this method to avoid memory leaks when the Web
   * Console is closed.
   *
   * @return object
   *         A promise object that is resolved once the Web Console is closed.
   */
  destroy() {
    if (!this.hudId) {
      return;
    }

    if (this.ui) {
      this.ui.destroy();
    }

    if (this._parserWorker) {
      this._parserWorker.stop();
      this._parserWorker = null;
    }

    const id = Utils.supportsString(this.hudId);
    Services.obs.notifyObservers(id, "web-console-destroyed");
    this.hudId = null;

    this.emit("destroyed");
  }
}

module.exports = WebConsole;
