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

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

  NetworkDataBytes: "chrome://remote/content/shared/NetworkDataBytes.sys.mjs",
});

/**
 * The NetworkResponse class is a wrapper around the internal channel which
 * provides getters and methods closer to fetch's response concept
 * (https://fetch.spec.whatwg.org/#concept-response).
 */
export class NetworkResponse {
  #cachedResponseBody;
  #channel;
  #decodedBodySize;
  #encodedBodySize;
  #fromCache;
  #fromServiceWorker;
  #hasCachedResponseBody;
  #headersTransmittedSize;
  #isCachedResource;
  #isDataURL;
  #responseBodyReady;
  #status;
  #statusMessage;
  #totalTransmittedSize;
  #wrappedChannel;

  /**
   *
   * @param {nsIChannel} channel
   *     The channel for the response.
   * @param {object} params
   * @param {boolean} params.fromCache
   *     Whether the response was read from the cache or not.
   * @param {boolean} params.fromServiceWorker
   *     Whether the response is coming from a service worker or not.
   * @param {boolean} params.hasResponseCollector
   *     Whether there is an active response collector for the context which
   *     owns this request.
   * @param {boolean} params.isCachedResource
   *     Whether the response is served by the stencil (image/CSS/JS) cache.
   * @param {string?} params.memoryCacheKey
   *     The cache key of the in-memory cached response.
   * @param {string=} params.rawHeaders
   *     The response's raw (ie potentially compressed) headers
   */
  constructor(channel, params) {
    this.#channel = channel;
    const {
      fromCache,
      fromServiceWorker,
      hasResponseCollector = false,
      isCachedResource,
      memoryCacheKey = undefined,
      rawHeaders = "",
    } = params;
    this.#fromCache = fromCache;
    this.#fromServiceWorker = fromServiceWorker;
    this.#isCachedResource = isCachedResource;
    this.#isDataURL = this.#channel instanceof Ci.nsIDataChannel;
    this.#responseBodyReady = Promise.withResolvers();
    this.#wrappedChannel = ChannelWrapper.get(channel);

    this.#decodedBodySize = 0;
    this.#encodedBodySize = 0;
    this.#headersTransmittedSize = rawHeaders.length;
    this.#totalTransmittedSize = rawHeaders.length;

    // We use two separate fields to distinguish the "no response body" vs
    // "response is empty" in toJSON.
    this.#hasCachedResponseBody = false;
    this.#cachedResponseBody = "";

    if (memoryCacheKey && hasResponseCollector) {
      let charset = "";
      const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
      if (httpChannel) {
        charset = httpChannel.classicScriptHintCharset || "";
      }

      const text = ChromeUtils.getCachedJavaScriptSource(
        memoryCacheKey,
        channel.URI.spec,
        charset
      );
      if (text !== undefined) {
        this.#cachedResponseBody = text;
        this.#hasCachedResponseBody = true;
      }
    }

    // See https://github.com/w3c/webdriver-bidi/issues/761
    // For 304 responses, the response will be replaced by the cached response
    // between responseStarted and responseCompleted, which will effectively
    // change the status and statusMessage.
    // Until the issue linked above has been discussed and closed, we will
    // cache the status/statusMessage in order to ensure consistent values
    // between responseStarted and responseCompleted.
    this.#status = this.#isDataURL ? 200 : this.#channel.responseStatus;
    this.#statusMessage =
      this.#isDataURL || this.#isCachedResource
        ? "OK"
        : this.#channel.responseStatusText;
  }

  get cachedResponseBody() {
    return this.#cachedResponseBody;
  }

  get decodedBodySize() {
    return this.#decodedBodySize;
  }

  get encodedBodySize() {
    return this.#encodedBodySize;
  }

  get hasCachedResponseBody() {
    return this.#hasCachedResponseBody;
  }

  get headers() {
    return this.#getHeadersList();
  }

  get headersTransmittedSize() {
    return this.#headersTransmittedSize;
  }

  get fromCache() {
    return this.#fromCache;
  }

  get fromServiceWorker() {
    return this.#fromServiceWorker;
  }

  get isDataURL() {
    return this.#isDataURL;
  }

  get mimeType() {
    return this.#getComputedMimeType();
  }

  get protocol() {
    return lazy.NetworkUtils.getProtocol(this.#channel);
  }

  get serializedURL() {
    return this.#channel.URI.spec;
  }

  get status() {
    return this.#status;
  }

  get statusMessage() {
    return this.#statusMessage;
  }

  get totalTransmittedSize() {
    return this.#totalTransmittedSize;
  }

  /**
   * Check if this response will lead to a redirect.
   */
  get willRedirect() {
    // See static helper on nsHttpChannel:WillRedirect
    // https://searchfox.org/mozilla-central/rev/6b4cb595d05ac38e2cfc493e3b81fe4c97a71f12/netwerk/protocol/http/nsHttpChannel.cpp#283-288
    const isRedirectStatus =
      this.#status == 301 ||
      this.#status == 302 ||
      this.#status == 303 ||
      this.#status == 307 ||
      this.#status == 308;
    return isRedirectStatus && this.#channel.getResponseHeader("Location");
  }

  /**
   * Clear a response header from the responses's headers list.
   *
   * @param {string} name
   *     The header's name.
   */
  clearResponseHeader(name) {
    this.#channel.setResponseHeader(
      name, // aName
      "", // aValue="" as an empty value
      false // aMerge=false to force clearing the header
    );
  }

