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

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

import { RootBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/RootBiDiModule.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  NetworkHelper:
    "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs",

  assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
  CacheBehavior: "chrome://remote/content/shared/NetworkCacheManager.sys.mjs",
  ContextDescriptorType:
    "chrome://remote/content/shared/messagehandler/MessageHandler.sys.mjs",
  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
  generateUUID: "chrome://remote/content/shared/UUID.sys.mjs",
  Log: "chrome://remote/content/shared/Log.sys.mjs",
  matchURLPattern:
    "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs",
  NavigableManager: "chrome://remote/content/shared/NavigableManager.sys.mjs",
  NetworkDataBytes: "chrome://remote/content/shared/NetworkDataBytes.sys.mjs",
  NetworkDecodedBodySizeMap:
    "chrome://remote/content/shared/NetworkDecodedBodySizeMap.sys.mjs",
  NetworkListener:
    "chrome://remote/content/shared/listeners/NetworkListener.sys.mjs",
  NetworkResponse: "chrome://remote/content/shared/NetworkResponse.sys.mjs",
  parseChallengeHeader:
    "chrome://remote/content/shared/ChallengeHeaderParser.sys.mjs",
  parseURLPattern:
    "chrome://remote/content/shared/webdriver/URLPattern.sys.mjs",
  pprint: "chrome://remote/content/shared/Format.sys.mjs",
  SessionDataCategory:
    "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
  SessionDataMethod:
    "chrome://remote/content/shared/messagehandler/sessiondata/SessionData.sys.mjs",
  truncate: "chrome://remote/content/shared/Format.sys.mjs",
  updateCacheBehavior:
    "chrome://remote/content/shared/NetworkCacheManager.sys.mjs",
  UserContextManager:
    "chrome://remote/content/shared/UserContextManager.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", () =>
  lazy.Log.get(lazy.Log.TYPES.WEBDRIVER_BIDI)
);

// WIP: See Bug 1994983, the invalid tests for network.setExtraHeaders expect
// the optional arguments not to accept null.
const NULL = Symbol("NULL");

/**
 * Defines the maximum total size as expected by the specification at
 * https://w3c.github.io/webdriver-bidi/#max-total-data-size
 */
const DEFAULT_MAX_TOTAL_SIZE = 200 * 1000 * 1000;
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "maxTotalDataSize",
  "remote.network.maxTotalDataSize",
  DEFAULT_MAX_TOTAL_SIZE
);

/**
 * @typedef {object} AuthChallenge
 * @property {string} scheme
 * @property {string} realm
 */

/**
 * @typedef {object} AuthCredentials
 * @property {'password'} type
 * @property {string} username
 * @property {string} password
 */

/**
 * @typedef {object} BaseParameters
 * @property {string=} context
 * @property {Array<string>?} intercepts
 * @property {boolean} isBlocked
 * @property {Navigation=} navigation
 * @property {number} redirectCount
 * @property {RequestData} request
 * @property {number} timestamp
 */

/**
 * @typedef {object} BlockedRequest
 * @property {NetworkEventRecord} networkEventRecord
 * @property {InterceptPhase} phase
 */

/**
 * Enum of possible BytesValue types.
 *
 * @readonly
 * @enum {BytesValueType}
 */
export const BytesValueType = {
  Base64: "base64",
  String: "string",
};

/**
 * @typedef {object} BytesValue
 * @property {BytesValueType} type
 * @property {string} value
 */

/**
 * Enum of possible network data collector types.
 *
 * @readonly
 * @enum {CollectorType}
 */
const CollectorType = {
  Blob: "blob",
};

/**
 * @typedef {object} Collector
 * @property {number} maxEncodedDataSize
 * @property {Set<DataType>} dataTypes
 * @property {string} collector
 * @property {CollectorType} collectorType
 * @property {Set<string>} userContexts
 */

/**
 * Enum of possible continueWithAuth actions.
 *
 * @readonly
 * @enum {ContinueWithAuthAction}
 */
const ContinueWithAuthAction = {
  Cancel: "cancel",
  Default: "default",
  ProvideCredentials: "provideCredentials",
};

/**
 * @typedef {object} Cookie
 * @property {string} domain
 * @property {number=} expires
 * @property {boolean} httpOnly
 * @property {string} name
 * @property {string} path
 * @property {SameSite} sameSite
 * @property {boolean} secure
 * @property {number} size
 * @property {BytesValue} value
 */

/**
 * @typedef {object} CookieHeader
 * @property {string} name
 * @property {BytesValue} value
 */

/**
 * Enum of possible network data types.
 *
 * @readonly
 * @enum {DataType}
 */
const DataType = {
  Request: "request",
  Response: "response",
};

/**
 * @typedef {object} Data
 * @property {BytesValue|null} bytes
 * @property {Array<Collector>} collectors
 * @property {boolean} pending
 * @property {string} request
 * @property {number} size
 * @property {DataType} type
 */

/**
 * @typedef {object} FetchTimingInfo
 * @property {number} timeOrigin
 * @property {number} requestTime
 * @property {number} redirectStart
 * @property {number} redirectEnd
 * @property {number} fetchStart
 * @property {number} dnsStart
 * @property {number} dnsEnd
 * @property {number} connectStart
 * @property {number} connectEnd
 * @property {number} tlsStart
 * @property {number} requestStart
 * @property {number} responseStart
 * @property {number} responseEnd
 */

/**
 * @typedef {object} Header
 * @property {string} name
 * @property {BytesValue} value
 */

/**
 * @typedef {string} InitiatorType
 */

/**
 * Enum of possible initiator types.
 *
 * @readonly
 * @enum {InitiatorType}
 */
const InitiatorType = {
  Other: "other",
  Parser: "parser",
  Preflight: "preflight",
  Script: "script",
};

/**
 * @typedef {object} Initiator
 * @property {InitiatorType} type
 * @property {number=} columnNumber
 * @property {number=} lineNumber
 * @property {string=} request
 * @property {StackTrace=} stackTrace
 */

/**
 * Enum of intercept phases.
 *
 * @readonly
 * @enum {InterceptPhase}
 */
const InterceptPhase = {
  AuthRequired: "authRequired",
  BeforeRequestSent: "beforeRequestSent",
  ResponseStarted: "responseStarted",
};

/**
 * @typedef {object} InterceptProperties
 * @property {Array<InterceptPhase>} phases
 * @property {Array<URLPattern>} urlPatterns
 */

/**
 * @typedef {object} RequestData
 * @property {number|null} bodySize
 *     Defaults to null.
 * @property {Array<Cookie>} cookies
 * @property {Array<Header>} headers
 * @property {number} headersSize
 * @property {string} method
 * @property {string} request
 * @property {FetchTimingInfo} timings
 * @property {string} url
 */

/**
 * @typedef {object} BeforeRequestSentParametersProperties
 * @property {Initiator} initiator
 */

/* eslint-disable jsdoc/valid-types */
/**
 * Parameters for the BeforeRequestSent event
 *
 * @typedef {BaseParameters & BeforeRequestSentParametersProperties} BeforeRequestSentParameters
 */
/* eslint-enable jsdoc/valid-types */

/**
 * @typedef {object} ResponseContent
 * @property {number|null} size
 *     Defaults to null.
 */

/**
 * @typedef {object} ResponseData
 * @property {string} url
 * @property {string} protocol
 * @property {number} status
 * @property {string} statusText
 * @property {boolean} fromCache
 * @property {Array<Header>} headers
 * @property {string} mimeType
 * @property {number} bytesReceived
 * @property {number|null} headersSize
 *     Defaults to null.
 * @property {number|null} bodySize
 *     Defaults to null.
 * @property {ResponseContent} content
 * @property {Array<AuthChallenge>=} authChallenges
 */

/**
 * @typedef {object} ResponseStartedParametersProperties
 * @property {ResponseData} response
 */

/* eslint-disable jsdoc/valid-types */
/**
 * Parameters for the ResponseStarted event
 *
 * @typedef {BaseParameters & ResponseStartedParametersProperties} ResponseStartedParameters
 */
/* eslint-enable jsdoc/valid-types */

/**
 * @typedef {object} ResponseCompletedParametersProperties
 * @property {ResponseData} response
 */

/**
 * Mapping from nsICookie sameSite constants to network.SameSite values.
 *
 * @readonly
 * @enum {SameSite}
 */
const NetworkCookieSameSiteType = {
  [Ci.nsICookie.SAMESITE_NONE]: "none",
  [Ci.nsICookie.SAMESITE_LAX]: "lax",
  [Ci.nsICookie.SAMESITE_STRICT]: "strict",
  [Ci.nsICookie.SAMESITE_UNSET]: "default",
};

/**
 * Enum of possible sameSite values.
 *
 * @readonly
 * @enum {SameSite}
 */
const SameSite = {
  Default: "default",
  Lax: "lax",
  None: "none",
  Strict: "strict",
};

/**
 * @typedef {object} SetCookieHeader
 * @property {string} name
 * @property {BytesValue} value
 * @property {string=} domain
 * @property {boolean=} httpOnly
 * @property {string=} expiry
 * @property {number=} maxAge
 * @property {string=} path
 * @property {SameSite=} sameSite
 * @property {boolean=} secure
 */

/**
 * @typedef {object} URLPatternPattern
 * @property {'pattern'} type
 * @property {string=} protocol
 * @property {string=} hostname
 * @property {string=} port
 * @property {string=} pathname
 * @property {string=} search
 */

/**
 * @typedef {object} URLPatternString
 * @property {'string'} type
 * @property {string} pattern
 */

/**
 * @typedef {(URLPatternPattern|URLPatternString)} URLPattern
 */

/* eslint-disable jsdoc/valid-types */
/**
 * Parameters for the ResponseCompleted event
 *
 * @typedef {BaseParameters & ResponseCompletedParametersProperties} ResponseCompletedParameters
 */
/* eslint-enable jsdoc/valid-types */

// @see https://searchfox.org/mozilla-central/rev/527d691a542ccc0f333e36689bd665cb000360b2/netwerk/protocol/http/HttpBaseChannel.cpp#2083-2088
const IMMUTABLE_RESPONSE_HEADERS = [
  "content-encoding",
  "content-length",
  "content-type",
  "trailer",
  "transfer-encoding",
];

const UNAVAILABLE_DATA_ERROR_REASON = {
  Aborted: "aborted",
  Evicted: "evicted",
};

class NetworkModule extends RootBiDiModule {
  #blockedRequests;
  #collectedNetworkData;
  #decodedBodySizeMap;
  #extraHeaders;
  #hasExtraHeaders;
  #hasNetworkConditionsOffline;
  #interceptMap;
  #networkCollectors;
  #networkListener;
  #redirectedRequests;
  #subscribedEvents;

