/* 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 { Pool } = require("resource://devtools/shared/protocol/Pool.js");
const { isWindowGlobalPartOfContext } = ChromeUtils.importESModule(
  "resource://devtools/server/actors/watcher/browsing-context-helpers.sys.mjs"
);
const { WatcherRegistry } = ChromeUtils.importESModule(
  "resource://devtools/server/actors/watcher/WatcherRegistry.sys.mjs",
  {
    // WatcherRegistry needs to be a true singleton and loads ActorManagerParent
    // which also has to be a true singleton.
    loadInDevToolsLoader: false,
  }
);
const Targets = require("resource://devtools/server/actors/targets/index.js");

const lazy = {};

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

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

/**
 * Handles network events from the parent process
 */
class NetworkEventWatcher {
  /**
   * Start watching for all network events related to a given Watcher Actor.
   *
   * @param WatcherActor watcherActor
   *        The watcher actor in the parent process from which we should
   *        observe network events.
   * @param Object options
   *        Dictionary object with following attributes:
   *        - onAvailable: mandatory function
   *          This will be called for each resource.
   *        - onUpdated: optional function
   *          This would be called multiple times for each resource.
   */
  async watch(watcherActor, { onAvailable, onUpdated }) {
    this.networkEvents = new Map();

    this.watcherActor = watcherActor;
    this.onNetworkEventAvailable = onAvailable;
    this.onNetworkEventUpdated = onUpdated;
    // Boolean to know if we keep previous document network events or not.
    this.persist = false;
    this.listener = new lazy.NetworkObserver({
      ignoreChannelFunction: this.shouldIgnoreChannel.bind(this),
      onNetworkEvent: this.onNetworkEvent.bind(this),
    });

    Services.obs.addObserver(this, "window-global-destroyed");
  }

  /**
   * Clear all the network events and the related actors.
   *
   * This is called on actor destroy, but also from WatcherActor.clearResources(NETWORK_EVENT)
   */
  clear() {
    this.networkEvents.clear();
    this.listener.clear();
    if (this._pool) {
      this._pool.destroy();
      this._pool = null;
    }
  }

  /**
   * A protocol.js Pool to store all NetworkEventActor's which may be destroyed on navigations.
   */
  get pool() {
    if (this._pool) {
      return this._pool;
    }
    this._pool = new Pool(this.watcherActor.conn, "network-events");
    this.watcherActor.manage(this._pool);
    return this._pool;
  }

  /**
   * Instruct to keep reference to previous document requests or not.
   * If persist is disabled, we will clear all informations about previous document
   * on each navigation.
   * If persist is enabled, we will keep all informations for all documents, leading
   * to lots of allocations!
   *
   * @param {Boolean} enabled
   */
  setPersist(enabled) {
    this.persist = enabled;
  }

  /**
   * Gets the throttle settings
   *
   * @return {*} data
   *
   */
  getThrottleData() {
    return this.listener.getThrottleData();
  }

  /**
   * Sets the throttle data
   *
   * @param {*} data
   *
   */
  setThrottleData(data) {
    this.listener.setThrottleData(data);
  }

  /**
   * Instruct to save or ignore request and response bodies
   * @param {Boolean} save
   */
  setSaveRequestAndResponseBodies(save) {
    this.listener.setSaveRequestAndResponseBodies(save);
  }

  /**
   * Block requests based on the filters
   * @param {Object} filters
   */
  blockRequest(filters) {
    this.listener.blockRequest(filters);
  }

  /**
   * Unblock requests based on the fitlers
   * @param {Object} filters
   */
  unblockRequest(filters) {
    this.listener.unblockRequest(filters);
  }

  /**
   * Calls the listener to set blocked urls
   *
   * @param {Array} urls
   *        The urls to block
   */

  setBlockedUrls(urls) {
    this.listener.setBlockedUrls(urls);
  }

  /**
   * Calls the listener to get the blocked urls
   *
   * @return {Array} urls
   *          The blocked urls
   */

  getBlockedUrls() {
    return this.listener.getBlockedUrls();
  }

  override(url, path) {
    this.listener.override(url, path);
  }

  removeOverride(url) {
    this.listener.removeOverride(url);
  }

