/* 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/. */

import { addDebuggerToGlobal } from "resource://gre/modules/jsdebugger.sys.mjs";

import { ContentProcessDomain } from "chrome://remote/content/cdp/domains/ContentProcessDomain.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  executeSoon: "chrome://remote/content/shared/Sync.sys.mjs",
  isChromeFrame: "chrome://remote/content/shared/Stack.sys.mjs",
  ExecutionContext:
    "chrome://remote/content/cdp/domains/content/runtime/ExecutionContext.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "ConsoleAPIStorage", () => {
  return Cc["@mozilla.org/consoleAPI-storage;1"].getService(
    Ci.nsIConsoleAPIStorage
  );
});

// Import the `Debugger` constructor in the current scope
// eslint-disable-next-line mozilla/reject-globalThis-modification
addDebuggerToGlobal(globalThis);

const CONSOLE_API_LEVEL_MAP = {
  warn: "warning",
};

// Bug 1786299: Puppeteer needs specific error messages.
const ERROR_CONTEXT_NOT_FOUND = "Cannot find context with specified id";

class SetMap extends Map {
  constructor() {
    super();
    this._count = 1;
  }
  // Every key in the map is associated with a Set.
  // The first time `key` is used `obj.set(key, value)` maps `key` to
  // to `Set(value)`. Subsequent calls add more values to the Set for `key`.
  // Note that `obj.get(key)` will return undefined if there's no such key,
  // as in a regular Map.
  set(key, value) {
    const innerSet = this.get(key);
    if (innerSet) {
      innerSet.add(value);
    } else {
      super.set(key, new Set([value]));
    }
    this._count++;
    return this;
  }
  // used as ExecutionContext id
  get count() {
    return this._count;
  }
}

export class Runtime extends ContentProcessDomain {
  constructor(session) {
    super(session);
    this.enabled = false;

    // Map of all the ExecutionContext instances:
    // [id (Number) => ExecutionContext instance]
    this.contexts = new Map();
    // [innerWindowId (Number) => Set of ExecutionContext instances]
    this.innerWindowIdToContexts = new SetMap();

    this._onContextCreated = this._onContextCreated.bind(this);
    this._onContextDestroyed = this._onContextDestroyed.bind(this);

    // TODO Bug 1602083
    this.session.contextObserver.on("context-created", this._onContextCreated);
    this.session.contextObserver.on(
      "context-destroyed",
      this._onContextDestroyed
    );
  }

  destructor() {
    this.disable();

    this.session.contextObserver.off("context-created", this._onContextCreated);
    this.session.contextObserver.off(
      "context-destroyed",
      this._onContextDestroyed
    );

    super.destructor();
  }

  // commands