  /**
   * Returns the NetworkDataBytes instance representing the response body for
   * this response.
   *
   * @returns {NetworkDataBytes}
   */
  readAndProcessResponseBody = async () => {
    const responseContent = await this.#responseBodyReady.promise;

    return new lazy.NetworkDataBytes({
      getBytesValue: async () => {
        if (responseContent.isContentEncoded) {
          return lazy.NetworkUtils.decodeResponseChunks(
            responseContent.encodedData,
            {
              // Should always attempt to decode as UTF-8.
              charset: "UTF-8",
              compressionEncodings: responseContent.compressionEncodings,
              encodedBodySize: responseContent.encodedBodySize,
              encoding: responseContent.encoding,
            }
          );
        }
        return responseContent.text;
      },
      isBase64: responseContent.encoding === "base64",
    });
  };

  setResponseContent(responseContent) {
    this.#responseBodyReady.resolve(responseContent);
  }

  /**
   * Set a response header
   *
   * @param {string} name
   *     The header's name.
   * @param {string} value
   *     The header's value.
   * @param {object} options
   * @param {boolean} options.merge
   *     True if the value should be merged with the existing value, false if it
   *     should override it. Defaults to false.
   */
  setResponseHeader(name, value, options) {
    const { merge = false } = options;
    this.#channel.setResponseHeader(name, value, merge);
  }

  setResponseStatus(options) {
    let { status, statusText } = options;
    if (status === null) {
      status = this.#channel.responseStatus;
    }

    if (statusText === null) {
      statusText = this.#channel.responseStatusText;
    }

    this.#channel.setResponseStatus(status, statusText);

    // Update the cached status and statusMessage.
    this.#status = this.#channel.responseStatus;
    this.#statusMessage = this.#channel.responseStatusText;
  }

  /**
   * Set the various response sizes for this response. Depending on how the
   * completion was monitored (DevTools NetworkResponseListener or ChannelWrapper
   * event), sizes need to be retrieved differently.
   * There this is a simple setter and the actual logic to retrieve sizes is in
   * NetworkEventRecord.
   *
   * @param {object} sizes
   * @param {number} sizes.decodedBodySize
   *     The decoded body size.
   * @param {number} sizes.encodedBodySize
   *     The encoded body size.
   * @param {number} sizes.totalTransmittedSize
   *     The total transmitted size.
   */
  setResponseSizes(sizes) {
    const { decodedBodySize, encodedBodySize, totalTransmittedSize } = sizes;
    this.#decodedBodySize = decodedBodySize;
    this.#encodedBodySize = encodedBodySize;
    this.#totalTransmittedSize = totalTransmittedSize;
  }

  /**
   * Return a static version of the class instance.
   * This method is used to prepare the data to be sent with the events for cached resources
   * generated from the content process but need to be sent to the parent.
   */
  toJSON() {
    return {
      cachedResponseBody: this.cachedResponseBody,
      decodedBodySize: this.decodedBodySize,
      encodedBodySize: this.encodedBodySize,
      fromCache: this.fromCache,
      hasCachedResponseBody: this.hasCachedResponseBody,
      headers: this.headers,
      headersTransmittedSize: this.headersTransmittedSize,
      isDataURL: this.isDataURL,
      mimeType: this.mimeType,
      protocol: this.protocol,
      serializedURL: this.serializedURL,
      status: this.status,
      statusMessage: this.statusMessage,
      totalTransmittedSize: this.totalTransmittedSize,
      willRedirect: this.willRedirect,
    };
  }

  #getComputedMimeType() {
    // TODO: DevTools NetworkObserver is computing a similar value in
    // addResponseContent, but uses an inconsistent implementation in
    // addResponseStart. This approach can only be used as early as in
    // addResponseHeaders. We should move this logic to the NetworkObserver and
    // expose mimeType in addResponseStart. Bug 1809670.
    let mimeType = "";

    try {
      if (this.#isDataURL || this.#isCachedResource) {
        mimeType = this.#channel.contentType;
      } else {
        mimeType = this.#wrappedChannel.contentType;
      }
      const contentCharset = this.#channel.contentCharset;
      if (contentCharset) {
        mimeType += `;charset=${contentCharset}`;
      }
    } catch (e) {
      // Ignore exceptions when reading contentType/contentCharset
    }

    return mimeType;
  }

  #getHeadersList() {
    const headers = [];

    // According to the fetch spec for data URLs we can just hardcode
    // "Content-Type" header.
    if (this.#isDataURL) {
      headers.push(["Content-Type", this.#channel.contentType]);
    } else {
      this.#channel.visitResponseHeaders({
        visitHeader(name, value) {
          headers.push([name, value]);
        },
      });
    }

    return headers;
  }
}
