/* 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";

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

const lazy = {};

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

// Internal resource types used to create the appropriate network event based
// on where the channel / resource is coming from.
const RESOURCE_TYPES = {
  BLOCKED: "blocked-resource",
  CACHED: "cached-resource",
  DATA_CHANNEL: "data-channel-resource",
};

/**
 * Handles network events from the content process
 * This currently only handles events for requests (js/css) blocked by CSP.
 */
class NetworkEventContentWatcher {
  /**
   * Start watching for all network events related to a given Target Actor.
   *
   * @param TargetActor targetActor
   *        The target actor in the content 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(targetActor, { onAvailable, onUpdated }) {
    // Map from channelId to network event objects.
    this.networkEvents = new Map();

    this.targetActor = targetActor;
    this.onAvailable = onAvailable;
    this.onUpdated = onUpdated;

    Services.obs.addObserver(
      this.httpFailedOpeningRequest,
      "http-on-failed-opening-request"
    );

    Services.obs.addObserver(
      this.httpOnResourceCacheResponse,
      "http-on-resource-cache-response"
    );

    Services.obs.addObserver(this.onDataChannelOpened, "data-channel-opened");
  }
  /**
   * Allows clearing of network events
   */
  clear() {
    this.networkEvents.clear();
  }

  httpFailedOpeningRequest = (subject, topic) => {
    if (
      topic != "http-on-failed-opening-request" ||
      !(subject instanceof Ci.nsIHttpChannel)
    ) {
      const channel = subject.QueryInterface(Ci.nsIChannel);
      console.warn(
        `httpFailedOpeningRequest triggered on non-nsIHttpChannel for uri: ${channel.URI.spec}`
      );
      return;
    }

    const channel = subject.QueryInterface(Ci.nsIHttpChannel);

    // Ignore preload requests to avoid duplicity request entries in
    // the Network panel. If a preload fails (for whatever reason)
    // then the platform kicks off another 'real' request.
    if (lazy.NetworkUtils.isPreloadRequest(channel)) {
      return;
    }

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

    this.onNetworkEventAvailable(channel, {
      networkEventOptions: {
        blockedReason: channel.loadInfo.requestBlockingReason,
      },
      type: RESOURCE_TYPES.BLOCKED,
    });
  };

  httpOnResourceCacheResponse = (subject, topic, memoryCacheKey) => {
    if (
      topic != "http-on-resource-cache-response" ||
      !(subject instanceof Ci.nsIHttpChannel)
    ) {
      return;
    }

    const channel = subject.QueryInterface(Ci.nsIHttpChannel);

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

    if (
      channel.loadInfo?.externalContentPolicyType !==
      Ci.nsIContentPolicy.TYPE_SCRIPT
    ) {
      // For images and stylesheets from the cache, only one network request
      // should be created per URI.
      //
      // For scripts from the cache, multiple network requests should be
      // created.
      const hasURI = Array.from(this.networkEvents.values()).some(
        networkEvent => networkEvent.uri === channel.URI.spec
      );

      if (hasURI) {
        return;
      }
    }

    this.onNetworkEventAvailable(channel, {
      fromCache: true,
      memoryCacheKey,
      networkEventOptions: {},
      type: RESOURCE_TYPES.CACHED,
    });
  };

  onDataChannelOpened = (subject, topic) => {
    if (
      topic != "data-channel-opened" ||
      !(subject instanceof Ci.nsIDataChannel)
    ) {
      return;
    }

    const channel = subject.QueryInterface(Ci.nsIDataChannel);
    channel.QueryInterface(Ci.nsIIdentChannel);
    channel.QueryInterface(Ci.nsIChannel);

    if (channel.isDocument) {
      // Navigation data channels are available in the parent process and will
      // be monitored there.
      return;
    }

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

    this.onNetworkEventAvailable(channel, {
      fromCache: false,
      networkEventOptions: {},
      type: RESOURCE_TYPES.DATA_CHANNEL,
    });
  };