  constructor(messageHandler) {
    super(messageHandler);

    // Map of request id to BlockedRequest
    this.#blockedRequests = new Map();

    // Map of collected network Data, from a composite key `${requestId}-${dataType}`
    // to a network Data struct.
    // https://w3c.github.io/webdriver-bidi/#collected-network-data
    // TODO: This is a property of the remote end per spec, not of the session.
    // At the moment, each network module starts its own network observer. This
    // makes it impossible to have a session agnostic step when receiving a new
    // network event.
    // Note: Implemented as a Map. A Map is guaranteed to iterate in the order
    // of insertion, but still provides fast lookup.
    this.#collectedNetworkData = new Map();

    // Implements the BiDi Session extra headers.
    // https://w3c.github.io/webdriver-bidi/#session-extra-headers
    this.#extraHeaders = {
      // Array of Header objects, initially empty.
      defaultHeaders: [],
      // WeakMap between navigables and arrays of Header objects.
      // Due to technical limitations, navigables are represented via the
      // BrowsingContextWebProgress of the top level browsing context.
      navigableHeaders: new WeakMap(),
      // Map between user context ids and arrays of Header objects.
      userContextHeaders: new Map(),
    };

    // Flags used to check if the internal listener should remain enabled even
    // when no public events are subscribed.
    this.#hasExtraHeaders = false;
    this.#hasNetworkConditionsOffline = false;

    // Map of intercept id to InterceptProperties
    this.#interceptMap = new Map();

    // Map of collector id to Collector
    this.#networkCollectors = new Map();

    // Set of request ids which are being redirected using continueRequest with
    // a url parameter. Those requests will lead to an additional beforeRequestSent
    // event which needs to be filtered out.
    this.#redirectedRequests = new Set();

    // Set of event names which have active subscriptions
    this.#subscribedEvents = new Set();

    this.#decodedBodySizeMap = new lazy.NetworkDecodedBodySizeMap();

