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

/**
 * This module implements the JavaScript tracer.
 *
 * It is being used by:
 * - any code that want to manually toggle the tracer, typically when debugging code,
 * - the tracer actor to start and stop tracing from DevTools UI,
 * - the tracing state resource watcher in order to notify DevTools UI about the tracing state.
 *
 * It will default logging the tracers to the terminal/stdout.
 * But if DevTools are opened, it may delegate the logging to the tracer actor.
 * It will typically log the traces to the Web Console.
 *
 * `JavaScriptTracer.onEnterFrame` method is hot codepath and should be reviewed accordingly.
 */

const NEXT_INTERACTION_MESSAGE =
  "Waiting for next user interaction before tracing (next mousedown or keydown event)";

const FRAME_EXIT_REASONS = {
  // The function has been early terminated by the Debugger API
  TERMINATED: "terminated",
  // The function simply ends by returning a value
  RETURN: "return",
  // The function yields a new value
  YIELD: "yield",
  // The function await on a promise
  AWAIT: "await",
  // The function throws an exception
  THROW: "throw",
};

const DOM_MUTATIONS = {
  // Track all DOM Node being added
  ADD: "add",
  // Track all attributes being modified
  ATTRIBUTES: "attributes",
  // Track all DOM Node being removed
  REMOVE: "remove",
};

const listeners = new Set();

// Detecting worker is different if this file is loaded via Common JS loader (isWorker global)
// or as a JSM (constructor name)
// eslint-disable-next-line no-shadow
const isWorker =
  globalThis.isWorker ||
  globalThis.constructor.name == "WorkerDebuggerGlobalScope";

// This module can be loaded from the worker thread, where we can't use ChromeUtils.
// So implement custom lazy getters (without XPCOMUtils ESM) from here.
// Worker codepath in DevTools will pass a custom Debugger instance.
const customLazy = {
  get Debugger() {
    // When this code runs in the worker thread, loaded via `loadSubScript`
    // (ex: browser_worker_tracer.js and WorkerDebugger.tracer.js),
    // this module runs within the WorkerDebuggerGlobalScope and have immediate access to Debugger class.
    if (globalThis.Debugger) {
      return globalThis.Debugger;
    }
    // When this code runs in the worker thread, loaded via `require`
    // (ex: from tracer actor module),
    // this module no longer has WorkerDebuggerGlobalScope as global,
    // but has to use require() to pull Debugger.
    if (isWorker) {
      // require is defined for workers.
      // eslint-disable-next-line no-undef
      return require("Debugger");
    }
    const { addDebuggerToGlobal } = ChromeUtils.importESModule(
      "resource://gre/modules/jsdebugger.sys.mjs"
    );
    // Avoid polluting all Modules global scope by using a Sandox as global.
    const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
    const debuggerSandbox = Cu.Sandbox(systemPrincipal);
    addDebuggerToGlobal(debuggerSandbox);
    delete customLazy.Debugger;
    customLazy.Debugger = debuggerSandbox.Debugger;
    return customLazy.Debugger;
  },

  get DistinctCompartmentDebugger() {
    const { addDebuggerToGlobal } = ChromeUtils.importESModule(
      "resource://gre/modules/jsdebugger.sys.mjs",
      { global: "contextual" }
    );
    const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
    const debuggerSandbox = Cu.Sandbox(systemPrincipal, {
      // As we may debug the JSM/ESM shared global, we should be using a Debugger
      // from another system global.
      freshCompartment: true,
    });
    addDebuggerToGlobal(debuggerSandbox);
    delete customLazy.DistinctCompartmentDebugger;
    customLazy.DistinctCompartmentDebugger = debuggerSandbox.Debugger;
    return customLazy.DistinctCompartmentDebugger;
  },
};