  onNetworkEventAvailable(
    channel,
    { fromCache, memoryCacheKey, networkEventOptions, type }
  ) {
    const networkEventActor = new NetworkEventActor(
      this.targetActor.conn,
      this.targetActor.sessionContext,
      {
        onNetworkEventUpdate: this.onNetworkEventUpdate.bind(this),
        onNetworkEventDestroy: this.onNetworkEventDestroyed.bind(this),
      },
      networkEventOptions,
      channel
    );
    this.targetActor.manage(networkEventActor);

    const resource = networkEventActor.asResource();

    const networkEvent = {
      browsingContextID: resource.browsingContextID,
      innerWindowId: resource.innerWindowId,
      resourceId: resource.resourceId,
      receivedUpdates: [],
      resourceUpdates: {},
      uri: channel.URI.spec,
    };

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

    // Set the flags on the resource so that the front-end can fetch
    // and display request headers and cookies details asap.
    lazy.NetworkUtils.setEventAsAvailable(resource, [
      lazy.NetworkUtils.NETWORK_EVENT_TYPES.REQUEST_HEADERS,
      lazy.NetworkUtils.NETWORK_EVENT_TYPES.REQUEST_COOKIES,
    ]);

    this.networkEvents.set(resource.resourceId, networkEvent);

    this.onAvailable([resource]);

    if (memoryCacheKey) {
      networkEventActor.addMemoryCacheData(channel, memoryCacheKey);
    }
    networkEventActor.addCacheDetails({ fromCache });
    if (type == RESOURCE_TYPES.BLOCKED) {
      lazy.NetworkUtils.setEventAsAvailable(networkEvent.resourceUpdates, [
        lazy.NetworkUtils.NETWORK_EVENT_TYPES.RESPONSE_END,
      ]);
      this._emitUpdate(networkEvent);
    } else if (type == RESOURCE_TYPES.CACHED) {
      networkEventActor.addResponseStart({ channel, fromCache: true });
      networkEventActor.addEventTimings(
        0 /* totalTime */,
        {} /* timings */,
        {} /* offsets */
      );
      networkEventActor.addServerTimings({});
      networkEventActor.addResponseContent({
        mimeType: channel.contentType,
        size: channel.contentLength,
        text: "",
        transferredSize: 0,
      });
      networkEventActor.addResponseContentComplete({});
    } else if (type == RESOURCE_TYPES.DATA_CHANNEL) {
      lazy.NetworkUtils.handleDataChannel(channel, networkEventActor);
    }
  }

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

    if (!networkEvent) {
      return;
    }

    const { NETWORK_EVENT_TYPES } = lazy.NetworkUtils;
    const { resourceUpdates, receivedUpdates } = networkEvent;

    switch (updateResource.updateType) {
      case NETWORK_EVENT_TYPES.CACHE_DETAILS:
        resourceUpdates.fromCache = updateResource.fromCache;
        resourceUpdates.fromServiceWorker = updateResource.fromServiceWorker;
        break;
      case NETWORK_EVENT_TYPES.RESPONSE_START: {
        // For cached image requests channel.responseStatus is set to 200 as
        // expected. However responseStatusText is empty. In this case fallback
        // to the expected statusText "OK".
        let statusText = updateResource.statusText;
        if (!statusText && updateResource.status === "200") {
          statusText = "OK";
        }
        resourceUpdates.httpVersion = updateResource.httpVersion;
        resourceUpdates.status = updateResource.status;
        resourceUpdates.statusText = statusText;
        resourceUpdates.remoteAddress = updateResource.remoteAddress;
        resourceUpdates.remotePort = updateResource.remotePort;
        resourceUpdates.waitingTime = updateResource.waitingTime;
        if (Number.isInteger(updateResource.priority)) {
          resourceUpdates.priority = updateResource.priority;
        }
        lazy.NetworkUtils.setEventAsAvailable(resourceUpdates, [
          NETWORK_EVENT_TYPES.RESPONSE_COOKIES,
          NETWORK_EVENT_TYPES.RESPONSE_HEADERS,
        ]);
        break;
      }
      case NETWORK_EVENT_TYPES.RESPONSE_CONTENT:
        if (updateResource.contentSize !== undefined) {
          resourceUpdates.contentSize = updateResource.contentSize;
        }
        resourceUpdates.mimeType = updateResource.mimeType;
        resourceUpdates.transferredSize = updateResource.transferredSize;
        break;
      case NETWORK_EVENT_TYPES.EVENT_TIMINGS:
        resourceUpdates.totalTime = updateResource.totalTime;
        break;
    }

    lazy.NetworkUtils.setEventAsAvailable(resourceUpdates, [
      updateResource.updateType,
    ]);

    receivedUpdates.push(updateResource.updateType);

    // Here we explicitly call all three `add` helpers on each network event
    // actor so in theory we could check only the last one to be called, ie
    // responseContent.
    const isResponseComplete =
      receivedUpdates.includes(NETWORK_EVENT_TYPES.RESPONSE_START) &&
      receivedUpdates.includes(NETWORK_EVENT_TYPES.RESPONSE_CONTENT_COMPLETE) &&
      receivedUpdates.includes(NETWORK_EVENT_TYPES.EVENT_TIMINGS);

    if (isResponseComplete) {
      // Lets add an event to clearly define the last update expected to be
      // emitted. There will be no more updates after this.
      lazy.NetworkUtils.setEventAsAvailable(resourceUpdates, [
        NETWORK_EVENT_TYPES.RESPONSE_END,
      ]);
    }

    if (
      updateResource.updateType == NETWORK_EVENT_TYPES.RESPONSE_START ||
      updateResource.updateType == NETWORK_EVENT_TYPES.RESPONSE_CONTENT ||
      isResponseComplete
    ) {
      this._emitUpdate(networkEvent);
      // clean up already sent updates
      networkEvent.resourceUpdates = {};
    }
  }

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

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

  destroy() {
    this.clear();
    Services.obs.removeObserver(
      this.httpFailedOpeningRequest,
      "http-on-failed-opening-request"
    );

    Services.obs.removeObserver(
      this.httpOnResourceCacheResponse,
      "http-on-resource-cache-response"
    );

    Services.obs.removeObserver(
      this.onDataChannelOpened,
      "data-channel-opened"
    );
  }
}

module.exports = NetworkEventContentWatcher;