  /**
   * Watch for previous document being unloaded in order to clear
   * all related network events, in case persist is disabled.
   * (which is the default behavior)
   */
  observe(windowGlobal, topic) {
    if (topic !== "window-global-destroyed") {
      return;
    }
    // If we persist, we will keep all requests allocated.
    // For now, consider that the Browser console and toolbox persist all the requests.
    if (this.persist || this.watcherActor.sessionContext.type == "all") {
      return;
    }
    // Only process WindowGlobals which are related to the debugged scope.
    if (
      !isWindowGlobalPartOfContext(
        windowGlobal,
        this.watcherActor.sessionContext
      )
    ) {
      return;
    }
    const { innerWindowId } = windowGlobal;

    for (const child of this.pool.poolChildren()) {
      // Destroy all network events matching the destroyed WindowGlobal
      if (!child.isNavigationRequest()) {
        if (child.getInnerWindowId() == innerWindowId) {
          child.destroy();
        }
        // Avoid destroying the navigation request, which is flagged with previous document's innerWindowId.
        // When navigating, the WindowGlobal we navigate *from* will be destroyed and notified here.
        // We should explicitly avoid destroying it here.
        // But, we still want to eventually destroy them.
        // So do this when navigating a second time, we will navigate from a distinct WindowGlobal
        // and check that this is the top level window global and not an iframe one.
        // So that we avoid clearing the top navigation when an iframe navigates
        //
        // Avoid destroying the request if innerWindowId isn't set. This happens when we reload many times in a row.
        // The previous navigation request will be cancelled and because of that its innerWindowId will be null.
        // But the frontend will receive it after the navigation begins (after will-navigate) and will display it
        // and try to fetch extra data about it. So, avoid destroying its NetworkEventActor.
      } else if (
        child.getInnerWindowId() &&
        child.getInnerWindowId() != innerWindowId &&
        windowGlobal.browsingContext ==
          this.watcherActor.browserElement?.browsingContext
      ) {
        child.destroy();
      }
    }
  }

  /**
   * Called by NetworkObserver in order to know if the channel should be ignored
   */
  shouldIgnoreChannel(channel) {
    // First of all, check if the channel matches the watcherActor's session.
    const filters = { sessionContext: this.watcherActor.sessionContext };
    if (!lazy.NetworkUtils.matchRequest(channel, filters)) {
      return true;
    }

    // When we are in the browser toolbox in parent process scope,
    // the session context is still "all", but we are no longer watching frame and process targets.
    // In this case, we should ignore all requests belonging to a BrowsingContext that isn't in the parent process
    // (i.e. the process where this Watcher runs)
    const isParentProcessOnlyBrowserToolbox =
      this.watcherActor.sessionContext.type == "all" &&
      !WatcherRegistry.isWatchingTargets(
        this.watcherActor,
        Targets.TYPES.FRAME
      );
    if (isParentProcessOnlyBrowserToolbox) {
      // We should ignore all requests coming from BrowsingContext running in another process
      const browsingContextID =
        lazy.NetworkUtils.getChannelBrowsingContextID(channel);
      const browsingContext = BrowsingContext.get(browsingContextID);
      // We accept any request that isn't bound to any BrowsingContext.
      // This is most likely a privileged request done from a JSM/C++.
      // `isInProcess` will be true, when the document executes in the parent process.
      //
      // Note that we will still accept all requests that aren't bound to any BrowsingContext
      // See browser_resources_network_events_parent_process.js test with privileged request
      // made from the content processes.
      // We miss some attribute on channel/loadInfo to know that it comes from the content process.
      if (browsingContext?.currentWindowGlobal.isInProcess === false) {
        return true;
      }
    }
    return false;
  }

  onNetworkEvent(networkEventOptions, channel) {
    if (this.networkEvents.has(channel.channelId)) {
      throw new Error(
        `Got notified about channel ${channel.channelId} more than once.`
      );
    }

    const actor = new NetworkEventActor(
      this.watcherActor.conn,
      this.watcherActor.sessionContext,
      {
        onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this),
        onNetworkEventDestroy: this.onNetworkEventDestroy.bind(this),
      },
      networkEventOptions,
      channel
    );
    this.pool.manage(actor);