/**
 * Start tracing against a given JS global.
 * Only code run from that global will be logged.
 *
 * @param {object} options
 *        Object with configurations:
 * @param {object} options.global
 *        The tracer only log traces related to the code executed within this global.
 *        When omitted, it will default to the options object's global.
 * @param {boolean} options.traceAllGlobals
 *        When set to true, this will trace all the globals running in the current thread.
 * @param {string} options.prefix
 *        Optional string logged as a prefix to all traces.
 * @param {boolean} options.loggingMethod
 *        Optional setting to use something else than `dump()` to log traces to stdout.
 *        This is mostly used by tests.
 * @param {boolean} options.traceDOMEvents
 *        Optional setting to enable tracing all the DOM events being going through
 *        dom/events/EventListenerManager.cpp's `EventListenerManager`.
 * @param {Array<string>} options.traceDOMMutations
 *        Optional setting to enable tracing all the DOM mutations.
 *        This array may contains three strings:
 *          - "add": trace all new DOM Node being added,
 *          - "attributes": trace all DOM attribute modifications,
 *          - "delete": trace all DOM Node being removed.
 * @param {boolean} options.traceValues
 *        Optional setting to enable tracing all function call values as well,
 *        as returned values (when we do log returned frames).
 * @param {boolean} options.traceOnNextInteraction
 *        Optional setting to enable when the tracing should only start when the
 *        use starts interacting with the page. i.e. on next keydown or mousedown.
 * @param {boolean} options.traceSteps
 *        Optional setting to enable tracing each frame within a function execution.
 *        (i.e. not only function call and function returns [when traceFunctionReturn is true])
 * @param {boolean} options.traceFunctionReturn
 *        Optional setting to enable when the tracing should notify about frame exit.
 *        i.e. when a function call returns or throws.
 * @param {string} options.filterFrameSourceUrl
 *        Optional setting to restrict all traces to only a given source URL.
 *        This is a loose check, so any source whose URL includes the passed string will be traced.
 * @param {number} options.maxDepth
 *        Optional setting to ignore frames when depth is greater than the passed number.
 * @param {number} options.maxRecords
 *        Optional setting to stop the tracer after having recorded at least
 *        the passed number of top level frames.
 * @param {number} options.pauseOnStep
 *        Optional setting to delay each frame execution for a given amount of time in ms.
 */
class JavaScriptTracer {
  constructor(options) {
    this.onEnterFrame = this.onEnterFrame.bind(this);

    // DevTools CommonJS Workers modules don't have access to AbortController
    if (!isWorker) {
      this.abortController = new AbortController();
    }

    if (options.traceAllGlobals) {
      this.traceAllGlobals = true;
      if (options.traceOnNextInteraction) {
        throw new Error(
          "Tracing all globals and waiting for next user interaction are not yet compatible"
        );
      }
      if (this.traceDOMEvents) {
        throw new Error(
          "Tracing all globals and DOM Events are not yet compatible"
        );
      }
      if (options.global) {
        throw new Error(
          "'global' option should be omitted when using 'traceAllGlobals'"
        );
      }
    } else {
      // By default, we would trace only JavaScript related to caller's global.
      // As there is no way to compute the caller's global default to the global of the
      // mandatory options argument.
      this.tracedGlobal = options.global || Cu.getGlobalForObject(options);
    }

    // Instantiate a brand new Debugger API so that we can trace independently
    // of all other DevTools operations. i.e. we can pause while tracing without any interference.
    this.dbg = this.makeDebugger();

    this.prefix = options.prefix ? `${options.prefix}: ` : "";

    // List of all async frame which are poped per Spidermonkey API
    // but are actually waiting for async operation.
    // We should later enter them again when the async task they are being waiting for is completed.
    this.pendingAwaitFrames = new Set();

    this.loggingMethod = options.loggingMethod;
    if (!this.loggingMethod) {
      // On workers, `dump` can't be called with JavaScript on another object,
      // so bind it.
      this.loggingMethod = isWorker ? dump.bind(null) : dump;
    }

    this.traceDOMEvents = !!options.traceDOMEvents;

    if (options.traceDOMMutations) {
      if (!Array.isArray(options.traceDOMMutations)) {
        throw new Error("'traceDOMMutations' attribute should be an array");
      }
      const acceptedValues = Object.values(DOM_MUTATIONS);
      if (!options.traceDOMMutations.every(e => acceptedValues.includes(e))) {
        throw new Error(
          `'traceDOMMutations' only accept array of strings whose values can be: ${acceptedValues}`
        );
      }
      this.traceDOMMutations = options.traceDOMMutations;
    }
    this.traceSteps = !!options.traceSteps;
    this.traceValues = !!options.traceValues;
    this.traceFunctionReturn = !!options.traceFunctionReturn;
    this.maxDepth = options.maxDepth;
    this.maxRecords = options.maxRecords;
    this.records = 0;
    if ("pauseOnStep" in options) {
      if (typeof options.pauseOnStep != "number") {
        throw new Error("'pauseOnStep' attribute should be a number");
      }
      this.pauseOnStep = options.pauseOnStep;
    }
    if ("filterFrameSourceUrl" in options) {
      if (typeof options.filterFrameSourceUrl != "string") {
        throw new Error("'filterFrameSourceUrl' attribute should be a string");
      }
      this.filterFrameSourceUrl = options.filterFrameSourceUrl;
    }

    // An increment used to identify function calls and their returned/exit frames
    this.frameId = 0;

    // This feature isn't supported on Workers as they aren't involving user events
    if (options.traceOnNextInteraction && !isWorker) {
      this.#waitForNextInteraction();
    } else {
      this.#startTracing();
    }
  }

