/* 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 {
  TYPES: { NETWORK_EVENT_STACKTRACE },
} = require("resource://devtools/server/actors/resources/index.js");

loader.lazyRequireGetter(
  this,
  "ChannelEventSinkFactory",
  "resource://devtools/server/actors/network-monitor/channel-event-sink.js",
  true
);

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  NetworkUtils:
    "resource://devtools/shared/network-observer/NetworkUtils.sys.mjs",
});

class NetworkEventStackTracesWatcher {
  /**
   * Start watching for all network event's stack traces related to a given Target actor.
   *
   * @param TargetActor targetActor
   *        The target actor from which we should observe the strack traces
   * @param Object options
   *        Dictionary object with following attributes:
   *        - onAvailable: mandatory
   *          This will be called for each resource.
   */
  async watch(targetActor, { onAvailable }) {
    this.stacktraces = new Map();
    this.onStackTraceAvailable = onAvailable;
    this.targetActor = targetActor;

    Services.obs.addObserver(this, "http-on-opening-request");
    Services.obs.addObserver(this, "document-on-opening-request");
    Services.obs.addObserver(this, "network-monitor-alternate-stack");
    ChannelEventSinkFactory.getService().registerCollector(this);
  }

  /**
   * Allows clearing of network stacktrace resources
   */
  clear() {
    this.stacktraces.clear();
  }

  /**
   * Stop watching for network event's strack traces related to a given Target Actor.
   *
   * @param TargetActor targetActor
   *        The target actor from which we should stop observing the strack traces
   */
  destroy(targetActor) {
    this.clear();
    Services.obs.removeObserver(this, "http-on-opening-request");
    Services.obs.removeObserver(this, "document-on-opening-request");
    Services.obs.removeObserver(this, "network-monitor-alternate-stack");
    ChannelEventSinkFactory.getService().unregisterCollector(this);
  }

  onChannelRedirect(oldChannel, newChannel, flags) {
    // We can be called with any nsIChannel, but are interested only in HTTP channels
    try {
      oldChannel.QueryInterface(Ci.nsIHttpChannel);
      newChannel.QueryInterface(Ci.nsIHttpChannel);
    } catch (ex) {
      return;
    }

    const oldId = oldChannel.channelId;
    const stacktrace = this.stacktraces.get(oldId);
    if (stacktrace) {
      this._setStackTrace(newChannel.channelId, stacktrace);
    }
  }

  observe(subject, topic, data) {
    let channel, id;
    try {
      // We need to QI nsIHttpChannel in order to load the interface's
      // methods / attributes for later code that could assume we are dealing
      // with a nsIHttpChannel.
      channel = subject.QueryInterface(Ci.nsIHttpChannel);
      id = channel.channelId;
    } catch (e1) {
      try {
        channel = subject.QueryInterface(Ci.nsIIdentChannel);
        id = channel.channelId;
      } catch (e2) {
        // WebSocketChannels do not have IDs, so use the serial. When a WebSocket is
        // opened in a content process, a channel is created locally but the HTTP
        // channel for the connection lives entirely in the parent process. When
        // the server code running in the parent sees that HTTP channel, it will
        // look for the creation stack using the websocket's serial.
        try {
          channel = subject.QueryInterface(Ci.nsIWebSocketChannel);
          id = channel.serial;
        } catch (e3) {
          // Try if the channel is a nsIWorkerChannelInfo which is the substitute
          // of the channel in the parent process.
          try {
            channel = subject.QueryInterface(Ci.nsIWorkerChannelInfo);
            id = channel.channelId;
          } catch (e4) {
            // Channels which don't implement the above interfaces can appear here,
            // such as nsIFileChannel. Ignore these channels.
            return;
          }
        }
      }
    }

    if (
      !lazy.NetworkUtils.matchRequest(channel, {
        targetActor: this.targetActor,
      })
    ) {
      return;
    }

    if (this.stacktraces.has(id)) {
      // We can get up to two stack traces for the same channel: one each from
      // the two observer topics we are listening to. Use the first stack trace
      // which is specified, and ignore any later one.
      return;
    }

    const stacktrace = [];
    switch (topic) {
      case "http-on-opening-request":
      case "document-on-opening-request": {
        // The channel is being opened on the main thread, associate the current
        // stack with it.
        //
        // Convert the nsIStackFrame XPCOM objects to a nice JSON that can be
        // passed around through message managers etc.
        let frame = Components.stack;
        if (frame?.caller) {
          frame = frame.caller;
          while (frame) {
            stacktrace.push({
              filename: frame.filename,
              lineNumber: frame.lineNumber,
              columnNumber: frame.columnNumber,
              functionName: frame.name,
              asyncCause: frame.asyncCause,
            });
            frame = frame.caller || frame.asyncCaller;
          }
        }
        break;
      }
      case "network-monitor-alternate-stack": {
        // An alternate stack trace is being specified for this channel.
        // The topic data is the JSON for the saved frame stack we should use,
        // so convert this into the expected format.
        //
        // This topic is used in the following cases:
        //
        // - The HTTP channel is opened asynchronously or on a different thread
        //   from the code which triggered its creation, in which case the stack
        //   from Components.stack will be empty. The alternate stack will be
        //   for the point we want to associate with the channel.
        //
        // - The channel is not a nsIHttpChannel, and we will receive no
        //   opening request notification for it.
        let frame = JSON.parse(data);
        while (frame) {
          stacktrace.push({
            filename: frame.source,
            lineNumber: frame.line,
            columnNumber: frame.column,
            functionName: frame.functionDisplayName,
            asyncCause: frame.asyncCause,
          });
          frame = frame.parent || frame.asyncParent;
        }
        break;
      }
      default:
        throw new Error("Unexpected observe() topic");
    }

    this._setStackTrace(id, stacktrace);
  }

  _setStackTrace(resourceId, stacktrace) {
    this.stacktraces.set(resourceId, stacktrace);
    this.onStackTraceAvailable([
      {
        resourceType: NETWORK_EVENT_STACKTRACE,
        resourceId,
        stacktraceAvailable: stacktrace && !!stacktrace.length,
        lastFrame: stacktrace && stacktrace.length ? stacktrace[0] : undefined,
      },
    ]);
  }

  getStackTrace(id) {
    let stacktrace = [];
    if (this.stacktraces.has(id)) {
      stacktrace = this.stacktraces.get(id);
    }
    return stacktrace;
  }
}
module.exports = NetworkEventStackTracesWatcher;