    this.#networkListener = new lazy.NetworkListener(
      this.messageHandler.navigationManager,
      this.#decodedBodySizeMap
    );
    this.#networkListener.on("auth-required", this.#onAuthRequired);
    this.#networkListener.on("before-request-sent", this.#onBeforeRequestSent);
    this.#networkListener.on("fetch-error", this.#onFetchError);
    this.#networkListener.on("response-completed", this.#onResponseEvent);
    this.#networkListener.on("response-started", this.#onResponseEvent);

    lazy.UserContextManager.on(
      "user-context-deleted",
      this.#onUserContextDeleted
    );
  }

  destroy() {
    lazy.UserContextManager.off(
      "user-context-deleted",
      this.#onUserContextDeleted
    );

    this.#networkListener.off("auth-required", this.#onAuthRequired);
    this.#networkListener.off("before-request-sent", this.#onBeforeRequestSent);
    this.#networkListener.off("fetch-error", this.#onFetchError);
    this.#networkListener.off("response-completed", this.#onResponseEvent);
    this.#networkListener.off("response-started", this.#onResponseEvent);
    this.#networkListener.destroy();

    this.#decodedBodySizeMap.destroy();

    // Network related session cleanup steps
    // https://w3c.github.io/webdriver-bidi/#cleanup-the-session

    // Resume blocked requests
    for (const [, { request }] of this.#blockedRequests) {
      try {
        request.wrappedChannel.resume();
      } catch {
        lazy.logger.warn(
          `Failed to resume request "${request.requestId}" when ending the session`
        );
      }
    }

    // Remove collectors from collected data.
    // TODO: This step is unnecessary until we support multiple sessions, because
    // the collectedNetworkData is attached to the session and is cleaned up
    // afterwards.

    this.#blockedRequests = null;
    this.#collectedNetworkData = null;
    this.#decodedBodySizeMap = null;
    this.#extraHeaders = null;
    this.#interceptMap = null;
    this.#networkCollectors = null;
    this.#subscribedEvents = null;
  }

  /**
   * Adds a data collector to collect network data.
   *
   * @param {object=} options
   * @param {Array<DataType>} options.dataTypes
   *     Maximum size of data to collect in bytes.
   * @param {number} options.maxEncodedDataSize
   *     Maximum size of data to collect in bytes.
   * @param {CollectorType=} options.collectorType
   *     The type of data to collect. Optional, defaults to "blob".
   * @param {Array<string>=} options.contexts
   *     Optional list of browsing context ids.
   * @param {Array<string>=} options.userContexts
   *     Optional list of user context ids.
   *
   * @returns {object}
   *     An object with the following property:
   *     - collector {string} The unique id of the data collector.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   */
  async addDataCollector(options = {}) {
    const {
      dataTypes,
      maxEncodedDataSize,
      collectorType = CollectorType.Blob,
      contexts: contextIds = null,
      userContexts: userContextIds = null,
    } = options;

    lazy.assert.positiveInteger(
      maxEncodedDataSize,
      lazy.pprint`Expected "maxEncodedDataSize" to be a positive integer, got ${maxEncodedDataSize}`
    );

    if (maxEncodedDataSize === 0) {
      throw new lazy.error.InvalidArgumentError(
        `Expected "maxEncodedDataSize" to be greater than 0, got ${maxEncodedDataSize}`
      );
    }

    if (maxEncodedDataSize > lazy.maxTotalDataSize) {
      throw new lazy.error.InvalidArgumentError(
        `Expected "maxEncodedDataSize" to be less than the max total data size available (${lazy.maxTotalDataSize}), got ${maxEncodedDataSize}`
      );
    }

    lazy.assert.isNonEmptyArray(
      dataTypes,
      `Expected "dataTypes" to be a non-empty array, got ${dataTypes}`
    );

    const supportedDataTypes = Object.values(DataType);
    for (const dataType of dataTypes) {
      if (!supportedDataTypes.includes(dataType)) {
        throw new lazy.error.InvalidArgumentError(
          `Expected "dataTypes" values to be one of ${supportedDataTypes},` +
            lazy.pprint` got ${dataType}`
        );
      }
    }

    const supportedCollectorTypes = Object.values(CollectorType);
    if (!supportedCollectorTypes.includes(collectorType)) {
      throw new lazy.error.InvalidArgumentError(
        `Expected "collectorType" to be one of ${supportedCollectorTypes},` +
          lazy.pprint` got ${collectorType}`
      );
    }

    const navigables = new Set();
    const userContexts = new Set();
    if (contextIds !== null) {
      lazy.assert.isNonEmptyArray(
        contextIds,
        lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}`
      );

      for (const contextId of contextIds) {
        lazy.assert.string(
          contextId,
          lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}`
        );
        const context = this._getNavigable(contextId);

        lazy.assert.topLevel(
          context,
          lazy.pprint`Browsing context with id ${contextId} is not top-level`
        );

        navigables.add(contextId);
      }
    }

    if (userContextIds !== null) {
      lazy.assert.isNonEmptyArray(
        userContextIds,
        lazy.pprint`Expected "userContexts" to be a non-empty array, got ${userContextIds}`
      );

      for (const userContextId of userContextIds) {
        lazy.assert.string(
          userContextId,
          lazy.pprint`Expected elements of "userContexts" to be a string, got ${userContextId}`
        );

        const internalId =
          lazy.UserContextManager.getInternalIdById(userContextId);

        if (internalId === null) {
          throw new lazy.error.NoSuchUserContextError(
            `User context with id: ${userContextId} doesn't exist`
          );
        }

        userContexts.add(userContextId);
      }
    }

    if (contextIds !== null && userContextIds !== null) {
      throw new lazy.error.InvalidArgumentError(
        `Providing both "contexts" and "userContexts" arguments is not supported`
      );
    }

    // Generate a unique collector ID
    const collectorId = lazy.generateUUID();

    const collector = {
      collector: collectorId,
      collectorType,
      contexts: navigables,
      dataTypes,
      maxEncodedDataSize,
      userContexts,
    };

    this.#networkCollectors.set(collectorId, collector);

    await this.#updateCollectorSessionData(
      collectorId,
      collector,
      lazy.SessionDataMethod.Add
    );

    return {
      collector: collectorId,
    };
  }

  /**
   * Adds a network intercept, which allows to intercept and modify network
   * requests and responses.
   *
   * The network intercept will be created for the provided phases
   * (InterceptPhase) and for specific url patterns. When a network event
   * corresponding to an intercept phase has a URL which matches any url pattern
   * of any intercept, the request will be suspended.
   *
   * @param {object=} options
   * @param {Array<string>=} options.contexts
   *     The list of browsing context ids where this intercept should be used.
   *     Optional, defaults to null.
   * @param {Array<InterceptPhase>} options.phases
   *     The phases where this intercept should be checked.
   * @param {Array<URLPattern>=} options.urlPatterns
   *     The URL patterns for this intercept. Optional, defaults to empty array.
   *
   * @returns {object}
   *     An object with the following property:
   *     - intercept {string} The unique id of the network intercept.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   */
  addIntercept(options = {}) {
    const { contexts = null, phases, urlPatterns = [] } = options;

    if (contexts !== null) {
      lazy.assert.isNonEmptyArray(
        contexts,
        `Expected "contexts" to be a non-empty array, got ${contexts}`
      );

      for (const contextId of contexts) {
        lazy.assert.string(
          contextId,
          `Expected elements of "contexts" to be a string, got ${contextId}`
        );
        const context = this._getNavigable(contextId);

        lazy.assert.topLevel(
          context,
          lazy.pprint`Browsing context with id ${contextId} is not top-level`
        );
      }
    }

    lazy.assert.isNonEmptyArray(
      phases,
      `Expected "phases" to be a non-empty array, got ${phases}`
    );

    const supportedInterceptPhases = Object.values(InterceptPhase);
    for (const phase of phases) {
      if (!supportedInterceptPhases.includes(phase)) {
        throw new lazy.error.InvalidArgumentError(
          `Expected "phases" values to be one of ${supportedInterceptPhases}, got ${phase}`
        );
      }
    }

    lazy.assert.array(
      urlPatterns,
      `Expected "urlPatterns" to be an array, got ${urlPatterns}`
    );

    const parsedPatterns = urlPatterns.map(urlPattern =>
      lazy.parseURLPattern(urlPattern)
    );

    const interceptId = lazy.generateUUID();
    this.#interceptMap.set(interceptId, {
      contexts,
      phases,
      urlPatterns: parsedPatterns,
    });

    return {
      intercept: interceptId,
    };
  }

  /**
   * Continues a request that is blocked by a network intercept at the
   * beforeRequestSent phase.
   *
   * @param {object=} options
   * @param {string} options.request
   *     The id of the blocked request that should be continued.
   * @param {BytesValue=} options.body
   *     Optional BytesValue to replace the body of the request.
   * @param {Array<CookieHeader>=} options.cookies
   *     Optional array of cookie header values to replace the cookie header of
   *     the request.
   * @param {Array<Header>=} options.headers
   *     Optional array of headers to replace the headers of the request.
   *     request.
   * @param {string=} options.method
   *     Optional string to replace the method of the request.
   * @param {string=} options.url
   *     Optional string to replace the url of the request. If the provided url
   *     is not a valid URL, an InvalidArgumentError will be thrown.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchRequestError}
   *     Raised if the request id does not match any request in the blocked
   *     requests map.
   */
  async continueRequest(options = {}) {
    const {
      body = null,
      cookies = null,
      headers = null,
      method = null,
      url = null,
      request: requestId,
    } = options;

    lazy.assert.string(
      requestId,
      `Expected "request" to be a string, got ${requestId}`
    );

    if (body !== null) {
      this.#assertBytesValue(
        body,
        lazy.truncate`Expected "body" to be a network.BytesValue, got ${body}`
      );
    }

    if (cookies !== null) {
      lazy.assert.array(
        cookies,
        `Expected "cookies" to be an array got ${cookies}`
      );

      for (const cookie of cookies) {
        this.#assertHeader(
          cookie,
          `Expected values in "cookies" to be network.CookieHeader, got ${cookie}`
        );
      }
    }

    let deserializedHeaders = [];
    if (headers !== null) {
      deserializedHeaders = this.#deserializeHeaders(headers);
    }

    if (method !== null) {
      lazy.assert.string(
        method,
        `Expected "method" to be a string, got ${method}`
      );
      lazy.assert.that(
        value => this.#isValidHttpToken(value),
        `Expected "method" to be a valid HTTP token, got ${method}`
      )(method);
    }

    if (url !== null) {
      lazy.assert.string(url, `Expected "url" to be a string, got ${url}`);

      if (!URL.canParse(url)) {
        throw new lazy.error.InvalidArgumentError(
          `Expected "url" to be a valid URL, got ${url}`
        );
      }
    }

    if (!this.#blockedRequests.has(requestId)) {
      throw new lazy.error.NoSuchRequestError(
        `Blocked request with id ${requestId} not found`
      );
    }

    const { phase, request, resolveBlockedEvent } =
      this.#blockedRequests.get(requestId);

    if (phase !== InterceptPhase.BeforeRequestSent) {
      throw new lazy.error.InvalidArgumentError(
        `Expected blocked request to be in "beforeRequestSent" phase, got ${phase}`
      );
    }

    if (method !== null) {
      request.setRequestMethod(method);
    }

    if (headers !== null) {
      // Delete all existing request headers.
      request.headers.forEach(([name]) => {
        request.clearRequestHeader(name);
      });

      // Set all headers specified in the headers parameter.
      for (const [name, value] of deserializedHeaders) {
        request.setRequestHeader(name, value, { merge: true });
      }
    }

    if (cookies !== null) {
      let cookieHeader = "";
      for (const cookie of cookies) {
        if (cookieHeader != "") {
          cookieHeader += ";";
        }
        cookieHeader += this.#serializeCookieHeader(cookie);
      }

      let foundCookieHeader = false;
      for (const [name] of request.headers) {
        if (name.toLowerCase() == "cookie") {
          // If there is already a cookie header, use merge: false to override
          // the value.
          request.setRequestHeader(name, cookieHeader, { merge: false });
          foundCookieHeader = true;
          break;
        }
      }

      if (!foundCookieHeader) {
        request.setRequestHeader("Cookie", cookieHeader, { merge: false });
      }
    }

    if (body !== null) {
      const value = deserializeBytesValue(body);
      request.setRequestBody(value);
    }

    if (url !== null) {
      // Store the requestId in the redirectedRequests set to skip the extra
      // beforeRequestSent event.
      this.#redirectedRequests.add(requestId);
      request.redirectTo(url);
    }

    request.wrappedChannel.resume();

    resolveBlockedEvent();
  }

  /**
   * Continues a response that is blocked by a network intercept at the
   * responseStarted or authRequired phase.
   *
   * @param {object=} options
   * @param {string} options.request
   *     The id of the blocked request that should be continued.
   * @param {Array<SetCookieHeader>=} options.cookies
   *     Optional array of set-cookie header values to replace the set-cookie
   *     headers of the response.
   * @param {AuthCredentials=} options.credentials
   *     Optional AuthCredentials to use.
   * @param {Array<Header>=} options.headers
   *     Optional array of header values to replace the headers of the response.
   * @param {string=} options.reasonPhrase
   *     Optional string to replace the status message of the response.
   * @param {number=} options.statusCode
   *     Optional number to replace the status code of the response.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchRequestError}
   *     Raised if the request id does not match any request in the blocked
   *     requests map.
   */
  async continueResponse(options = {}) {
    const {
      cookies = null,
      credentials = null,
      headers = null,
      reasonPhrase = null,
      request: requestId,
      statusCode = null,
    } = options;

    lazy.assert.string(
      requestId,
      `Expected "request" to be a string, got ${requestId}`
    );

    if (cookies !== null) {
      lazy.assert.array(
        cookies,
        `Expected "cookies" to be an array got ${cookies}`
      );

      for (const cookie of cookies) {
        this.#assertSetCookieHeader(cookie);
      }
    }

    if (credentials !== null) {
      this.#assertAuthCredentials(credentials);
    }

    let deserializedHeaders = [];
    if (headers !== null) {
      // For existing responses, are unable to update some response headers,
      // so we skip them for the time being and log a warning.
      // Bug 1914351 should remove this limitation.
      deserializedHeaders = this.#deserializeHeaders(headers).filter(
        ([name]) => {
          if (IMMUTABLE_RESPONSE_HEADERS.includes(name.toLowerCase())) {
            lazy.logger.warn(
              `network.continueResponse cannot currently modify the header "${name}", skipping (see Bug 1914351).`
            );
            return false;
          }
          return true;
        }
      );
    }

    if (reasonPhrase !== null) {
      lazy.assert.string(
        reasonPhrase,
        `Expected "reasonPhrase" to be a string, got ${reasonPhrase}`
      );
    }

    if (statusCode !== null) {
      lazy.assert.positiveInteger(
        statusCode,
        `Expected "statusCode" to be a positive integer, got ${statusCode}`
      );
    }

    if (!this.#blockedRequests.has(requestId)) {
      throw new lazy.error.NoSuchRequestError(
        `Blocked request with id ${requestId} not found`
      );
    }

    const { authCallbacks, phase, request, resolveBlockedEvent, response } =
      this.#blockedRequests.get(requestId);

    if (headers !== null) {
      // Delete all existing response headers.
      response.headers
        .filter(
          ([name]) =>
            // All headers in IMMUTABLE_RESPONSE_HEADERS cannot be changed and
            // will lead to a NS_ERROR_ILLEGAL_VALUE error.
            // Bug 1914351 should remove this limitation.
            !IMMUTABLE_RESPONSE_HEADERS.includes(name.toLowerCase())
        )
        .forEach(([name]) => response.clearResponseHeader(name));

      for (const [name, value] of deserializedHeaders) {
        response.setResponseHeader(name, value, { merge: true });
      }
    }

    if (cookies !== null) {
      for (const cookie of cookies) {
        const headerValue = this.#serializeSetCookieHeader(cookie);
        response.setResponseHeader("Set-Cookie", headerValue, { merge: true });
      }
    }

    if (statusCode !== null || reasonPhrase !== null) {
      response.setResponseStatus({
        status: statusCode,
        statusText: reasonPhrase,
      });
    }

    if (
      phase !== InterceptPhase.ResponseStarted &&
      phase !== InterceptPhase.AuthRequired
    ) {
      throw new lazy.error.InvalidArgumentError(
        `Expected blocked request to be in "responseStarted" or "authRequired" phase, got ${phase}`
      );
    }

    if (phase === InterceptPhase.AuthRequired) {
      // Requests blocked in the AuthRequired phase should be resumed using
      // authCallbacks.
      if (credentials !== null) {
        await authCallbacks.provideAuthCredentials(
          credentials.username,
          credentials.password
        );
      } else {
        await authCallbacks.provideAuthCredentials();
      }
    } else {
      request.wrappedChannel.resume();
    }

    resolveBlockedEvent();
  }

  /**
   * Continues a response that is blocked by a network intercept at the
   * authRequired phase.
   *
   * @param {object=} options
   * @param {string} options.request
   *     The id of the blocked request that should be continued.
   * @param {string} options.action
   *     The continueWithAuth action, one of ContinueWithAuthAction.
   * @param {AuthCredentials=} options.credentials
   *     The credentials to use for the ContinueWithAuthAction.ProvideCredentials
   *     action.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchRequestError}
   *     Raised if the request id does not match any request in the blocked
   *     requests map.
   */
  async continueWithAuth(options = {}) {
    const { action, credentials, request: requestId } = options;

    lazy.assert.string(
      requestId,
      `Expected "request" to be a string, got ${requestId}`
    );

    if (!Object.values(ContinueWithAuthAction).includes(action)) {
      throw new lazy.error.InvalidArgumentError(
        `Expected "action" to be one of ${Object.values(
          ContinueWithAuthAction
        )} got ${action}`
      );
    }

    if (action == ContinueWithAuthAction.ProvideCredentials) {
      this.#assertAuthCredentials(credentials);
    }

    if (!this.#blockedRequests.has(requestId)) {
      throw new lazy.error.NoSuchRequestError(
        `Blocked request with id ${requestId} not found`
      );
    }

    const { authCallbacks, phase, resolveBlockedEvent } =
      this.#blockedRequests.get(requestId);

    if (phase !== InterceptPhase.AuthRequired) {
      throw new lazy.error.InvalidArgumentError(
        `Expected blocked request to be in "authRequired" phase, got ${phase}`
      );
    }

    switch (action) {
      case ContinueWithAuthAction.Cancel: {
        authCallbacks.cancelAuthPrompt();
        break;
      }
      case ContinueWithAuthAction.Default: {
        authCallbacks.forwardAuthPrompt();
        break;
      }
      case ContinueWithAuthAction.ProvideCredentials: {
        await authCallbacks.provideAuthCredentials(
          credentials.username,
          credentials.password
        );

        break;
      }
    }

    resolveBlockedEvent();
  }

  /**
   * Releases a collected network data for a given collector and data type.
   *
   * @param {object=} options
   * @param {string} options.collector
   *     The collector from which the data should be disowned.
   * @param {string} options.dataType
   *     The data type of the data to disown.
   * @param {string} options.request
   *     The id of the request for which data should be disowned.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchNetworkCollectorError}
   *     Raised if the collector id could not be found in the internal collectors
   *     map.
   * @throws {NoSuchNetworkDataError}
   *     If the network data could not be found for the provided parameters.
   */
  async disownData(options = {}) {
    const { collector, dataType, request: requestId } = options;

    lazy.assert.string(
      requestId,
      lazy.pprint`Expected "request" to be a string, got ${requestId}`
    );

    const supportedDataTypes = Object.values(DataType);
    if (!supportedDataTypes.includes(dataType)) {
      throw new lazy.error.InvalidArgumentError(
        `Expected "dataType" to be one of ${supportedDataTypes},` +
          lazy.pprint` got ${dataType}`
      );
    }

    lazy.assert.string(
      collector,
      lazy.pprint`Expected "collector" to be a string, got ${collector}`
    );

    if (!this.#networkCollectors.has(collector)) {
      throw new lazy.error.NoSuchNetworkCollectorError(
        `Network data collector with id ${collector} not found`
      );
    }

    const collectedData = this.#getCollectedData(requestId, dataType);
    if (!collectedData) {
      throw new lazy.error.NoSuchNetworkDataError(
        `Network data for request id ${requestId} and DataType ${dataType} not found`
      );
    }

    if (!collectedData.collectors.has(collector)) {
      throw new lazy.error.NoSuchNetworkDataError(
        `Network data for request id ${requestId} and DataType ${dataType} does not match collector ${collector}`
      );
    }

    this.#removeCollectorFromData(collectedData, collector);
  }

  /**
   * An object that holds information about a network data content.
   *
   * @typedef NetworkGetDataResult
   *
   * @property {BytesValue} bytes
   *     The network data content as BytesValue.
   */

  /**
   * Retrieve a network data if available.
   *
   * @param {object} options
   * @param {string=} options.collector
   *     Optional id of a collector. If provided, data will only be returned if
   *     the collector is in the network data collectors.
   * @param {DataType} options.dataType
   *     The type of the data to retrieve.
   * @param {boolean=} options.disown
   *     Optional. If set to true, the collector parameter is mandatory and the
   *     collector will be removed from the network data collectors. Defaults to
   *     false.
   * @param {string} options.request
   *     The id of the request of the data to retrieve.
   *
   * @returns {NetworkGetDataResult}
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchNetworkCollectorError}
   *     Raised if the collector id could not be found in the internal collectors
   *     map.
   * @throws {NoSuchNetworkDataError}
   *     If the network data could not be found for the provided parameters.
   * @throws {UnavailableNetworkDataError}
   *     If the network data content is no longer available because it was
   *     evicted.
   */
  async getData(options = {}) {
    const {
      collector = null,
      dataType,
      disown = null,
      request: requestId,
    } = options;

    lazy.assert.string(
      requestId,
      lazy.pprint`Expected "request" to be a string, got ${requestId}`
    );

    const supportedDataTypes = Object.values(DataType);
    if (!supportedDataTypes.includes(dataType)) {
      throw new lazy.error.InvalidArgumentError(
        `Expected "dataType" to be one of ${supportedDataTypes},` +
          lazy.pprint` got ${dataType}`
      );
    }

    if (collector !== null) {
      lazy.assert.string(
        collector,
        lazy.pprint`Expected "collector" to be a string, got ${collector}`
      );

      if (!this.#networkCollectors.has(collector)) {
        throw new lazy.error.NoSuchNetworkCollectorError(
          `Network data collector with id ${collector} not found`
        );
      }
    }

    if (disown !== null) {
      lazy.assert.boolean(
        disown,
        lazy.pprint`Expected "disown" to be a boolean, got ${disown}`
      );

      if (disown && collector === null) {
        throw new lazy.error.InvalidArgumentError(
          `Expected "collector" to be provided when using "disown"=true`
        );
      }
    }

    const collectedData = this.#getCollectedData(requestId, dataType);
    if (!collectedData) {
      throw new lazy.error.NoSuchNetworkDataError(
        `Network data for request id ${requestId} and DataType ${dataType} not found`
      );
    }

    if (collectedData.pending) {
      await collectedData.networkDataCollected.promise;
    }

    if (collector !== null && !collectedData.collectors.has(collector)) {
      throw new lazy.error.NoSuchNetworkDataError(
        `Network data for request id ${requestId} and DataType ${dataType} does not match collector ${collector}`
      );
    }

    if (collectedData.bytes === null) {
      const reason = collectedData.unavailableReason;
      throw new lazy.error.UnavailableNetworkDataError(
        `Network data content for request id ${requestId} and DataType ${dataType} is unavailable (reason: ${reason})`
      );
    }

    const value = await collectedData.bytes.getBytesValue();

    if (disown) {
      this.#removeCollectorFromData(collectedData, collector);
    }

    const type = collectedData.bytes.isBase64
      ? BytesValueType.Base64
      : BytesValueType.String;

    // The data retrieved here is already either a UTF8-decoded string or a
    // base64 encoded binary. No need to re-apply the serializeAsBytesValue
    // algorithm.
    return {
      bytes: {
        type,
        value,
      },
    };
  }

  /**
   * Fails a request that is blocked by a network intercept.
   *
   * @param {object=} options
   * @param {string} options.request
   *     The id of the blocked request that should be continued.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchRequestError}
   *     Raised if the request id does not match any request in the blocked
   *     requests map.
   */
  async failRequest(options = {}) {
    const { request: requestId } = options;

    lazy.assert.string(
      requestId,
      `Expected "request" to be a string, got ${requestId}`
    );

    if (!this.#blockedRequests.has(requestId)) {
      throw new lazy.error.NoSuchRequestError(
        `Blocked request with id ${requestId} not found`
      );
    }

    const { phase, request, resolveBlockedEvent } =
      this.#blockedRequests.get(requestId);

    if (phase === InterceptPhase.AuthRequired) {
      throw new lazy.error.InvalidArgumentError(
        `Expected blocked request not to be in "authRequired" phase`
      );
    }

    request.wrappedChannel.resume();
    request.wrappedChannel.cancel(
      Cr.NS_ERROR_ABORT,
      Ci.nsILoadInfo.BLOCKING_REASON_WEBDRIVER_BIDI
    );

    resolveBlockedEvent();
  }

  /**
   * Continues a request that’s blocked by a network intercept, by providing a
   * complete response.
   *
   * @param {object=} options
   * @param {string} options.request
   *     The id of the blocked request for which the response should be
   *     provided.
   * @param {BytesValue=} options.body
   *     Optional BytesValue to replace the body of the response.
   *     For now, only supported for requests blocked in beforeRequestSent.
   * @param {Array<SetCookieHeader>=} options.cookies
   *     Optional array of set-cookie header values to use for the provided
   *     response.
   *     For now, only supported for requests blocked in beforeRequestSent.
   * @param {Array<Header>=} options.headers
   *     Optional array of header values to use for the provided
   *     response.
   *     For now, only supported for requests blocked in beforeRequestSent.
   * @param {string=} options.reasonPhrase
   *     Optional string to use as the status message for the provided response.
   *     For now, only supported for requests blocked in beforeRequestSent.
   * @param {number=} options.statusCode
   *     Optional number to use as the status code for the provided response.
   *     For now, only supported for requests blocked in beforeRequestSent.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchRequestError}
   *     Raised if the request id does not match any request in the blocked
   *     requests map.
   */
  async provideResponse(options = {}) {
    const {
      body = null,
      cookies = null,
      headers = null,
      reasonPhrase = null,
      request: requestId,
      statusCode = null,
    } = options;

    lazy.assert.string(
      requestId,
      `Expected "request" to be a string, got ${requestId}`
    );

    if (body !== null) {
      this.#assertBytesValue(
        body,
        `Expected "body" to be a network.BytesValue, got ${body}`
      );
    }

    if (cookies !== null) {
      lazy.assert.array(
        cookies,
        `Expected "cookies" to be an array got ${cookies}`
      );

      for (const cookie of cookies) {
        this.#assertSetCookieHeader(cookie);
      }
    }

    let deserializedHeaders = [];
    if (headers !== null) {
      deserializedHeaders = this.#deserializeHeaders(headers);
    }

    if (reasonPhrase !== null) {
      lazy.assert.string(
        reasonPhrase,
        `Expected "reasonPhrase" to be a string, got ${reasonPhrase}`
      );
    }

    if (statusCode !== null) {
      lazy.assert.positiveInteger(
        statusCode,
        `Expected "statusCode" to be a positive integer, got ${statusCode}`
      );
    }

    if (!this.#blockedRequests.has(requestId)) {
      throw new lazy.error.NoSuchRequestError(
        `Blocked request with id ${requestId} not found`
      );
    }

    const { authCallbacks, phase, request, resolveBlockedEvent } =
      this.#blockedRequests.get(requestId);

    // Handle optional arguments for the beforeRequestSent phase.
    // TODO: Support optional arguments in all phases, see Bug 1901055.
    if (phase === InterceptPhase.BeforeRequestSent) {
      // Create a new response.
      const replacedHttpResponse = Cc[
        "@mozilla.org/network/replaced-http-response;1"
      ].createInstance(Ci.nsIReplacedHttpResponse);

      if (statusCode !== null) {
        replacedHttpResponse.responseStatus = statusCode;
      }

      if (reasonPhrase !== null) {
        replacedHttpResponse.responseStatusText = reasonPhrase;
      }

      if (body !== null) {
        replacedHttpResponse.responseBody = deserializeBytesValue(body);
      }

      if (headers !== null) {
        for (const [name, value] of deserializedHeaders) {
          replacedHttpResponse.setResponseHeader(name, value, true);
        }
      }

      if (cookies !== null) {
        for (const cookie of cookies) {
          const headerValue = this.#serializeSetCookieHeader(cookie);
          replacedHttpResponse.setResponseHeader(
            "Set-Cookie",
            headerValue,
            true
          );
        }
      }

      request.setResponseOverride(replacedHttpResponse);
      request.wrappedChannel.resume();
    } else {
      if (body !== null) {
        throw new lazy.error.UnsupportedOperationError(
          `The "body" parameter is only supported for the beforeRequestSent phase at the moment`
        );
      }

      if (cookies !== null) {
        throw new lazy.error.UnsupportedOperationError(
          `The "cookies" parameter is only supported for the beforeRequestSent phase at the moment`
        );
      }

      if (headers !== null) {
        throw new lazy.error.UnsupportedOperationError(
          `The "headers" parameter is only supported for the beforeRequestSent phase at the moment`
        );
      }

      if (reasonPhrase !== null) {
        throw new lazy.error.UnsupportedOperationError(
          `The "reasonPhrase" parameter is only supported for the beforeRequestSent phase at the moment`
        );
      }

      if (statusCode !== null) {
        throw new lazy.error.UnsupportedOperationError(
          `The "statusCode" parameter is only supported for the beforeRequestSent phase at the moment`
        );
      }

      if (phase === InterceptPhase.AuthRequired) {
        // AuthRequired with no optional argument, resume the authentication.
        await authCallbacks.provideAuthCredentials();
      } else {
        // Any phase other than AuthRequired with no optional argument, resume the
        // request.
        request.wrappedChannel.resume();
      }
    }

    resolveBlockedEvent();
  }

  /**
   * Removes a data collector.
   *
   * @param {object=} options
   * @param {string} options.collector
   *     The id of the collector to remove.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchNetworkCollectorError}
   *     Raised if the collector id could not be found in the internal collectors
   *     map.
   */
  async removeDataCollector(options = {}) {
    const { collector } = options;

    lazy.assert.string(
      collector,
      lazy.pprint`Expected "collector" to be a string, got ${collector}`
    );

    if (!this.#networkCollectors.has(collector)) {
      throw new lazy.error.NoSuchNetworkCollectorError(
        `Network data collector with id ${collector} not found`
      );
    }

    const collectorData = this.#networkCollectors.get(collector);
    this.#networkCollectors.delete(collector);

    for (const [, collectedData] of this.#collectedNetworkData) {
      this.#removeCollectorFromData(collectedData, collector);
    }

    await this.#updateCollectorSessionData(
      collector,
      collectorData,
      lazy.SessionDataMethod.Remove
    );
  }

  /**
   * Removes an existing network intercept.
   *
   * @param {object=} options
   * @param {string} options.intercept
   *     The id of the intercept to remove.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchInterceptError}
   *     Raised if the intercept id could not be found in the internal intercept
   *     map.
   */
  removeIntercept(options = {}) {
    const { intercept } = options;

    lazy.assert.string(
      intercept,
      `Expected "intercept" to be a string, got ${intercept}`
    );

    if (!this.#interceptMap.has(intercept)) {
      throw new lazy.error.NoSuchInterceptError(
        `Network intercept with id ${intercept} not found`
      );
    }

    this.#interceptMap.delete(intercept);
  }

  /**
   * Configures the network cache behavior for certain requests.
   *
   * @param {object=} options
   * @param {CacheBehavior} options.cacheBehavior
   *     An enum value to set the network cache behavior.
   * @param {Array<string>=} options.contexts
   *     The list of browsing context ids where the network cache
   *     behavior should be updated.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchFrameError}
   *     If the browsing context cannot be found.
   */
  setCacheBehavior(options = {}) {
    const { cacheBehavior: behavior, contexts: contextIds = null } = options;

    if (!Object.values(lazy.CacheBehavior).includes(behavior)) {
      throw new lazy.error.InvalidArgumentError(
        `Expected "cacheBehavior" to be one of ${Object.values(
          lazy.CacheBehavior
        )}` + lazy.pprint` got ${behavior}`
      );
    }

    if (contextIds === null) {
      // Set the default behavior if no specific context is specified.
      lazy.updateCacheBehavior(behavior);
      return;
    }

    lazy.assert.isNonEmptyArray(
      contextIds,
      lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}`
    );

    const contexts = new Set();
    for (const contextId of contextIds) {
      lazy.assert.string(
        contextId,
        lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}`
      );
      const context = this._getNavigable(contextId);

      lazy.assert.topLevel(
        context,
        lazy.pprint`Browsing context with id ${contextId} is not top-level`
      );

      contexts.add(context);
    }

    lazy.updateCacheBehavior(behavior, contexts);
  }

  /**
   * Allows to specify headers that will extend, or overwrite, existing request
   * headers.
   *
   * @param {object=} options
   * @param {Array<Header>} options.headers
   *     Array of header values to replace the headers of the response.
   * @param {Array<string>=} options.contexts
   *     Optional list of browsing context ids.
   * @param {Array<string>=} options.userContexts
   *     Optional list of user context ids.
   *
   * @throws {InvalidArgumentError}
   *     Raised if an argument is of an invalid type or value.
   * @throws {NoSuchFrameError}
   *     If the browsing context cannot be found.
   */
  setExtraHeaders(options = {}) {
    const {
      headers,
      contexts: contextIds = NULL,
      userContexts: userContextIds = NULL,
    } = options;

    lazy.assert.array(
      headers,
      lazy.pprint`Expected "headers" to be an array, got ${headers}`
    );

    const deserializedHeaders = this.#deserializeHeaders(headers);

    if (contextIds !== NULL && userContextIds !== NULL) {
      throw new lazy.error.InvalidArgumentError(
        `Providing both "contexts" and "userContexts" arguments is not supported`
      );
    }

    const navigables = new Set();
    const userContexts = new Set();
    if (userContextIds !== NULL) {
      lazy.assert.isNonEmptyArray(
        userContextIds,
        lazy.pprint`Expected "userContexts" to be a non-empty array, got ${userContextIds}`
      );

      for (const userContextId of userContextIds) {
        lazy.assert.string(
          userContextId,
          lazy.pprint`Expected elements of "userContexts" to be a string, got ${userContextId}`
        );

        const internalId =
          lazy.UserContextManager.getInternalIdById(userContextId);

        if (internalId === null) {
          throw new lazy.error.NoSuchUserContextError(
            `User context with id: ${userContextId} doesn't exist`
          );
        }

        userContexts.add(userContextId);
      }
    }

    if (contextIds !== NULL) {
      lazy.assert.isNonEmptyArray(
        contextIds,
        lazy.pprint`Expected "contexts" to be a non-empty array, got ${contextIds}`
      );

      for (const contextId of contextIds) {
        lazy.assert.string(
          contextId,
          lazy.pprint`Expected elements of "contexts" to be a string, got ${contextId}`
        );
        const context = this._getNavigable(contextId);

        lazy.assert.topLevel(
          context,
          lazy.pprint`Browsing context with id ${contextId} is not top-level`
        );

        navigables.add(contextId);
      }
    }

    if (userContextIds !== NULL) {
      for (const userContextId of userContexts) {
        this.#extraHeaders.userContextHeaders.set(
          userContextId,
          deserializedHeaders
        );
      }
    } else if (contextIds !== NULL) {
      for (const contextId of navigables) {
        const context = this._getNavigable(contextId);
        this.#extraHeaders.navigableHeaders.set(
          context.webProgress,
          deserializedHeaders
        );
      }
    } else {
      this.#extraHeaders.defaultHeaders = deserializedHeaders;
    }

    if (!this.#hasExtraHeaders && headers.length) {
      this.#hasExtraHeaders = true;
      this.#networkListener.startListening();
    }
  }

  /**
   * Add a new request in the blockedRequests map.
   *
   * @param {string} requestId
   *     The request id.
   * @param {InterceptPhase} phase
   *     The phase where the request is blocked.
   * @param {object=} options
   * @param {object=} options.authCallbacks
   *     Only defined for requests blocked in the authRequired phase.
   *     Provides callbacks to handle the authentication.
   * @param {nsIChannel=} options.requestChannel
   *     The request channel.
   * @param {nsIChannel=} options.responseChannel
   *     The response channel.
   */
  #addBlockedRequest(requestId, phase, options = {}) {
    const { authCallbacks, request, response } = options;
    const { promise: blockedEventPromise, resolve: resolveBlockedEvent } =
      Promise.withResolvers();

    this.#blockedRequests.set(requestId, {
      authCallbacks,
      request,
      response,
      resolveBlockedEvent,
      phase,
    });

    blockedEventPromise.finally(() => {
      this.#blockedRequests.delete(requestId);
    });
  }

  /**
   * Implements https://w3c.github.io/webdriver-bidi/#allocate-size-to-record-data
   *
   * @param {number} size
   *     The size to allocate in bytes.
   */
  #allocateSizeToRecordData(size) {
    let availableSize = lazy.maxTotalDataSize;
    const alreadyCollectedData = [];
    for (const [, collectedData] of this.#collectedNetworkData) {
      if (collectedData.bytes !== null) {
        availableSize = availableSize - collectedData.size;
        alreadyCollectedData.push(collectedData);
      }
    }

    if (size > availableSize) {
      for (const collectedData of alreadyCollectedData) {
        availableSize = availableSize + collectedData.size;
        collectedData.bytes = null;
        collectedData.unavailableReason = UNAVAILABLE_DATA_ERROR_REASON.Evicted;
        collectedData.size = null;

        if (size <= availableSize) {
          return;
        }
      }
    }
  }

  #assertAuthCredentials(credentials) {
    lazy.assert.object(
      credentials,
      `Expected "credentials" to be an object, got ${credentials}`
    );

    if (credentials.type !== "password") {
      throw new lazy.error.InvalidArgumentError(
        `Expected credentials "type" to be "password" got ${credentials.type}`
      );
    }

    lazy.assert.string(
      credentials.username,
      `Expected credentials "username" to be a string, got ${credentials.username}`
    );
    lazy.assert.string(
      credentials.password,
      `Expected credentials "password" to be a string, got ${credentials.password}`
    );
  }

  #assertBytesValue(obj, msg) {
    lazy.assert.object(obj, msg);
    lazy.assert.string(obj.value, msg);
    lazy.assert.in(obj.type, Object.values(BytesValueType), msg);
  }

  #assertHeader(value, msg) {
    lazy.assert.object(value, msg);
    lazy.assert.string(value.name, msg);
    this.#assertBytesValue(value.value, msg);
  }

  #assertSetCookieHeader(setCookieHeader) {
    lazy.assert.object(
      setCookieHeader,
      `Expected set-cookie header to be an object, got ${setCookieHeader}`
    );

    const {
      name,
      value,
      domain = null,
      httpOnly = null,
      expiry = null,
      maxAge = null,
      path = null,
      sameSite = null,
      secure = null,
    } = setCookieHeader;

    lazy.assert.string(
      name,
      `Expected set-cookie header "name" to be a string, got ${name}`
    );

    this.#assertBytesValue(
      value,
      `Expected set-cookie header "value" to be a BytesValue, got ${name}`
    );

    if (domain !== null) {
      lazy.assert.string(
        domain,
        `Expected set-cookie header "domain" to be a string, got ${domain}`
      );
    }
    if (httpOnly !== null) {
      lazy.assert.boolean(
        httpOnly,
        `Expected set-cookie header "httpOnly" to be a boolean, got ${httpOnly}`
      );
    }
    if (expiry !== null) {
      lazy.assert.string(
        expiry,
        `Expected set-cookie header "expiry" to be a string, got ${expiry}`
      );
    }
    if (maxAge !== null) {
      lazy.assert.integer(
        maxAge,
        `Expected set-cookie header "maxAge" to be an integer, got ${maxAge}`
      );
    }
    if (path !== null) {
      lazy.assert.string(
        path,
        `Expected set-cookie header "path" to be a string, got ${path}`
      );
    }
    if (sameSite !== null) {
      lazy.assert.in(
        sameSite,
        Object.values(SameSite),
        `Expected set-cookie header "sameSite" to be one of ${Object.values(
          SameSite
        )}, got ${sameSite}`
      );
    }
    if (secure !== null) {
      lazy.assert.boolean(
        secure,
        `Expected set-cookie header "secure" to be a boolean, got ${secure}`
      );
    }
  }

  #cloneNetworkRequestBody(request) {
    if (!this.#networkCollectors.size) {
      return;
    }

    // If request body is missing or null, do not store any collected data.
    if (!request.postData || request.postData === null) {
      return;
    }

    const key = `${request.requestId}-${DataType.Request}`;
    if (this.#collectedNetworkData.has(key)) {
      // For redirected requests we might already be tracking the body.
      return;
    }

    const collectedData = {
      bytes: null,
      collectors: new Set(),
      pending: true,
      // This allows to implement the await/resume on "network data collected"
      // described in the specification.
      networkDataCollected: Promise.withResolvers(),
      request: request.requestId,
      size: null,
      type: DataType.Request,
    };

    // The actual cloning is already handled by the DevTools
    // NetworkResponseListener, here we just have to prepare the networkData and
    // add it to the array.
    this.#collectedNetworkData.set(key, collectedData);
  }

  #cloneNetworkResponseBody(request) {
    if (!this.#networkCollectors.size) {
      return;
    }

    const key = `${request.requestId}-${DataType.Response}`;
    if (this.#collectedNetworkData.has(key)) {
      // For redirected requests we might already be tracking the body.
      return;
    }

    const collectedData = {
      bytes: null,
      // The cloned body is fully handled by DevTools' NetworkResponseListener
      // so it will not explicitly be stored here.
      collectors: new Set(),
      pending: true,
      // This allows to implement the await/resume on "network data collected"
      // described in the specification.
      networkDataCollected: Promise.withResolvers(),
      request: request.requestId,
      size: null,
      type: DataType.Response,
      // Internal string used in the UnavailableNetworkData error message.
      unavailableReason: null,
    };

    // The actual cloning is already handled by the DevTools
    // NetworkResponseListener, here we just have to prepare the networkData and
    // add it to the array.
    this.#collectedNetworkData.set(key, collectedData);
  }

  #deserializeHeader(protocolHeader) {
    const name = protocolHeader.name;
    const value = deserializeBytesValue(protocolHeader.value);
    return [name, value];
  }

  #deserializeHeaders(headers) {
    const deserializedHeaders = [];
    lazy.assert.array(
      headers,
      lazy.pprint`Expected "headers" to be an array got ${headers}`
    );

    for (const header of headers) {
      this.#assertHeader(
        header,
        lazy.pprint`Expected values in "headers" to be network.Header, got ${header}`
      );

      // Deserialize headers immediately to validate the value
      const deserializedHeader = this.#deserializeHeader(header);
      lazy.assert.that(
        value => this.#isValidHttpToken(value),
        lazy.pprint`Expected "header" name to be a valid HTTP token, got ${deserializedHeader[0]}`
      )(deserializedHeader[0]);
      lazy.assert.that(
        value => this.#isValidHeaderValue(value),
        lazy.pprint`Expected "header" value to be a valid header value, got ${deserializedHeader[1]}`
      )(deserializedHeader[1]);

      deserializedHeaders.push(deserializedHeader);
    }

    return deserializedHeaders;
  }

  #extractChallenges(response) {
    let headerName;

    // Using case-insensitive match for header names, so we use the lowercase
    // version of the "WWW-Authenticate" / "Proxy-Authenticate" strings.
    if (response.status === 401) {
      headerName = "www-authenticate";
    } else if (response.status === 407) {
      headerName = "proxy-authenticate";
    } else {
      return null;
    }

    const challenges = [];
    for (const [name, value] of response.headers) {
      if (name.toLowerCase() === headerName) {
        // A single header can contain several challenges.
        const headerChallenges = lazy.parseChallengeHeader(value);
        for (const headerChallenge of headerChallenges) {
          const realmParam = headerChallenge.params.find(
            param => param.name == "realm"
          );
          const realm = realmParam ? realmParam.value : undefined;
          const challenge = {
            scheme: headerChallenge.scheme,
            realm,
          };
          challenges.push(challenge);
        }
      }
    }

    return challenges;
  }

  #getCollectedData(requestId, dataType) {
    return this.#collectedNetworkData.get(`${requestId}-${dataType}`) || null;
  }

  #getNetworkIntercepts(event, request, topContextId) {
    if (!request.supportsInterception) {
      // For requests which do not support interception (such as data URIs or
      // cached resources), do not attempt to match intercepts.
      return [];
    }

    const intercepts = [];

    let phase;
    switch (event) {
      case "network.beforeRequestSent":
        phase = InterceptPhase.BeforeRequestSent;
        break;
      case "network.responseStarted":
        phase = InterceptPhase.ResponseStarted;
        break;
      case "network.authRequired":
        phase = InterceptPhase.AuthRequired;
        break;
      case "network.responseCompleted":
        // The network.responseCompleted event does not match any interception
        // phase. Return immediately.
        return intercepts;
    }

    const url = request.serializedURL;
    for (const [interceptId, intercept] of this.#interceptMap) {
      if (
        intercept.contexts !== null &&
        !intercept.contexts.includes(topContextId)
      ) {
        // Skip this intercept if the event's context does not match the list
        // of contexts for this intercept.
        continue;
      }

      if (intercept.phases.includes(phase)) {
        const urlPatterns = intercept.urlPatterns;
        if (
          !urlPatterns.length ||
          urlPatterns.some(pattern => lazy.matchURLPattern(pattern, url))
        ) {
          intercepts.push(interceptId);
        }
      }
    }

    return intercepts;
  }

  #getCookiesForRequest(request) {
    if (!request.host) {
      return [];
    }

    const storeCookies = Services.cookies.getCookiesWithOriginAttributes(
      request.originAttributesString,
      request.host
    );

    const headerCookieNames = new Set();
    for (const [name, value] of request.headers) {
      if (name.toLowerCase() === "cookie") {
        for (const part of value.split(";")) {
          const eq = part.indexOf("=");
          if (eq !== -1) {
            headerCookieNames.add(unescape(part.substr(0, eq).trim()));
          }
        }
      }
    }

    const cookies = [];
    for (const cookie of storeCookies) {
      if (headerCookieNames.has(cookie.name)) {
        cookies.push(this.#serializeNetworkCookie(cookie));
      }
    }

    return cookies;
  }

  #getRequestData(request) {
    const requestId = request.requestId;

    // "Let url be the result of running the URL serializer with request’s URL"
    // request.serializedURL is already serialized.
    const url = request.serializedURL;
    const method = request.method;

    const bodySize = request.postDataSize;
    const headersSize = request.headersSize;
    const headers = [];

    for (const [name, value] of request.headers) {
      headers.push(this.#serializeHeader(name, value));
    }

    const cookies = this.#getCookiesForRequest(request);

    const destination = request.destination;
    const initiatorType = request.initiatorType;
    const timings = request.timings;

    return {
      request: requestId,
      url,
      method,
      bodySize,
      headersSize,
      headers,
      cookies,
      destination,
      initiatorType,
      timings,
    };
  }

  #getResponseContentInfo(response) {
    return {
      size: response.decodedBodySize,
    };
  }

  #getResponseData(response) {
    const url = response.serializedURL;
    const protocol = response.protocol;
    const status = response.status;
    const statusText = response.statusMessage;
    // TODO: Ideally we should have a `isCacheStateLocal` getter
    // const fromCache = response.isCacheStateLocal();
    const fromCache = response.fromCache;
    const mimeType = response.mimeType;
    const headers = [];
    for (const [name, value] of response.headers) {
      headers.push(this.#serializeHeader(name, value));
    }

    const bytesReceived = response.totalTransmittedSize;
    const headersSize = response.headersTransmittedSize;
    const bodySize = response.encodedBodySize;
    const content = this.#getResponseContentInfo(response);
    const authChallenges = this.#extractChallenges(response);

    const params = {
      url,
      protocol,
      status,
      statusText,
      fromCache,
      headers,
      mimeType,
      bytesReceived,
      headersSize,
      bodySize,
      content,
    };

    if (authChallenges !== null) {
      params.authChallenges = authChallenges;
    }

    return params;
  }

  #getSuspendMarkerText(requestData, phase) {
    return `Request (id: ${requestData.request}) suspended by WebDriver BiDi in ${phase} phase`;
  }

  #isValidHeaderValue(value) {
    if (!value.length) {
      return true;
    }

    // For non-empty strings check against:
    // - leading or trailing tabs & spaces
    // - new lines and null bytes
    const chars = value.split("");
    const tabOrSpace = [" ", "\t"];
    const forbiddenChars = ["\r", "\n", "\0"];
    return (
      !tabOrSpace.includes(chars.at(0)) &&
      !tabOrSpace.includes(chars.at(-1)) &&
      forbiddenChars.every(c => !chars.includes(c))
    );
  }

  /**
   * This helper is adapted from a C++ validation helper in nsHttp.cpp.
   *
   * @see https://searchfox.org/mozilla-central/rev/445a6e86233c733c5557ef44e1d33444adaddefc/netwerk/protocol/http/nsHttp.cpp#169
   */
  #isValidHttpToken(token) {
    // prettier-ignore
    // This array corresponds to all char codes between 0 and 127, which is the
    // range of supported char codes for HTTP tokens. Within this range,
    // accepted char codes are marked with a 1, forbidden char codes with a 0.
    const validTokenMap = [
      0, 0, 0, 0, 0, 0, 0, 0,  //   0
      0, 0, 0, 0, 0, 0, 0, 0,  //   8
      0, 0, 0, 0, 0, 0, 0, 0,  //  16
      0, 0, 0, 0, 0, 0, 0, 0,  //  24

      0, 1, 0, 1, 1, 1, 1, 1,  //  32
      0, 0, 1, 1, 0, 1, 1, 0,  //  40
      1, 1, 1, 1, 1, 1, 1, 1,  //  48
      1, 1, 0, 0, 0, 0, 0, 0,  //  56

      0, 1, 1, 1, 1, 1, 1, 1,  //  64
      1, 1, 1, 1, 1, 1, 1, 1,  //  72
      1, 1, 1, 1, 1, 1, 1, 1,  //  80
      1, 1, 1, 0, 0, 0, 1, 1,  //  88

      1, 1, 1, 1, 1, 1, 1, 1,  //  96
      1, 1, 1, 1, 1, 1, 1, 1,  // 104
      1, 1, 1, 1, 1, 1, 1, 1,  // 112
      1, 1, 1, 0, 1, 0, 1, 0   // 120
    ];

    if (!token.length) {
      return false;
    }
    return token
      .split("")
      .map(s => s.charCodeAt(0))
      .every(c => validTokenMap[c]);
  }

  /**
   * Implements https://w3c.github.io/webdriver-bidi/#match-collector-for-navigable
   *
   * @param {Collector} collector
   *     The collector to match
   * @param {BrowsingContext} navigable
   *     The navigable (BrowsingContext) to match
   * @returns {boolean}
   *     True if the collector corresponds to the provided navigable. False
   *     otherwise.
   */
  #matchCollectorForNavigable(collector, navigable) {
    if (collector.contexts.size) {
      const navigableId =
        lazy.NavigableManager.getIdForBrowsingContext(navigable);
      return collector.contexts.has(navigableId);
    }

    if (collector.userContexts.size) {
      const userContext =
        lazy.UserContextManager.getIdByBrowsingContext(navigable);
      return collector.userContexts.has(userContext);
    }

    // Return true.
    return true;
  }

  /**
   * Implements https://w3c.github.io/webdriver-bidi/#maybe-abort-network-response-body-collection
   *
   * @param {NetworkRequest} request
   *     The request object for which we want to abort the body collection.
   */
  #maybeAbortNetworkResponseBodyCollection(request) {
    const collectedData = this.#getCollectedData(
      request.requestId,
      DataType.Response
    );
    if (collectedData === null) {
      return;
    }

    lazy.logger.trace(
      `Network data not collected for request "${request.requestId}" and data type "${DataType.Response}"` +
        `: fetch error`
    );
    collectedData.pending = false;
    collectedData.unavailableReason = UNAVAILABLE_DATA_ERROR_REASON.Aborted;
    collectedData.networkDataCollected.resolve();
  }

  /**
   * Implements https://w3c.github.io/webdriver-bidi/#maybe-collect-network-request-body
   *
   * @param {NetworkRequest} request
   *     The request object for which we want to collect the body.
   */
  async #maybeCollectNetworkRequestBody(request) {
    const collectedData = this.#getCollectedData(
      request.requestId,
      DataType.Request
    );

    if (collectedData === null) {
      return;
    }

    this.#maybeCollectNetworkData({
      collectedData,
      dataType: DataType.Request,
      request,
      readAndProcessBodyFn: request.readAndProcessRequestBody,
      size: request.postDataSize,
    });
  }

  /**
   * Implements https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-response-body
   *
   * @param {NetworkRequest} request
   *     The request object for which we want to collect the body.
   * @param {NetworkResponse} response
   *     The response object for which we want to collect the body.
   */
  async #maybeCollectNetworkResponseBody(request, response) {
    if (response.willRedirect) {
      return;
    }

    const collectedData = this.#getCollectedData(
      request.requestId,
      DataType.Response
    );

    if (collectedData === null) {
      return;
    }

    if (
      !(response instanceof lazy.NetworkResponse) &&
      !response.isDataURL &&
      !response.hasCachedResponseBody
    ) {
      lazy.logger.trace(
        `Network data not collected for request "${request.requestId}" and data type "${DataType.Response}"` +
          `: unsupported response (read from memory cache)`
      );
      // Cached stencils do not return any response body.
      collectedData.pending = false;
      collectedData.networkDataCollected.resolve();
      this.#collectedNetworkData.delete(
        `${collectedData.request}-${collectedData.type}`
      );
      return;
    }

    let readAndProcessBodyFn, size;
    if (response.isDataURL) {
      // Handle data URLs as a special case since the response is not provided
      // by the DevTools ResponseListener in this case.
      const url = request.serializedURL;
      const body = url.substring(url.indexOf(",") + 1);
      const isText =
        response.mimeType &&
        lazy.NetworkHelper.isTextMimeType(response.mimeType);

      readAndProcessBodyFn = () =>
        new lazy.NetworkDataBytes({
          getBytesValue: () => body,
          isBase64: !isText,
        });
      size = body.length;
    } else if (response.hasCachedResponseBody) {
      readAndProcessBodyFn = () =>
        new lazy.NetworkDataBytes({
          getBytesValue: () => response.cachedResponseBody,
          isBase64: false,
        });
      size = response.cachedResponseBody.length;
    } else {
      readAndProcessBodyFn = response.readAndProcessResponseBody;
      size = response.encodedBodySize;
    }

    this.#maybeCollectNetworkData({
      collectedData,
      dataType: DataType.Response,
      request,
      readAndProcessBodyFn,
      size,
    });
  }

  /**
   * Implements https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-data
   *
   * @param {object} options
   * @param {Data} options.collectedData
   * @param {DataType} options.dataType
   * @param {NetworkRequest} options.request
   * @param {Function} options.readAndProcessBodyFn
   * @param {number} options.size
   */
  async #maybeCollectNetworkData(options) {
    const {
      collectedData,
      dataType,
      request,
      // Note: this parameter is not present in
      // https://www.w3.org/TR/webdriver-bidi/#maybe-collect-network-data
      // Each caller is responsible for providing a callable which will return
      // a NetworkDataBytes instance corresponding to the collected data.
      readAndProcessBodyFn,
      // Note: the spec assumes that in some cases the size can be computed
      // dynamically. But in practice we might be storing encoding data in a
      // format which makes it hard to get the size. So here we always expect
      // callers to provide a size.
      size,
    } = options;

    const browsingContext = lazy.NavigableManager.getBrowsingContextById(
      request.contextId
    );
    if (!browsingContext) {
      lazy.logger.trace(
        `Network data not collected for request "${request.requestId}" and data type "${dataType}"` +
          `: navigable no longer available`
      );
      collectedData.pending = false;
      this.#collectedNetworkData.delete(
        `${collectedData.request}-${collectedData.type}`
      );
      collectedData.networkDataCollected.resolve();
      return;
    }

    const topNavigable = browsingContext.top;
    let collectors = [];
    for (const [, collector] of this.#networkCollectors) {
      if (
        collector.dataTypes.includes(dataType) &&
        this.#matchCollectorForNavigable(collector, topNavigable)
      ) {
        collectors.push(collector);
      }
    }

    if (!collectors.length) {
      lazy.logger.trace(
        `Network data not collected for request "${request.requestId}" and data type "${dataType}"` +
          `: no matching collector`
      );
      collectedData.pending = false;
      this.#collectedNetworkData.delete(
        `${collectedData.request}-${collectedData.type}`
      );
      collectedData.networkDataCollected.resolve();
      return;
    }

    let bytes = null;

    // At this point, the specification expects to processBody for the cloned
    // body. Here we do not explicitly clone the bodies.
    // For responses, DevTools' NetworkResponseListener clones the stream.
    // For requests, NetworkHelper.readPostTextFromRequest clones the stream on
    // the fly to read it as text.
    try {
      const bytesOrNull = await readAndProcessBodyFn();
      if (bytesOrNull !== null) {
        bytes = bytesOrNull;
      }
    } catch {
      // Let processBodyError be this step: Do nothing.
    }

    // If the network module was destroyed while waiting to read the response
    // body, the session has been destroyed. Resolve the promise and bail out.
    if (!this.#collectedNetworkData) {
      collectedData.networkDataCollected.resolve();
      return;
    }

    if (bytes !== null) {
      for (const collector of collectors) {
        if (size <= collector.maxEncodedDataSize) {
          collectedData.collectors.add(collector.collector);
        }
      }

      if (collectedData.collectors.size) {
        this.#allocateSizeToRecordData(size);
        collectedData.bytes = bytes;
        collectedData.size = size;
      }
    }

    // Note: specification flips `collectedData.pending` to false earlier, but
    // the implementation is async with `await response.readResponseBody()`.
    // `collectedData.pending` is only flipped before returning here - and in
    // early returns above.
    collectedData.pending = false;
    if (!collectedData.collectors.size) {
      this.#collectedNetworkData.delete(
        `${collectedData.request}-${collectedData.type}`
      );
    }
    collectedData.networkDataCollected.resolve();
  }

  #onAuthRequired = (name, data) => {
    const { authCallbacks, request, response } = data;

    let isBlocked = false;
    try {
      const browsingContext = lazy.NavigableManager.getBrowsingContextById(
        request.contextId
      );
      if (!browsingContext) {
        // Do not emit events if the context id does not match any existing
        // browsing context.
        return;
      }

      const protocolEventName = "network.authRequired";

      const isListening = this._hasListener(protocolEventName, {
        contextId: browsingContext.id,
      });
      if (!isListening) {
        // If there are no listeners subscribed to this event and this context,
        // bail out.
        return;
      }

      const baseParameters = this.#processNetworkEvent(
        protocolEventName,
        request
      );

      const responseData = this.#getResponseData(response);
      const authRequiredEvent = {
        ...baseParameters,
        response: responseData,
      };

      this._emitEventForBrowsingContext(
        browsingContext.id,
        protocolEventName,
        authRequiredEvent
      );

      if (authRequiredEvent.isBlocked) {
        isBlocked = true;

        // requestChannel.suspend() is not needed here because the request is
        // already blocked on the authentication prompt notification until
        // one of the authCallbacks is called.
        this.#addBlockedRequest(
          authRequiredEvent.request.request,
          InterceptPhase.AuthRequired,
          {
            authCallbacks,
            request,
            response,
          }
        );
      }
    } finally {
      if (!isBlocked) {
        // If the request was not blocked, forward the auth prompt notification
        // to the next consumer.
        authCallbacks.forwardAuthPrompt();
      }
    }
  };

  #onBeforeRequestSent = (name, data) => {
    const { request } = data;

    if (this.#redirectedRequests.has(request.requestId)) {
      // If this beforeRequestSent event corresponds to a request that has
      // just been redirected using continueRequest, skip the event and remove
      // it from the redirectedRequests set.
      this.#redirectedRequests.delete(request.requestId);
      return;
    }

    const browsingContext = lazy.NavigableManager.getBrowsingContextById(
      request.contextId
    );
    if (!browsingContext) {
      // Do not emit events if the context id does not match any existing
      // browsing context.
      return;
    }

    // Make sure a collected data is created for the request.
    // Note: this is supposed to be triggered from fetch and doesn't depend on
    // whether network events are used or not.
    this.#cloneNetworkRequestBody(request);

    const relatedNavigables = [browsingContext];
    this.#updateRequestHeaders(request, relatedNavigables);

    const protocolEventName = "network.beforeRequestSent";

    const isListening = this._hasListener(protocolEventName, {
      contextId: browsingContext.id,
    });

    if (isListening) {
      this.#maybeCollectNetworkRequestBody(request);

      const baseParameters = this.#processNetworkEvent(
        protocolEventName,
        request
      );

      // Bug 1805479: Handle the initiator, including stacktrace details.
      const initiator = {
        type: InitiatorType.Other,
      };

      const beforeRequestSentEvent = {
        ...baseParameters,
        initiator,
      };

      this._emitEventForBrowsingContext(
        browsingContext.id,
        protocolEventName,
        beforeRequestSentEvent
      );
      if (beforeRequestSentEvent.isBlocked) {
        request.wrappedChannel.suspend(
          this.#getSuspendMarkerText(request, "beforeRequestSent")
        );

        this.#addBlockedRequest(
          beforeRequestSentEvent.request.request,
          InterceptPhase.BeforeRequestSent,
          {
            request,
          }
        );
      }
    }

    // If network conditions are set to "offline", most requests should be
    // prevented, but some are still sent (e.g. keep-alive).
    // Per https://w3c.github.io/webdriver-bidi/#webdriver-bidi-before-request-sent
    // this should be handled after emitting the beforeRequestSent event.
    if (browsingContext.top?.forceOffline) {
      request.wrappedChannel.cancel(
        Cr.NS_ERROR_OFFLINE,
        Ci.nsILoadInfo.BLOCKING_REASON_WEBDRIVER_BIDI
      );
    }
  };

  #onFetchError = (name, data) => {
    const { request } = data;

    const browsingContext = lazy.NavigableManager.getBrowsingContextById(
      request.contextId
    );
    if (!browsingContext) {
      // Do not emit events if the context id does not match any existing
      // browsing context.
      return;
    }

    const protocolEventName = "network.fetchError";

    const isListening = this._hasListener(protocolEventName, {
      contextId: browsingContext.id,
    });
    if (!isListening) {
      // If there are no listeners subscribed to this event and this context,
      // bail out.
      return;
    }

    this.#maybeAbortNetworkResponseBodyCollection(request);

    const baseParameters = this.#processNetworkEvent(
      protocolEventName,
      request
    );

    const fetchErrorEvent = {
      ...baseParameters,
      errorText: request.errorText,
    };

    this._emitEventForBrowsingContext(
      browsingContext.id,
      protocolEventName,
      fetchErrorEvent
    );
  };

  #onResponseEvent = async (name, data) => {
    const { request, response } = data;

    const browsingContext = lazy.NavigableManager.getBrowsingContextById(
      request.contextId
    );
    if (!browsingContext) {
      // Do not emit events if the context id does not match any existing
      // browsing context.
      return;
    }

    const protocolEventName =
      name === "response-started"
        ? "network.responseStarted"
        : "network.responseCompleted";

    if (protocolEventName === "network.responseStarted") {
      this.#cloneNetworkResponseBody(request);
    }

    const isListening = this._hasListener(protocolEventName, {
      contextId: browsingContext.id,
    });
    if (!isListening) {
      // If there are no listeners subscribed to this event and this context,
      // bail out.
      return;
    }

    if (protocolEventName === "network.responseCompleted") {
      this.#maybeCollectNetworkResponseBody(request, response);
    }

    const baseParameters = this.#processNetworkEvent(
      protocolEventName,
      request
    );

    const responseData = this.#getResponseData(response);

    const responseEvent = {
      ...baseParameters,
      response: responseData,
    };

    this._emitEventForBrowsingContext(
      browsingContext.id,
      protocolEventName,
      responseEvent
    );

    if (
      protocolEventName === "network.responseStarted" &&
      responseEvent.isBlocked &&
      request.supportsInterception
    ) {
      request.wrappedChannel.suspend(
        this.#getSuspendMarkerText(request, "responseStarted")
      );

      this.#addBlockedRequest(
        responseEvent.request.request,
        InterceptPhase.ResponseStarted,
        {
          request,
          response,
        }
      );
    }
  };

  #onUserContextDeleted = (name, data) => {
    const userContextId = data.userContextId;
    if (this.#extraHeaders.userContextHeaders.has(userContextId)) {
      this.#extraHeaders.userContextHeaders.delete(userContextId);
    }
  };

  #processNetworkEvent(event, request) {
    const requestData = this.#getRequestData(request);
    const navigation = request.navigationId;
    let contextId = null;
    let topContextId = null;
    let userContextId = null;
    if (request.contextId) {
      // Retrieve the top browsing context id for this network event.
      contextId = request.contextId;
      const browsingContext =
        lazy.NavigableManager.getBrowsingContextById(contextId);
      topContextId = lazy.NavigableManager.getIdForBrowsingContext(
        browsingContext.top
      );
      userContextId =
        lazy.UserContextManager.getIdByBrowsingContext(browsingContext);
    }

    const intercepts = this.#getNetworkIntercepts(event, request, topContextId);
    const redirectCount = request.redirectCount;
    const timestamp = Date.now();
    const isBlocked = !!intercepts.length;
    const params = {
      context: contextId,
      isBlocked,
      navigation,
      redirectCount,
      request: requestData,
      timestamp,
      userContext: userContextId,
    };

    if (isBlocked) {
      params.intercepts = intercepts;
    }

    return params;
  }

  /**
   * Implements https://w3c.github.io/webdriver-bidi/#remove-collector-from-data
   *
   * @param {Data} collectedData
   *     The Data from which the collector should be removed.
   * @param {string} collectorId
   *     The collector id to remove.
   */
  #removeCollectorFromData(collectedData, collectorId) {
    if (collectedData.collectors.has(collectorId)) {
      collectedData.collectors.delete(collectorId);
      if (!collectedData.collectors.size) {
        this.#collectedNetworkData.delete(
          `${collectedData.request}-${collectedData.type}`
        );
      }
    }
  }

  #serializeCookieHeader(cookieHeader) {
    const name = cookieHeader.name;
    const value = deserializeBytesValue(cookieHeader.value);
    return `${name}=${value}`;
  }

  #serializeHeader(name, value) {
    return {
      name,
      value: serializeAsBytesValue(value),
    };
  }

  #serializeNetworkCookie(cookie) {
    const serialized = {
      domain: cookie.host,
      httpOnly: cookie.isHttpOnly,
      name: cookie.name,
      path: cookie.path,
      sameSite: NetworkCookieSameSiteType[cookie.sameSite],
      secure: cookie.isSecure,
      size: cookie.name.length + cookie.value.length,
      value: serializeAsBytesValue(cookie.value),
    };

    if (!cookie.isSession) {
      // expiry is in milliseconds, the spec expects seconds.
      serialized.expiry = Math.round(cookie.expiry / 1000);
    }

    return serialized;
  }

  #serializeSetCookieHeader(setCookieHeader) {
    const {
      name,
      value,
      domain = null,
      httpOnly = null,
      expiry = null,
      maxAge = null,
      path = null,
      sameSite = null,
      secure = null,
    } = setCookieHeader;

    let headerValue = `${name}=${deserializeBytesValue(value)}`;

    if (expiry !== null) {
      headerValue += `;Expires=${expiry}`;
    }
    if (maxAge !== null) {
      headerValue += `;Max-Age=${maxAge}`;
    }
    if (domain !== null) {
      headerValue += `;Domain=${domain}`;
    }
    if (path !== null) {
      headerValue += `;Path=${path}`;
    }
    if (secure === true) {
      headerValue += `;Secure`;
    }
    if (httpOnly === true) {
      headerValue += `;HttpOnly`;
    }
    if (sameSite !== null) {
      headerValue += `;SameSite=${sameSite}`;
    }
    return headerValue;
  }

  #startListening(event) {
    if (!this.#subscribedEvents.size) {
      this.#networkListener.startListening();
    }

    this.#subscribedEvents.add(event);
  }

  #stopListening(event) {
    this.#subscribedEvents.delete(event);

    if (this.#hasNetworkConditionsOffline || this.#hasExtraHeaders) {
      // If networkConditions or extraHeaders are set, the listener should
      // remain enabled even if no public events are emitted.
      return;
    }

    if (!this.#subscribedEvents.size) {
      this.#networkListener.stopListening();
    }
  }

  #subscribeEvent(event) {
    if (this.constructor.supportedEvents.includes(event)) {
      this.#startListening(event);
    }
  }

  #unsubscribeEvent(event) {
    if (this.constructor.supportedEvents.includes(event)) {
      this.#stopListening(event);
    }
  }

  /**
   * Update SessionData when a response data collector is added or removed.
   *
   * @param {string} collectorId
   *     The id of the collector.
   * @param {Collector} collector
   *     The collector object.
   * @param {SessionDataMethod} method
   *     Whether to add or remove the item.
   */
  async #updateCollectorSessionData(collectorId, collector, method) {
    const sessionDataItems = [];

    if (!collector.dataTypes.includes(DataType.Response)) {
      // windowglobal modules only need to know whether responses are collected
      // in order to capture cached content held in the content process.
      return;
    }

    const sessionDataItem = {
      category: lazy.SessionDataCategory.ResponseCollector,
      method,
      moduleName: "network",
      values: [collectorId],
    };

    if (collector.contexts.size) {
      for (const contextId of collector.contexts) {
        sessionDataItems.push({
          ...sessionDataItem,
          contextDescriptor: {
            type: lazy.ContextDescriptorType.TopBrowsingContext,
            id: contextId,
          },
        });
      }
    } else if (collector.userContexts.size) {
      for (const userContextId of collector.userContexts) {
        sessionDataItems.push({
          ...sessionDataItem,
          contextDescriptor: {
            type: lazy.ContextDescriptorType.UserContext,
            id: userContextId,
          },
        });
      }
    } else {
      sessionDataItems.push({
        ...sessionDataItem,
        contextDescriptor: {
          type: lazy.ContextDescriptorType.All,
        },
      });
    }

    await this.messageHandler.updateSessionData(sessionDataItems);
  }

  /**
   * Implements https://w3c.github.io/webdriver-bidi/#update-headers
   */
  #updateHeaders(request, headers) {
    for (const [name, value] of headers) {
      // Use merge: false to always override the value
      request.setRequestHeader(name, value, { merge: false });
    }
  }

  /**
   * Implements https://w3c.github.io/webdriver-bidi/#update-request-headers
   */
  #updateRequestHeaders(request, navigables) {
    for (const browsingContext of navigables) {
      this.#updateHeaders(request, this.#extraHeaders.defaultHeaders);

      const userContextHeaders = this.#extraHeaders.userContextHeaders;
      const userContext =
        lazy.UserContextManager.getIdByBrowsingContext(browsingContext);
      if (userContextHeaders.has(userContext)) {
        this.#updateHeaders(request, userContextHeaders.get(userContext));
      }

      const navigableHeaders = this.#extraHeaders.navigableHeaders;
      const topNavigableWebProgress = browsingContext.top.webProgress;
      if (navigableHeaders.has(topNavigableWebProgress)) {
        this.#updateHeaders(
          request,
          navigableHeaders.get(topNavigableWebProgress)
        );
      }
    }
  }

  /**
   * Internal commands
   */

  _applySessionData(params) {
    // TODO: Bug 1775231. Move this logic to a shared module or an abstract
    // class.
    const { category } = params;
    if (category === "event") {
      const filteredSessionData = params.sessionData.filter(item =>
        this.messageHandler.matchesContext(item.contextDescriptor)
      );
      for (const event of this.#subscribedEvents.values()) {
        const hasSessionItem = filteredSessionData.some(
          item => item.value === event
        );
        // If there are no session items for this context, we should unsubscribe from the event.
        if (!hasSessionItem) {
          this.#unsubscribeEvent(event);
        }
      }

      // Subscribe to all events, which have an item in SessionData.
      for (const { value } of filteredSessionData) {
        this.#subscribeEvent(value);
      }
    }
  }

  _sendEventsForWindowGlobalNetworkResource(params) {
    this.#onBeforeRequestSent("before-request-sent", params);
    this.#onResponseEvent("response-started", params);
    this.#onResponseEvent("response-completed", params);
  }

  _setDecodedBodySize(params) {
    const { channelId, decodedBodySize } = params;
    this.#decodedBodySizeMap.setDecodedBodySize(channelId, decodedBodySize);
  }

  _startListeningForNetworkConditionsOffline() {
    if (!this.#hasNetworkConditionsOffline) {
      this.#hasNetworkConditionsOffline = true;
      this.#networkListener.startListening();
    }
  }

  static get supportedEvents() {
    return [
      "network.authRequired",
      "network.beforeRequestSent",
      "network.fetchError",
      "network.responseCompleted",
      "network.responseStarted",
    ];
  }
}