  // Is actively tracing?
  // We typically start tracing from the constructor, unless the "trace on next user interaction" feature is used.
  isTracing = false;

  /**
   * In case `traceOnNextInteraction` option is used, delay the actual start of tracing until a first user interaction.
   */
  #waitForNextInteraction() {
    // Use a dedicated Abort Controller as we are going to stop it as soon as we get the first user interaction,
    // whereas other listeners would typically wait for tracer stop.
    this.nextInteractionAbortController = new AbortController();

    const listener = () => {
      this.nextInteractionAbortController.abort();
      // Avoid tracing if the users asked to stop tracing while we were waiting for the user interaction.
      if (this.dbg) {
        this.#startTracing();
      }
    };
    const eventOptions = {
      signal: this.nextInteractionAbortController.signal,
      capture: true,
    };
    // Register the event listener on the Chrome Event Handler in order to receive the event first.
    // When used for the parent process target, `tracedGlobal` is browser.xhtml's window, which doesn't have a chromeEventHandler.
    const eventHandler =
      this.tracedGlobal.docShell.chromeEventHandler || this.tracedGlobal;
    eventHandler.addEventListener("mousedown", listener, eventOptions);
    eventHandler.addEventListener("keydown", listener, eventOptions);

    // Significate to the user that the tracer is registered, but not tracing just yet.
    let shouldLogToStdout = listeners.size == 0;
    for (const l of listeners) {
      if (typeof l.onTracingPending == "function") {
        shouldLogToStdout |= l.onTracingPending();
      }
    }
    if (shouldLogToStdout) {
      this.loggingMethod(this.prefix + NEXT_INTERACTION_MESSAGE + "\n");
    }
  }

  /**
   * Actually really start watching for executions.
   *
   * This may be delayed when traceOnNextInteraction options is used.
   * Otherwise we start tracing as soon as the class instantiates.
   */
  #startTracing() {
    this.isTracing = true;

    this.dbg.onEnterFrame = this.onEnterFrame;

    if (this.traceDOMEvents) {
      this.startTracingDOMEvents();
    }
    // This feature isn't supported on Workers as they aren't interacting with the DOM Tree
    if (this.traceDOMMutations?.length > 0 && !isWorker) {
      this.startTracingDOMMutations();
    }

    // In any case, we consider the tracing as started
    this.notifyToggle(true);
  }

  startTracingDOMEvents() {
    this.debuggerNotificationObserver = new DebuggerNotificationObserver();
    this.eventListener = this.eventListener.bind(this);
    this.debuggerNotificationObserver.addListener(this.eventListener);
    this.debuggerNotificationObserver.connect(this.tracedGlobal);

    // When we are tracing a document, also ensure connecting to all its children iframe globals.
    // If we don't, Debugger API would fire onEnterFrame for their JavaScript code,
    // but DOM Events wouldn't be notified by DebuggerNotificationObserver.
    if (!isWorker && this.tracedGlobal instanceof Ci.nsIDOMWindow) {
      const { browserId } = this.tracedGlobal.browsingContext;
      // Keep track of any future global
      this.dbg.onNewGlobalObject = g => {
        try {
          const win = g.unsafeDereference();
          // only process globals relating to documents, and which are within the debugged tab
          if (
            win instanceof Ci.nsIDOMWindow &&
            win.browsingContext.browserId == browserId
          ) {
            this.dbg.addDebuggee(g);
            this.debuggerNotificationObserver.connect(win);
          }
        } catch (e) {}
      };
      // Register all, already existing children
      for (const browsingContext of this.tracedGlobal.browsingContext.getAllBrowsingContextsInSubtree()) {
        try {
          // Only consider children which run in the same process, and exposes their window object
          if (browsingContext.window) {
            this.dbg.addDebuggee(browsingContext.window);
            this.debuggerNotificationObserver.connect(browsingContext.window);
          }
        } catch (e) {}
      }
    }

    this.currentDOMEvent = null;
  }

  stopTracingDOMEvents() {
    if (this.debuggerNotificationObserver) {
      this.debuggerNotificationObserver.removeListener(this.eventListener);
      this.debuggerNotificationObserver.disconnect(this.tracedGlobal);
      this.debuggerNotificationObserver = null;
    }
    this.currentDOMEvent = null;
  }

  startTracingDOMMutations() {
    this.tracedGlobal.document.devToolsWatchingDOMMutations = true;

    const eventOptions = {
      signal: this.abortController.signal,
      capture: true,
    };
    // When used for the parent process target, `tracedGlobal` is browser.xhtml's window, which doesn't have a chromeEventHandler.
    const eventHandler =
      this.tracedGlobal.docShell.chromeEventHandler || this.tracedGlobal;
    if (this.traceDOMMutations.includes(DOM_MUTATIONS.ADD)) {
      eventHandler.addEventListener(
        "devtoolschildinserted",
        this.#onDOMMutation,
        eventOptions
      );
    }
    if (this.traceDOMMutations.includes(DOM_MUTATIONS.ATTRIBUTES)) {
      eventHandler.addEventListener(
        "devtoolsattrmodified",
        this.#onDOMMutation,
        eventOptions
      );
    }
    if (this.traceDOMMutations.includes(DOM_MUTATIONS.REMOVE)) {
      eventHandler.addEventListener(
        "devtoolschildremoved",
        this.#onDOMMutation,
        eventOptions
      );
    }
  }

  stopTracingDOMMutations() {
    this.tracedGlobal.document.devToolsWatchingDOMMutations = false;
    // Note that the event listeners are all going to be unregistered via the AbortController.
  }

  /**
   * Called for any DOM Mutation done in the traced document.
   *
   * @param {DOM Event} event
   */
  #onDOMMutation = event => {
    // Ignore elements inserted by DevTools, like the inspector's highlighters
    if (event.target.isNativeAnonymous) {
      return;
    }

    let type = "";
    switch (event.type) {
      case "devtoolschildinserted":
        type = DOM_MUTATIONS.ADD;
        break;
      case "devtoolsattrmodified":
        type = DOM_MUTATIONS.ATTRIBUTES;
        break;
      case "devtoolschildremoved":
        type = DOM_MUTATIONS.REMOVE;
        break;
      default:
        throw new Error("Unexpected DOM Mutation event type: " + event.type);
    }

    let shouldLogToStdout = true;

    // The depth is the depth of the parent frame, consider the dom mutation as nested to it
    const depth = this.depth + 1;

    if (listeners.size > 0) {
      shouldLogToStdout = false;
      for (const listener of listeners) {
        // If any listener return true, also log to stdout
        if (typeof listener.onTracingDOMMutation == "function") {
          shouldLogToStdout |= listener.onTracingDOMMutation({
            depth,
            prefix: this.prefix,

            type,
            element: event.target,
            caller: Components.stack.caller,
          });
        }
      }
    }

    if (shouldLogToStdout) {
      const padding = "—".repeat(depth + 1);
      this.loggingMethod(
        this.prefix +
          padding +
          `[DOM Mutation | ${type}] ` +
          objectToString(event.target) +
          "\n"
      );
    }
  };

  /**
   * Called by DebuggerNotificationObserver interface when a DOM event start being notified
   * and after it has been notified.
   *
   * @param {DebuggerNotification} notification
   *        Info about the DOM event. See the related idl file.
   */
  eventListener(notification) {
    // For each event we get two notifications.
    // One just before firing the listeners and another one just after.
    //
    // Update `this.currentDOMEvent` to be refering to the event name
    // while the DOM event is being notified. It will be null the rest of the time.
    //
    // We don't need to maintain a stack of events as that's only consumed by onEnterFrame
    // which only cares about the very lastest event being currently trigerring some code.
    if (notification.phase == "pre") {
      // We get notified about "real" DOM event when type is "domEvent",
      // but also when some other DOM APIs are involved.
      // notification's type will be "setTimeout" when the setTimeout method is called,
      // or "setTimeoutCallback" when the callback passed to setTimeout is called.
      // This also work against setInterval/clearTimeout/clearInterval and requestAnimationFrame.
      if (notification.type == "domEvent") {
        // `targetType` can help distinguish same-name DOM events fired against XHR, window or workers.
        const { targetType } = notification;
        let { type } = notification.event;
        if (!type) {
          // In the Worker thread, `notification.event` is an opaque wrapper.
          // In other threads it is a Xray wrapper.
          // Because of this difference, we have to fallback to use the Debugger.Object API.
          type = this.dbg
            .makeGlobalObjectReference(notification.global)
            .makeDebuggeeValue(notification.event)
            .getProperty("type").return;
        }
        this.currentDOMEvent = `${targetType}.${type}`;
      } else {
        this.currentDOMEvent = notification.type;
      }
    } else {
      this.currentDOMEvent = null;
    }
  }

  /**
   * Stop observing execution.
   *
   * @param {string} reason
   *        Optional string to justify why the tracer stopped.
   */
  stopTracing(reason = "") {
    // Note that this may be called before `#startTracing()`, but still want to completely shut it down.
    if (!this.dbg) {
      return;
    }

    this.dbg.onEnterFrame = undefined;

    this.dbg.removeAllDebuggees();
    this.dbg.onNewGlobalObject = undefined;
    this.dbg = null;

    this.depth = 0;

    // Cancel the traceOnNextInteraction event listeners.
    if (this.nextInteractionAbortController) {
      this.nextInteractionAbortController.abort();
      this.nextInteractionAbortController = null;
    }

    if (this.traceDOMEvents) {
      this.stopTracingDOMEvents();
    }
    if (this.traceDOMMutations?.length > 0 && !isWorker) {
      this.stopTracingDOMMutations();
    }

    // Unregister all event listeners
    if (this.abortController) {
      this.abortController.abort();
    }

    this.tracedGlobal = null;
    this.isTracing = false;

    this.notifyToggle(false, reason);
  }

  /**
   * Instantiate a Debugger API instance dedicated to each Tracer instance.
   * It will notably be different from the instance used in DevTools.
   * This allows to implement tracing independently of DevTools.
   */
  makeDebugger() {
    if (this.traceAllGlobals) {
      const dbg = new customLazy.DistinctCompartmentDebugger();
      dbg.addAllGlobalsAsDebuggees();

      // addAllGlobalAsAdebuggees will also add the global for this module...
      // which we have to prevent tracing!
      // eslint-disable-next-line mozilla/reject-globalThis-modification
      dbg.removeDebuggee(globalThis);

      // Add any future global being created later
      dbg.onNewGlobalObject = g => dbg.addDebuggee(g);
      return dbg;
    }

    // When this code runs in the worker thread, Cu isn't available
    // and we don't have system principal anyway in this context.
    const { isSystemPrincipal } =
      typeof Cu == "object" ? Cu.getObjectPrincipal(this.tracedGlobal) : {};

    // When debugging the system modules, we have to use a special instance
    // of Debugger loaded in a distinct system global.
    const dbg = isSystemPrincipal
      ? new customLazy.DistinctCompartmentDebugger()
      : new customLazy.Debugger();

    // For now, we only trace calls for one particular global at a time.
    // See the constructor for its definition.
    dbg.addDebuggee(this.tracedGlobal);

    return dbg;
  }

  /**
   * Notify DevTools and/or the user via stdout that tracing
   * has been enabled or disabled.
   *
   * @param {boolean} state
   *        True if we just started tracing, false when it just stopped.
   * @param {string} reason
   *        Optional string to justify why the tracer stopped.
   */
  notifyToggle(state, reason) {
    let shouldLogToStdout = listeners.size == 0;
    for (const listener of listeners) {
      if (typeof listener.onTracingToggled == "function") {
        shouldLogToStdout |= listener.onTracingToggled(state, reason);
      }
    }
    if (shouldLogToStdout) {
      if (state) {
        this.loggingMethod(this.prefix + "Start tracing JavaScript\n");
      } else {
        if (reason) {
          reason = ` (reason: ${reason})`;
        }
        this.loggingMethod(
          this.prefix + "Stop tracing JavaScript" + reason + "\n"
        );
      }
    }
  }

  /**
   * Called by the Debugger API (this.dbg) when a new frame is executed.
   *
   * @param {Debugger.Frame} frame
   *        A descriptor object for the JavaScript frame.
   */
  onEnterFrame(frame) {
    // Safe check, just in case we keep being notified, but the tracer has been stopped
    if (!this.dbg) {
      return;
    }
    try {
      // If an optional filter is passed, ignore frames which aren't matching the filter string
      if (
        this.filterFrameSourceUrl &&
        !frame.script.source.url?.includes(this.filterFrameSourceUrl)
      ) {
        return;
      }

      // Because of async frame which are popped and entered again on completion of the awaited async task,
      // we have to compute the depth from the frame. (and can't use a simple increment on enter/decrement on pop).
      const depth = getFrameDepth(frame);

      // Save the current depth for the DOM Mutation handler
      this.depth = depth;

      // Ignore the frame if we reached the depth limit (if one is provided)
      if (this.maxDepth && depth >= this.maxDepth) {
        return;
      }

      // When we encounter a frame which was previously popped because of pending on an async task,
      // ignore it and only log the following ones.
      if (this.pendingAwaitFrames.has(frame)) {
        this.pendingAwaitFrames.delete(frame);
        return;
      }

      // Auto-stop the tracer if we reached the number of max recorded top level frames
      if (depth === 0 && this.maxRecords) {
        if (this.records >= this.maxRecords) {
          this.stopTracing("max-records");
          return;
        }
        this.records++;
      }

      const frameId = this.frameId++;
      let shouldLogToStdout = true;

      // If there is at least one DevTools debugging this process,
      // delegate logging to DevTools actors.
      if (listeners.size > 0) {
        shouldLogToStdout = false;
        const formatedDisplayName = formatDisplayName(frame);
        for (const listener of listeners) {
          // If any listener return true, also log to stdout
          if (typeof listener.onTracingFrame == "function") {
            shouldLogToStdout |= listener.onTracingFrame({
              frameId,
              frame,
              depth,
              formatedDisplayName,
              prefix: this.prefix,
              currentDOMEvent: this.currentDOMEvent,
            });
          }
          // Bail out early if any listener stopped tracing as the Frame object
          // will be no longer usable by any other code.
          if (!this.isTracing) {
            return;
          }
        }
      }

      // DevTools may delegate the work to log to stdout,
      // but if DevTools are closed, stdout is the only way to log the traces.
      if (shouldLogToStdout) {
        this.logFrameEnteredToStdout(frame, depth);
      }

      if (this.traceSteps) {
        // Collect the location notified via onTracingFrame to also avoid redundancy between similar location
        // between onEnterFrame and onStep notifications.
        let { lineNumber: lastLine, columnNumber: lastColumn } =
          frame.script.getOffsetMetadata(frame.offset);

        frame.onStep = () => {
          // Spidermonkey steps on many intermediate positions which don't make sense to the user.
          // `isStepStart` is close to each statement start, which is meaningful to the user.
          const { isStepStart, lineNumber, columnNumber } =
            frame.script.getOffsetMetadata(frame.offset);
          if (!isStepStart) {
            return;
          }
          // onStep may be called on many instructions related to the same line and colunm.
          // Avoid notifying duplicated steps if we stepped on the exact same location.
          if (lastLine == lineNumber && lastColumn == columnNumber) {
            return;
          }
          lastLine = lineNumber;
          lastColumn = columnNumber;

          shouldLogToStdout = true;
          if (listeners.size > 0) {
            shouldLogToStdout = false;
            for (const listener of listeners) {
              // If any listener return true, also log to stdout
              if (typeof listener.onTracingFrameStep == "function") {
                shouldLogToStdout |= listener.onTracingFrameStep({
                  frame,
                  depth,
                  prefix: this.prefix,
                });
              }
            }
          }
          if (shouldLogToStdout) {
            this.logFrameStepToStdout(frame, depth);
          }
          // Optionaly pause the frame execution by letting the other event loop to run in between.
          if (typeof this.pauseOnStep == "number") {
            syncPause(this.pauseOnStep);
          }
        };
      }

      frame.onPop = completion => {
        this.depth--;

        // Special case async frames. We are exiting the current frame because of waiting for an async task.
        // (this is typically a `await foo()` from an async function)
        // This frame should later be "entered" again.
        if (completion?.await) {
          this.pendingAwaitFrames.add(frame);
          return;
        }

        if (!this.traceFunctionReturn) {
          return;
        }

        let why = "";
        let rv = undefined;
        if (!completion) {
          why = FRAME_EXIT_REASONS.TERMINATED;
        } else if ("return" in completion) {
          why = FRAME_EXIT_REASONS.RETURN;
          rv = completion.return;
        } else if ("yield" in completion) {
          why = FRAME_EXIT_REASONS.YIELD;
          rv = completion.yield;
        } else if ("await" in completion) {
          why = FRAME_EXIT_REASONS.AWAIT;
        } else {
          why = FRAME_EXIT_REASONS.THROW;
          rv = completion.throw;
        }

        shouldLogToStdout = true;
        if (listeners.size > 0) {
          shouldLogToStdout = false;
          const formatedDisplayName = formatDisplayName(frame);
          for (const listener of listeners) {
            // If any listener return true, also log to stdout
            if (typeof listener.onTracingFrameExit == "function") {
              shouldLogToStdout |= listener.onTracingFrameExit({
                frameId,
                frame,
                depth,
                formatedDisplayName,
                prefix: this.prefix,
                why,
                rv,
              });
            }
          }
        }
        if (shouldLogToStdout) {
          this.logFrameExitedToStdout(frame, depth, why, rv);
        }
      };

      // Optionaly pause the frame execution by letting the other event loop to run in between.
      if (typeof this.pauseOnStep == "number") {
        syncPause(this.pauseOnStep);
      }
    } catch (e) {
      console.error("Exception while tracing javascript", e);
    }
  }

  /**
   * Display to stdout one given frame execution, which represents a function call.
   *
   * @param {Debugger.Frame} frame
   * @param {number} depth
   */
  logFrameEnteredToStdout(frame, depth) {
    const padding = "—".repeat(depth + 1);

    // If we are tracing DOM events and we are in middle of an event,
    // and are logging the topmost frame,
    // then log a preliminary dedicated line to mention that event type.
    if (this.currentDOMEvent && depth == 0) {
      this.loggingMethod(
        this.prefix + padding + "DOM | " + this.currentDOMEvent + "\n"
      );
    }

    let message = `${padding}[${frame.implementation}]—> ${getTerminalHyperLink(
      frame
    )} - ${formatDisplayName(frame)}`;

    // Log arguments, but only when this feature is enabled as it introduces
    // some significant performance and visual overhead.
    // 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) {
      message += "(";
      for (let i = 0, l = frame.arguments.length; i < l; i++) {
        const arg = frame.arguments[i];
        // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
        if (arg?.unsafeDereference) {
          // Special case classes as they can't be easily differentiated in pure JavaScript
          if (arg.isClassConstructor) {
            message += "class " + arg.name;
          } else {
            message += objectToString(arg.unsafeDereference());
          }
        } else {
          message += primitiveToString(arg);
        }

        if (i < l - 1) {
          message += ", ";
        }
      }
      message += ")";
    }

    this.loggingMethod(this.prefix + message + "\n");
  }

  /**
   * Display to stdout one given frame execution, which represents a step within a function execution.
   *
   * @param {Debugger.Frame} frame
   * @param {number} depth
   */
  logFrameStepToStdout(frame, depth) {
    const padding = "—".repeat(depth + 1);

    const message = `${padding}— ${getTerminalHyperLink(frame)}`;

    this.loggingMethod(this.prefix + message + "\n");
  }

  /**
   * Display to stdout the exit of a given frame execution, which represents a function return.
   *
   * @param {Debugger.Frame} frame
   * @param {string} why
   * @param {number} depth
   */
  logFrameExitedToStdout(frame, depth, why, rv) {
    const padding = "—".repeat(depth + 1);

    let message = `${padding}[${frame.implementation}]<— ${getTerminalHyperLink(
      frame
    )} - ${formatDisplayName(frame)} ${why}`;

    // Log returned values, but only when this feature is enabled as it introduces
    // some significant performance and visual overhead.
    if (this.traceValues) {
      message += " ";
      // Debugger.Frame.arguments contains either a Debugger.Object or primitive object
      if (rv?.unsafeDereference) {
        // Special case classes as they can't be easily differentiated in pure JavaScript
        if (rv.isClassConstructor) {
          message += "class " + rv.name;
        } else {
          message += objectToString(rv.unsafeDereference());
        }
      } else {
        message += primitiveToString(rv);
      }
    }

    this.loggingMethod(this.prefix + message + "\n");
  }
}