    const resource = actor.asResource();
    const isBlocked = !!resource.blockedReason;
    const networkEvent = {
      browsingContextID: resource.browsingContextID,
      innerWindowId: resource.innerWindowId,
      resourceId: resource.resourceId,
      resourceType: resource.resourceType,
      isBlocked,
      receivedUpdates: [],
      resourceUpdates: {
        // Requests already come with request cookies and headers, so those
        // should always be considered as available. But the client still
        // heavily relies on those `Available` flags to fetch additional data,
        // so it is better to keep them for consistency.
        requestCookiesAvailable: true,
        requestHeadersAvailable: true,
      },
    };
    this.networkEvents.set(resource.resourceId, networkEvent);

    this.onNetworkEventAvailable([resource]);

    // Blocked requests will not receive further updates and should emit an
    // update packet immediately.
    // The frontend expects to receive a dedicated update to consider the
    // request as completed. TODO: lift this restriction so that we can only
    // emit a resource available notification if no update is needed.
    if (isBlocked) {
      this._emitUpdate(networkEvent);
    }

    return actor;
  }

  onNetworkEventUpdate(updateResource) {
    const networkEvent = this.networkEvents.get(updateResource.resourceId);

    if (!networkEvent) {
      return;
    }

    const { resourceUpdates, receivedUpdates } = networkEvent;

    switch (updateResource.updateType) {
      case "responseStart":
        resourceUpdates.httpVersion = updateResource.httpVersion;
        resourceUpdates.status = updateResource.status;
        resourceUpdates.statusText = updateResource.statusText;
        resourceUpdates.remoteAddress = updateResource.remoteAddress;
        resourceUpdates.remotePort = updateResource.remotePort;
        // The mimetype is only set when then the contentType is available
        // in the _onResponseHeader and not for cached/service worker requests
        // in _httpResponseExaminer.
        resourceUpdates.mimeType = updateResource.mimeType;
        resourceUpdates.waitingTime = updateResource.waitingTime;
        resourceUpdates.isResolvedByTRR = updateResource.isResolvedByTRR;
        resourceUpdates.proxyHttpVersion = updateResource.proxyHttpVersion;
        resourceUpdates.proxyStatus = updateResource.proxyStatus;
        resourceUpdates.proxyStatusText = updateResource.proxyStatusText;

        resourceUpdates.responseHeadersAvailable = true;
        resourceUpdates.responseCookiesAvailable = true;
        break;
      case "responseContent":
        resourceUpdates.contentSize = updateResource.contentSize;
        resourceUpdates.transferredSize = updateResource.transferredSize;
        resourceUpdates.mimeType = updateResource.mimeType;
        resourceUpdates.blockingExtension = updateResource.blockingExtension;
        resourceUpdates.blockedReason = updateResource.blockedReason;
        break;
      case "eventTimings":
        resourceUpdates.totalTime = updateResource.totalTime;
        break;
      case "securityInfo":
        resourceUpdates.securityState = updateResource.state;
        resourceUpdates.isRacing = updateResource.isRacing;
        break;
    }

    resourceUpdates[`${updateResource.updateType}Available`] = true;
    receivedUpdates.push(updateResource.updateType);

    const isComplete =
      receivedUpdates.includes("eventTimings") &&
      receivedUpdates.includes("responseContent") &&
      receivedUpdates.includes("securityInfo");

    if (isComplete) {
      this._emitUpdate(networkEvent);
    }
  }

  _emitUpdate(networkEvent) {
    this.onNetworkEventUpdated([
      {
        resourceType: networkEvent.resourceType,
        resourceId: networkEvent.resourceId,
        resourceUpdates: networkEvent.resourceUpdates,
        browsingContextID: networkEvent.browsingContextID,
        innerWindowId: networkEvent.innerWindowId,
      },
    ]);
  }

  onNetworkEventDestroy(channelId) {
    if (this.networkEvents.has(channelId)) {
      this.networkEvents.delete(channelId);
    }
  }

  /**
   * Stop watching for network event related to a given Watcher Actor.
   */
  destroy() {
    if (this.listener) {
      this.clear();
      this.listener.destroy();
      Services.obs.removeObserver(this, "window-global-destroyed");
    }
  }
}

module.exports = NetworkEventWatcher;
