/* 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 { throttle } = require("resource://devtools/shared/throttle.js");

const { makeDebuggeeValue } = require("devtools/server/actors/object/utils");

const {
  TYPES,
  getResourceWatcher,
} = require("resource://devtools/server/actors/resources/index.js");
const { JSTRACER_TRACE } = TYPES;

const lazy = {};
ChromeUtils.defineESModuleGetters(
  lazy,
  {
    JSTracer: "resource://devtools/server/tracer/tracer.sys.mjs",
  },
  { global: "contextual" }
);

const {
  getActorIdForInternalSourceId,
} = require("resource://devtools/server/actors/utils/dbg-source.js");

const THROTTLING_DELAY = 250;

class ResourcesTracingListener {
  constructor({ targetActor, traceValues, traceActor }) {
    this.targetActor = targetActor;
    this.traceValues = traceValues;
    this.sourcesManager = targetActor.sourcesManager;
    this.traceActor = traceActor;

    // On workers, we don't have access to setTimeout and can't have throttling
    this.throttleEmitTraces = isWorker
      ? this.flushTraces.bind(this)
      : throttle(this.flushTraces.bind(this), THROTTLING_DELAY);
  }

  // Collect pending data to be sent to the client in various arrays,
  // each focusing on particular data type.
  // All these arrays contains arrays as elements.
  #throttledTraces = [];

  // Index of the next collected frame
  #frameIndex = 0;
  // Three level of Maps, ultimately storing frame indexes.
  // The first level of Map is keyed by source ID,
  // the second by line number,
  // the last by column number.
  // Frame objects are sent to the client and not being held in memory,
  // we only store their related indexes which are put in trace arrays.
  #frameMap = new Map();

  /**
   * Called when the tracer stops recording JS executions.
   */
  stop() {
    this.#frameIndex = 0;
    this.#frameMap.clear();
  }

  /**
   * This method is throttled and will notify all pending traces to be logged in the console
   * via the console message watcher.
   */
  flushTraces() {
    const traceWatcher = getResourceWatcher(this.targetActor, JSTRACER_TRACE);
    // Ignore the request if the frontend isn't listening to traces for that target.
    if (!traceWatcher) {
      return;
    }
    const traces = this.#throttledTraces;
    this.#throttledTraces = [];

    traceWatcher.emitTraces(traces);
  }

  /**
   * Be notified by the underlying JavaScriptTracer class
   * in case it stops by itself, instead of being stopped when the Actor's stopTracing
   * method is called by the user.
   *
   * @param {boolean} enabled
   *        True if the tracer starts tracing, false it it stops.
   * @return {boolean}
   *         Return true, if the JavaScriptTracer should log a message to stdout.
   */
  onTracingToggled(enabled) {
    if (!enabled) {
      this.traceActor.stopTracing();
    }
    return false;
  }

  /**
   * Called when "trace on next user interaction" is enabled, to notify the user
   * that the tracer is initialized but waiting for the user first input.
   */
  onTracingPending() {
    const consoleMessageWatcher = getResourceWatcher(
      this.targetActor,
      TYPES.CONSOLE_MESSAGE
    );
    if (consoleMessageWatcher) {
      consoleMessageWatcher.emitMessages([
        {
          arguments: [lazy.JSTracer.NEXT_INTERACTION_MESSAGE],
          styles: [],
          level: "jstracer",
          chromeContext: false,
          timeStamp: ChromeUtils.dateNow(),
        },
      ]);
    }
    return false;
  }

  /**
   * Called by JavaScriptTracer class when a new mutation happened on any DOM Element.
   *
   * @param {object} options
   * @param {number} options.depth
   *        Represents the depth of the frame in the call stack.
   * @param {string} options.prefix
   *        A string to be displayed as a prefix of any logged frame.
   * @param {nsIStackFrame} options.caller
   *        The JS Callsite which caused this mutation.
   * @param {string} options.type
   *        Type of DOM Mutation:
   *        - "add": Node being added,
   *        - "attributes": Node whose attributes changed,
   *        - "remove": Node being removed,
   * @param {DOMNode} options.element
   *        The DOM Node related to the current mutation.
   * @return {boolean}
   *         Return true, if the JavaScriptTracer should log a message to stdout.
   */
  onTracingDOMMutation({ depth, prefix, type, caller, element }) {
    const dbgObj = makeDebuggeeValue(this.targetActor, element);
    const frameIndex = this.#getFrameIndex(
      null,
      null,
      caller
        ? getActorIdForInternalSourceId(this.targetActor, caller.sourceId)
        : null,
      caller?.lineNumber,
      caller?.columnNumber,
      caller?.filename
    );
    this.#throttledTraces.push([
      "dom-mutation",
      prefix,
      frameIndex,
      ChromeUtils.dateNow(),
      depth,
      type,
      this.traceActor.createValueGrip(dbgObj),
    ]);
    this.throttleEmitTraces();
    return false;
  }

  /**
   * Called by JavaScriptTracer class on each step of a function call.
   *
   * @param {object} options
   * @param {Debugger.Frame} options.frame
   *        A descriptor object for the JavaScript frame.
   * @param {number} options.depth
   *        Represents the depth of the frame in the call stack.
   * @param {string} options.prefix
   *        A string to be displayed as a prefix of any logged frame.
   * @return {boolean}
   *         Return true, if the JavaScriptTracer should log the step to stdout.
   */
  onTracingFrameStep({ frame, depth, prefix }) {
    const { script } = frame;
    const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
    const url = script.source.url;

    // NOTE: Debugger.Script.prototype.getOffsetMetadata returns
    //       columnNumber in 1-based.
    //       Convert to 0-based, while keeping the wasm's column (1) as is.
    //       (bug 1863878)
    const columnBase = script.format === "wasm" ? 0 : 1;
    const column = columnNumber - columnBase;

    // Ignore blackboxed sources
    if (this.sourcesManager.isBlackBoxed(url, lineNumber, column)) {
      return false;
    }

    const frameIndex = this.#getFrameIndex(
      frame.implementation,
      null,
      getActorIdForInternalSourceId(this.targetActor, script.source.id),
      lineNumber,
      column,
      url
    );
    this.#throttledTraces.push([
      "step",
      prefix,
      frameIndex,
      ChromeUtils.dateNow(),
      depth,
      null,
    ]);
    this.throttleEmitTraces();

    return false;
  }

  #getFrameIndex(implementation, name, sourceId, line, column, url) {
    let perSourceMap = this.#frameMap.get(sourceId);
    if (!perSourceMap) {
      perSourceMap = new Map();
      this.#frameMap.set(sourceId, perSourceMap);
    }
    let perLineMap = perSourceMap.get(line);
    if (!perLineMap) {
      perLineMap = new Map();
      perSourceMap.set(line, perLineMap);
    }
    let frameIndex = perLineMap.get(column);

    if (frameIndex == undefined) {
      frameIndex = this.#frameIndex++;

      // Remember updating TRACER_FIELDS_INDEXES when modifying the following array:
      const frameArray = [
        "frame",
        implementation,
        name,
        sourceId,
        line,
        column,
        url,
      ];

      perLineMap.set(column, frameIndex);
      this.#throttledTraces.push(frameArray);
    }
    return frameIndex;
  }

  /**
   * Called by JavaScriptTracer class when a new JavaScript frame is executed.
   *
   * @param {Debugger.Frame} frame
   *        A descriptor object for the JavaScript frame.
   * @param {number} depth
   *        Represents the depth of the frame in the call stack.
   * @param {string} formatedDisplayName
   *        A human readable name for the current frame.
   * @param {string} prefix
   *        A string to be displayed as a prefix of any logged frame.
   * @param {string} currentDOMEvent
   *        If this is a top level frame (depth==0), and we are currently processing
   *        a DOM Event, this will refer to the name of that DOM Event.
   *        Note that it may also refer to setTimeout and setTimeout callback calls.
   * @return {boolean}
   *         Return true, if the JavaScriptTracer should log the frame to stdout.
   */
  onTracingFrame({
    frame,
    depth,
    formatedDisplayName,
    prefix,
    currentDOMEvent,
  }) {
    const { script } = frame;
    const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
    const url = script.source.url;

    // NOTE: Debugger.Script.prototype.getOffsetMetadata returns
    //       columnNumber in 1-based.
    //       Convert to 0-based, while keeping the wasm's column (1) as is.
    //       (bug 1863878)
    const columnBase = script.format === "wasm" ? 0 : 1;
    const column = columnNumber - columnBase;

    // Ignore blackboxed sources
    if (this.sourcesManager.isBlackBoxed(url, lineNumber, column)) {
      return false;
    }

    // We may receive the currently processed DOM event (if this relates to one).
    // In this case, log a preliminary message, which looks different to highlight it.
    if (currentDOMEvent && depth == 0) {
      // Create a JSTRACER_TRACE resource with a slightly different shape
      this.#throttledTraces.push([
        "event",
        prefix,
        null,
        ChromeUtils.dateNow(),
        // Events are parent of any subsequent JS call, which has a 0 depth.
        -1,
        currentDOMEvent,
      ]);
    }

    let args = undefined,
      argNames = undefined;
    // Log arguments, but only when this feature is enabled as it introduce
    // some significant overhead in perf as well as memory as it may hold the objects in memory.
    // Also prevent trying to log function call arguments if we aren't logging a frame
    // with arguments (e.g. Debugger evaluation frames, when executing from the console)
    if (this.traceValues && frame.arguments) {
      args = [];
      for (let arg of frame.arguments) {
        // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
        if (arg?.unsafeDereference) {
          arg = arg.unsafeDereference();
        }
        // Instantiate a object actor so that the tools can easily inspect these objects
        const dbgObj = makeDebuggeeValue(this.targetActor, arg);
        args.push(this.traceActor.createValueGrip(dbgObj));
      }
      argNames = frame.callee.script.parameterNames;
    }

    // In order for getActorIdForInternalSourceId to work reliably,
    // we have to ensure creating a source actor for that source.
    // It happens on Google Docs that some evaled sources aren't registered?
    this.sourcesManager.getOrCreateSourceActor(script.source);

    const frameIndex = this.#getFrameIndex(
      frame.implementation,
      formatedDisplayName,
      getActorIdForInternalSourceId(this.targetActor, script.source.id),
      lineNumber,
      column,
      url
    );
    this.#throttledTraces.push([
      "enter",
      prefix,
      frameIndex,
      ChromeUtils.dateNow(),
      depth,
      args,
      argNames,
    ]);
    this.throttleEmitTraces();

    return false;
  }

  /**
   * Called by JavaScriptTracer class when a JavaScript frame exits (i.e. a function returns or throw).
   *
   * @param {object} options
   * @param {number} options.frameId
   *        Unique identifier for the current frame.
   *        This should match a frame notified via onTracingFrame.
   * @param {Debugger.Frame} options.frame
   *        A descriptor object for the JavaScript frame.
   * @param {number} options.depth
   *        Represents the depth of the frame in the call stack.
   * @param {string} options.formatedDisplayName
   *        A human readable name for the current frame.
   * @param {string} options.prefix
   *        A string to be displayed as a prefix of any logged frame.
   * @param {string} options.why
   *        A string to explain why the function stopped.
   *        See tracer.sys.mjs's FRAME_EXIT_REASONS.
   * @param {Debugger.Object|primitive} options.rv
   *        The returned value. It can be the returned value, or the thrown exception.
   *        It is either a primitive object, otherwise it is a Debugger.Object for any other JS Object type.
   * @return {boolean}
   *         Return true, if the JavaScriptTracer should log the frame to stdout.
   */
  onTracingFrameExit({
    frameId,
    frame,
    depth,
    formatedDisplayName,
    prefix,
    why,
    rv,
  }) {
    const { script } = frame;
    const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);
    const url = script.source.url;

    // NOTE: Debugger.Script.prototype.getOffsetMetadata returns
    //       columnNumber in 1-based.
    //       Convert to 0-based, while keeping the wasm's column (1) as is.
    //       (bug 1863878)
    const columnBase = script.format === "wasm" ? 0 : 1;
    const column = columnNumber - columnBase;

    // Ignore blackboxed sources
    if (this.sourcesManager.isBlackBoxed(url, lineNumber, column)) {
      return false;
    }

    let returnedValue = undefined;
    // Log arguments, but only when this feature is enabled as it introduce
    // some significant overhead in perf as well as memory as it may hold the objects in memory.
    if (this.traceValues) {
      // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
      if (rv?.unsafeDereference) {
        rv = rv.unsafeDereference();
      }
      // Instantiate a object actor so that the tools can easily inspect these objects
      const dbgObj = makeDebuggeeValue(this.targetActor, rv);
      returnedValue = this.traceActor.createValueGrip(dbgObj);
    }

    const frameIndex = this.#getFrameIndex(
      frame.implementation,
      formatedDisplayName,
      getActorIdForInternalSourceId(this.targetActor, script.source.id),
      lineNumber,
      column,
      url
    );
    this.#throttledTraces.push([
      "exit",
      prefix,
      frameIndex,
      ChromeUtils.dateNow(),
      depth,
      frameId,
      returnedValue,
      why,
    ]);
    this.throttleEmitTraces();

    return false;
  }
}

exports.ResourcesTracingListener = ResourcesTracingListener;