/**
 * Return a string description for any arbitrary JS value.
 * Used when logging to stdout.
 *
 * @param {object} obj
 *        Any JavaScript object to describe.
 * @return String
 *         User meaningful descriptor for the object.
 */
function objectToString(obj) {
  if (Element.isInstance(obj)) {
    let message = `<${obj.tagName}`;
    if (obj.id) {
      message += ` #${obj.id}`;
    }
    if (obj.className) {
      message += ` .${obj.className}`;
    }
    message += ">";
    return message;
  } else if (Array.isArray(obj)) {
    return `Array(${obj.length})`;
  } else if (Event.isInstance(obj)) {
    return `Event(${obj.type}) target=${objectToString(obj.target)}`;
  } else if (typeof obj === "function") {
    return `function ${obj.name || "anonymous"}()`;
  }
  return primitiveToString(obj);
}

function primitiveToString(value) {
  const type = typeof value;
  if (type === "string") {
    // Use stringify to escape special characters and display in enclosing quotes.
    return JSON.stringify(value);
  } else if (value === 0 && 1 / value === -Infinity) {
    // -0 is very special and need special threatment.
    return "-0";
  } else if (type === "bigint") {
    return `BigInt(${value})`;
  } else if (value && typeof value.toString === "function") {
    // Use toString as it allows to stringify Symbols. Converting them to string throws.
    return value.toString();
  }

  // For all other types/cases, rely on native convertion to string
  return String(value);
}