/**
 * Deserialize a network BytesValue.
 *
 * @see https://w3c.github.io/webdriver-bidi/#deserialize-protocol-bytes
 *
 * @param {BytesValue} protocolBytes
 *     The BytesValue to deserialize.
 * @returns {string}
 *     The deserialized value.
 */
export function deserializeBytesValue(protocolBytes) {
  const { type, value } = protocolBytes;

  let bytes;
  if (type === BytesValueType.String) {
    // If protocol bytes matches the network.StringValue production
    // Encode values as UTF-8
    bytes = encodeAsUTF8(value);
  } else {
    // Otherwise if protocol bytes matches the network.Base64Value production
    // Let bytes be forgiving-base64 decode protocol bytes["value"].
    bytes = atob(value);
  }

  return bytes;
}

/**
 * Encode the provided value as UTF-8, working around argument limits in JS.
 *
 * @param {string} value
 *     The value to encode.
 * @return {string}
 *     The UTF-8 encoded string.
 */
function encodeAsUTF8(value) {
  const CHUNK_SIZE = 65536;
  let result = "";

  const encoder = new TextEncoder();
  const utf8Bytes = encoder.encode(value);
  for (let i = 0; i < utf8Bytes.length; i += CHUNK_SIZE) {
    const chunk = utf8Bytes.slice(i, i + CHUNK_SIZE);
    result += String.fromCharCode.apply(null, chunk);
  }
  return result;
}

/**
 * Serialize a value as BytesValue.
 *
 * @see https://w3c.github.io/webdriver-bidi/#serialize-protocol-bytes
 *
 * @param {string} bytes
 *     The value to serialize.
 * @return {BytesValue}
 *     The serialized value.
 */
function serializeAsBytesValue(bytes) {
  let text, type;
  try {
    type = BytesValueType.String;
    // Let text be UTF-8 decode without BOM or fail bytes.
    const decoder = new TextDecoder("utf-8", {
      fatal: true,
      ignoreBOM: true,
    });
    text = decoder.decode(Uint8Array.from(bytes, c => c.charCodeAt(0)));
  } catch (e) {
    if (e instanceof TypeError) {
      // If text is failure, return a map matching the network.Base64Value production,
      type = BytesValueType.Base64;
      // Set value to forgiving-base64 encode bytes.
      text = btoa(bytes);
    } else {
      // Errors other than TypeError are unexpected and should bubble up.
      throw e;
    }
  }

  return {
    type,
    value: text,
  };
}

export const network = NetworkModule;