  async enable() {
    if (!this.enabled) {
      this.enabled = true;

      Services.console.registerListener(this);
      this.onConsoleLogEvent = this.onConsoleLogEvent.bind(this);
      lazy.ConsoleAPIStorage.addLogEventListener(
        this.onConsoleLogEvent,
        Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal)
      );

      // Spin the event loop in order to send the `executionContextCreated` event right
      // after we replied to `enable` request.
      lazy.executeSoon(() => {
        this._onContextCreated("context-created", {
          windowId: this.content.windowGlobalChild.innerWindowId,
          window: this.content,
          isDefault: true,
        });

        for (const message of lazy.ConsoleAPIStorage.getEvents()) {
          this.onConsoleLogEvent(message);
        }
      });
    }
  }

  disable() {
    if (this.enabled) {
      this.enabled = false;

      Services.console.unregisterListener(this);
      lazy.ConsoleAPIStorage.removeLogEventListener(this.onConsoleLogEvent);
    }
  }

  releaseObject(options = {}) {
    const { objectId } = options;

    let context = null;
    for (const ctx of this.contexts.values()) {
      if (ctx.hasRemoteObject(objectId)) {
        context = ctx;
        break;
      }
    }
    if (!context) {
      throw new Error(ERROR_CONTEXT_NOT_FOUND);
    }
    context.releaseObject(objectId);
  }

  /**
   * Calls function with given declaration on the given object.
   *
   * Object group of the result is inherited from the target object.
   *
   * @param {object} options
   * @param {string} options.functionDeclaration
   *     Declaration of the function to call.
   * @param {Array.<object>=} options.arguments
   *     Call arguments. All call arguments must belong to the same
   *     JavaScript world as the target object.
   * @param {boolean=} options.awaitPromise
   *     Whether execution should `await` for resulting value
   *     and return once awaited promise is resolved.
   * @param {number=} options.executionContextId
   *     Specifies execution context which global object will be used
   *     to call function on. Either executionContextId or objectId
   *     should be specified.
   * @param {string=} options.objectId
   *     Identifier of the object to call function on.
   *     Either objectId or executionContextId should be specified.
   * @param {boolean=} options.returnByValue
   *     Whether the result is expected to be a JSON object
   *     which should be sent by value.
   *
   * @returns {RemoteObject & { exeptionDetails?: ExceptionDetails }}
   */
  callFunctionOn(options = {}) {
    if (typeof options.functionDeclaration != "string") {
      throw new TypeError("functionDeclaration: string value expected");
    }
    if (
      typeof options.arguments != "undefined" &&
      !Array.isArray(options.arguments)
    ) {
      throw new TypeError("arguments: array value expected");
    }
    if (!["undefined", "boolean"].includes(typeof options.awaitPromise)) {
      throw new TypeError("awaitPromise: boolean value expected");
    }
    if (!["undefined", "number"].includes(typeof options.executionContextId)) {
      throw new TypeError("executionContextId: number value expected");
    }
    if (!["undefined", "string"].includes(typeof options.objectId)) {
      throw new TypeError("objectId: string value expected");
    }
    if (!["undefined", "boolean"].includes(typeof options.returnByValue)) {
      throw new TypeError("returnByValue: boolean value expected");
    }

    if (
      typeof options.executionContextId == "undefined" &&
      typeof options.objectId == "undefined"
    ) {
      throw new Error(
        "Either objectId or executionContextId must be specified"
      );
    }

    let context = null;
    // When an `objectId` is passed, we want to execute the function of a given object
    // So we first have to find its ExecutionContext
    if (options.objectId) {
      for (const ctx of this.contexts.values()) {
        if (ctx.hasRemoteObject(options.objectId)) {
          context = ctx;
          break;
        }
      }
    } else {
      context = this.contexts.get(options.executionContextId);
    }

    if (!context) {
      throw new Error(ERROR_CONTEXT_NOT_FOUND);
    }

    return context.callFunctionOn(
      options.functionDeclaration,
      options.arguments,
      options.returnByValue,
      options.awaitPromise,
      options.objectId
    );
  }

  /**
   * Evaluate expression on global object.
   *
   * @param {object} options
   * @param {string} options.expression
   *     Expression to evaluate.
   * @param {boolean=} options.awaitPromise
   *     Whether execution should `await` for resulting value
   *     and return once awaited promise is resolved.
   * @param {number=} options.contextId
   *     Specifies in which execution context to perform evaluation.
   *     If the parameter is omitted the evaluation will be performed
   *     in the context of the inspected page.
   * @param {boolean=} options.returnByValue
   *     Whether the result is expected to be a JSON object
   *     that should be sent by value. Defaults to false.
   * @param {boolean=} options.userGesture [unsupported]
   *     Whether execution should be treated as initiated by user in the UI.
   *
   * @returns {RemoteObject & { exeptionDetails?: ExceptionDetails }}
   *     The evaluation result, and optionally exception details.
   */
  evaluate(options = {}) {
    const {
      expression,
      awaitPromise = false,
      contextId,
      returnByValue = false,
    } = options;

    if (typeof expression != "string") {
      throw new Error("expression: string value expected");
    }
    if (!["undefined", "boolean"].includes(typeof options.awaitPromise)) {
      throw new TypeError("awaitPromise: boolean value expected");
    }
    if (typeof returnByValue != "boolean") {
      throw new Error("returnByValue: boolean value expected");
    }

    let context;
    if (typeof contextId != "undefined") {
      context = this.contexts.get(contextId);
      if (!context) {
        throw new Error(ERROR_CONTEXT_NOT_FOUND);
      }
    } else {
      context = this._getDefaultContextForWindow();
    }

    return context.evaluate(expression, awaitPromise, returnByValue);
  }

  getProperties(options = {}) {
    const { objectId, ownProperties } = options;

    for (const ctx of this.contexts.values()) {
      const debuggerObj = ctx.getRemoteObject(objectId);
      if (debuggerObj) {
        return ctx.getProperties({ objectId, ownProperties });
      }
    }
    return null;
  }

  /**
   * Internal methods: the following methods are not part of CDP;
   * note the _ prefix.
   */

  get _debugger() {
    if (this.__debugger) {
      return this.__debugger;
    }
    this.__debugger = new Debugger();
    return this.__debugger;
  }

  _buildExceptionStackTrace(stack) {
    const callFrames = [];

    while (
      stack &&
      stack.source !== "debugger eval code" &&
      !stack.source.startsWith("chrome://")
    ) {
      callFrames.push({
        functionName: stack.functionDisplayName,
        scriptId: stack.sourceId.toString(),
        url: stack.source,
        lineNumber: stack.line - 1,
        columnNumber: stack.column - 1,
      });
      stack = stack.parent || stack.asyncParent;
    }

    return {
      callFrames,
    };
  }

  _buildConsoleStackTrace(stack = []) {
    const callFrames = stack
      .filter(frame => !lazy.isChromeFrame(frame))
      .map(frame => {
        return {
          functionName: frame.functionName,
          scriptId: frame.sourceId.toString(),
          url: frame.filename,
          lineNumber: frame.lineNumber - 1,
          columnNumber: frame.columnNumber - 1,
        };
      });

    return {
      callFrames,
    };
  }

  _getRemoteObject(objectId) {
    for (const ctx of this.contexts.values()) {
      const debuggerObj = ctx.getRemoteObject(objectId);
      if (debuggerObj) {
        return debuggerObj;
      }
    }
    return null;
  }

  _serializeRemoteObject(debuggerObj, executionContextId) {
    const ctx = this.contexts.get(executionContextId);
    return ctx._toRemoteObject(debuggerObj);
  }

  _getRemoteObjectByNodeId(nodeId, executionContextId) {
    let debuggerObj = null;

    if (typeof executionContextId != "undefined") {
      const ctx = this.contexts.get(executionContextId);
      debuggerObj = ctx.getRemoteObjectByNodeId(nodeId);
    } else {
      for (const ctx of this.contexts.values()) {
        const obj = ctx.getRemoteObjectByNodeId(nodeId);
        if (obj) {
          debuggerObj = obj;
          break;
        }
      }
    }

    return debuggerObj;
  }

  _setRemoteObject(debuggerObj, context) {
    return context.setRemoteObject(debuggerObj);
  }

  _getDefaultContextForWindow(innerWindowId) {
    if (!innerWindowId) {
      innerWindowId = this.content.windowGlobalChild.innerWindowId;
    }
    const curContexts = this.innerWindowIdToContexts.get(innerWindowId);
    if (curContexts) {
      for (const ctx of curContexts) {
        if (ctx.isDefault) {
          return ctx;
        }
      }
    }
    return null;
  }

  _getContextsForFrame(frameId) {
    const frameContexts = [];
    for (const ctx of this.contexts.values()) {
      if (ctx.frameId == frameId) {
        frameContexts.push(ctx);
      }
    }
    return frameContexts;
  }

  _emitConsoleAPICalled(payload) {
    // Filter out messages that aren't coming from a valid inner window, or from
    // a different browser tab. Also messages of type "time", which are not
    // getting reported by Chrome.
    const curBrowserId = this.session.browsingContext.browserId;
    const win = Services.wm.getCurrentInnerWindowWithId(payload.innerWindowId);
    if (
      !win ||
      BrowsingContext.getFromWindow(win).browserId != curBrowserId ||
      payload.type === "time"
    ) {
      return;
    }

    const context = this._getDefaultContextForWindow();
    this.emit("Runtime.consoleAPICalled", {
      args: payload.arguments.map(arg => context._toRemoteObject(arg)),
      executionContextId: context?.id || 0,
      timestamp: payload.timestamp,
      type: payload.type,
      stackTrace: this._buildConsoleStackTrace(payload.stack),
    });
  }

  _emitExceptionThrown(payload) {
    // Filter out messages that aren't coming from a valid inner window, or from
    // a different browser tab. Also messages of type "time", which are not
    // getting reported by Chrome.
    const curBrowserId = this.session.browsingContext.browserId;
    const win = Services.wm.getCurrentInnerWindowWithId(payload.innerWindowId);
    if (!win || BrowsingContext.getFromWindow(win).browserId != curBrowserId) {
      return;
    }

    const context = this._getDefaultContextForWindow();
    this.emit("Runtime.exceptionThrown", {
      timestamp: payload.timestamp,
      exceptionDetails: {
        // Temporary placeholder to return a number.
        exceptionId: 0,
        text: payload.text,
        lineNumber: payload.lineNumber,
        columnNumber: payload.columnNumber,
        url: payload.url,
        stackTrace: this._buildExceptionStackTrace(payload.stack),
        executionContextId: context?.id || undefined,
      },
    });
  }

  /**
   * Helper method in order to instantiate the ExecutionContext for a given
   * DOM Window as well as emitting the related
   * `Runtime.executionContextCreated` event
   *
   * @param {string} name
   *     Event name
   * @param {object=} options
   * @param {number} options.windowId
   *     The inner window id of the newly instantiated document.
   * @param {Window} options.window
   *     The window object of the newly instantiated document.
   * @param {string=} options.contextName
   *     Human-readable name to describe the execution context.
   * @param {boolean=} options.isDefault
   *     Whether the execution context is the default one.
   * @param {string=} options.contextType
   *     "default" or "isolated"
   *
   * @returns {number} ID of created context
   */
  _onContextCreated(name, options = {}) {
    const {
      windowId,
      window,
      contextName = "",
      isDefault = true,
      contextType = "default",
    } = options;

    if (windowId === undefined) {
      throw new Error("windowId is required");
    }

    // allow only one default context per inner window
    if (isDefault && this.innerWindowIdToContexts.has(windowId)) {
      for (const ctx of this.innerWindowIdToContexts.get(windowId)) {
        if (ctx.isDefault) {
          return null;
        }
      }
    }

    const context = new lazy.ExecutionContext(
      this._debugger,
      window,
      this.innerWindowIdToContexts.count,
      isDefault
    );
    this.contexts.set(context.id, context);
    this.innerWindowIdToContexts.set(windowId, context);

    if (this.enabled) {
      this.emit("Runtime.executionContextCreated", {
        context: {
          id: context.id,
          origin: window.origin,
          name: contextName,
          auxData: {
            isDefault,
            frameId: context.frameId,
            type: contextType,
          },
        },
      });
    }

    return context.id;
  }

  /**
   * Helper method to destroy the ExecutionContext of the given id. Also emit
   * the related `Runtime.executionContextDestroyed` and
   * `Runtime.executionContextsCleared` events.
   * ContextObserver will call this method with either `id` or `frameId` argument
   * being set.
   *
   * @param {string} name
   *     Event name
   * @param {object=} options
   * @param {number} options.id
   *     The execution context id to destroy.
   * @param {number} options.windowId
   *     The inner-window id of the execution context to destroy.
   * @param {number} options.frameId
   *     The frame id of execution context to destroy.
   * Either `id` or `frameId` or `windowId` is passed.
   */
  _onContextDestroyed(name, { id, frameId, windowId }) {
    let contexts;
    if ([id, frameId, windowId].filter(id => !!id).length > 1) {
      throw new Error("Expects only *one* of id, frameId, windowId");
    }

    if (id) {
      contexts = [this.contexts.get(id)];
    } else if (frameId) {
      contexts = this._getContextsForFrame(frameId);
    } else {
      contexts = this.innerWindowIdToContexts.get(windowId) || [];
    }

    for (const ctx of contexts) {
      const isFrame = !!BrowsingContext.get(ctx.frameId).parent;

      ctx.destructor();
      this.contexts.delete(ctx.id);
      this.innerWindowIdToContexts.get(ctx.windowId).delete(ctx);

      if (this.enabled) {
        this.emit("Runtime.executionContextDestroyed", {
          executionContextId: ctx.id,
        });
      }

      if (this.innerWindowIdToContexts.get(ctx.windowId).size == 0) {
        this.innerWindowIdToContexts.delete(ctx.windowId);
        // Only emit when all the exeuction contexts were cleared for the
        // current browser / target, which means it should only be emitted
        // for a top-level browsing context reference.
        if (this.enabled && !isFrame) {
          this.emit("Runtime.executionContextsCleared");
        }
      }
    }
  }

  onConsoleLogEvent(message) {
    // From sendConsoleAPIMessage (toolkit/modules/Console.sys.mjs)
    this._emitConsoleAPICalled({
      arguments: message.arguments,
      innerWindowId: message.innerID,
      stack: message.stacktrace,
      timestamp: message.timeStamp,
      type: CONSOLE_API_LEVEL_MAP[message.level] || message.level,
    });
  }

  // nsIObserver

  /**
   * Takes a console message belonging to the current window and emits a
   * "exceptionThrown" event if it's a Javascript error, otherwise a
   * "consoleAPICalled" event.
   *
   * @param {nsIConsoleMessage} subject
   *     Console message.
   */
  observe(subject) {
    if (subject instanceof Ci.nsIScriptError && subject.hasException) {
      let entry = fromScriptError(subject);
      this._emitExceptionThrown(entry);
    }
  }

  // XPCOM

  get QueryInterface() {
    return ChromeUtils.generateQI(["nsIConsoleListener"]);
  }
}

function fromScriptError(error) {
  // From dom/bindings/nsIScriptError.idl
  return {
    innerWindowId: error.innerWindowID,
    columnNumber: error.columnNumber - 1,
    lineNumber: error.lineNumber - 1,
    stack: error.stack,
    text: error.errorMessage,
    timestamp: error.timeStamp,
    url: error.sourceName,
  };
}