/**
 * Try to describe the current frame we are tracing
 *
 * This will typically log the name of the method being called.
 *
 * @param {Debugger.Frame} frame
 *        The frame which is currently being executed.
 */
function formatDisplayName(frame) {
  if (frame.type === "call") {
    const callee = frame.callee;
    // Anonymous function will have undefined name and displayName.
    return "λ " + (callee.name || callee.displayName || "anonymous");
  }

  return `(${frame.type})`;
}

let activeTracer = null;

/**
 * Start tracing JavaScript.
 * i.e. log the name of any function being called in JS and its location in source code.
 *
 * @param {object} options (mandatory)
 *        See JavaScriptTracer.startTracing jsdoc.
 */
function startTracing(options) {
  if (!options) {
    throw new Error("startTracing excepts an options object as first argument");
  }
  if (!activeTracer) {
    activeTracer = new JavaScriptTracer(options);
  } else {
    console.warn(
      "Can't start JavaScript tracing, another tracer is still active and we only support one tracer at a time."
    );
  }
}

/**
 * Stop tracing JavaScript.
 */
function stopTracing() {
  if (activeTracer) {
    activeTracer.stopTracing();
    activeTracer = null;
  } else {
    console.warn("Can't stop JavaScript Tracing as we were not tracing.");
  }
}

/**
 * Listen for tracing updates.
 *
 * The listener object may expose the following methods:
 * - onTracingToggled(state)
 *   Where state is a boolean to indicate if tracing has just been enabled of disabled.
 *   It may be immediatelly called if a tracer is already active.
 *
 * - onTracingFrame({ frame, depth, formatedDisplayName, prefix })
 *   Called each time we enter a new JS frame.
 *   - frame is a Debugger.Frame object
 *   - depth is a number and represents the depth of the frame in the call stack
 *   - formatedDisplayName is a string and is a human readable name for the current frame
 *   - prefix is a string to display as a prefix of any logged frame
 *
 * @param {object} listener
 */
function addTracingListener(listener) {
  listeners.add(listener);

  if (
    activeTracer?.isTracing &&
    typeof listener.onTracingToggled == "function"
  ) {
    listener.onTracingToggled(true);
  }
}

/**
 * Unregister a listener previous registered via addTracingListener
 */
function removeTracingListener(listener) {
  listeners.delete(listener);
}

function getFrameDepth(frame) {
  if (typeof frame.depth !== "number") {
    let depth = 0;
    let f = frame;
    while ((f = f.older)) {
      if (f.depth) {
        depth = depth + f.depth + 1;
        break;
      }
      depth++;
    }
    frame.depth = depth;
  }

  return frame.depth;
}

/**
 * Generate a magic string that will be rendered in smart terminals as a URL
 * for the given Frame object. This URL is special as it includes a line and column.
 * This URL can be clicked and Firefox will automatically open the source matching
 * the frame's URL in the currently opened Debugger.
 * Firefox will interpret differently the URLs ending with `/:?\d*:\d+/`.
 *
 * @param {Debugger.Frame} frame
 *        The frame being traced.
 * @return {string}
 *        The URL's magic string.
 */
function getTerminalHyperLink(frame) {
  const { script } = frame;
  const { lineNumber, columnNumber } = script.getOffsetMetadata(frame.offset);

  // Use a special URL, including line and column numbers which Firefox
  // interprets as to be opened in the already opened DevTool's debugger
  const href = `${script.source.url}:${lineNumber}:${columnNumber}`;

  // Use special characters in order to print working hyperlinks right from the terminal
  // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
  return `\x1B]8;;${href}\x1B\\${href}\x1B]8;;\x1B\\`;
}

/**
 * Helper function to synchronously pause the current frame execution
 * for a given duration in ms.
 *
 * @param {number} duration
 */
function syncPause(duration) {
  let freeze = true;
  const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
  timer.initWithCallback(
    () => {
      freeze = false;
    },
    duration,
    Ci.nsITimer.TYPE_ONE_SHOT
  );
  Services.tm.spinEventLoopUntil("debugger-slow-motion", function () {
    return !freeze;
  });
}

export const JSTracer = {
  startTracing,
  stopTracing,
  addTracingListener,
  removeTracingListener,
  NEXT_INTERACTION_MESSAGE,
  DOM_MUTATIONS,
  objectToString,
};
