/* 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 { RootBiDiModule } from "chrome://remote/content/webdriver-bidi/modules/RootBiDiModule.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
  BeforeStopRequestListener:
    "chrome://remote/content/shared/listeners/BeforeStopRequestListener.sys.mjs",
  CacheBehavior: "chrome://remote/content/shared/NetworkCacheManager.sys.mjs",
  NetworkDecodedBodySizeMap:
    "chrome://remote/content/shared/NetworkDecodedBodySizeMap.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",
  NetworkListener:
    "chrome://remote/content/shared/listeners/NetworkListener.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",
  TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
  truncate: "chrome://remote/content/shared/Format.sys.mjs",
  updateCacheBehavior:
    "chrome://remote/content/shared/NetworkCacheManager.sys.mjs",
});

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

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

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

/**
 * Enum of possible sameSite values.
 *
 * @readonly
 * @enum {SameSite}
 */
const SameSite = {
  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",
];

class NetworkModule extends RootBiDiModule {
  #beforeStopRequestListener;
  #blockedRequests;
  #decodedBodySizeMap;
  #interceptMap;
  #networkListener;
  #redirectedRequests;
  #subscribedEvents;

  constructor(messageHandler) {
    super(messageHandler);

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

    // Map of intercept id to InterceptProperties
    this.#interceptMap = 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);

    this.#beforeStopRequestListener = new lazy.BeforeStopRequestListener();
    this.#beforeStopRequestListener.on(
      "beforeStopRequest",
      this.#onBeforeStopRequest
    );
  }

  destroy() {
    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.#beforeStopRequestListener.off(
      "beforeStopRequest",
      this.#onBeforeStopRequest
    );
    this.#beforeStopRequestListener.destroy();

    this.#decodedBodySizeMap.destroy();

    this.#decodedBodySizeMap = null;
    this.#blockedRequests = null;
    this.#interceptMap = null;
    this.#subscribedEvents = null;
  }

  /**
   * 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.array(
        contexts,
        `Expected "contexts" to be an array, got ${contexts}`
      );

      if (!options.contexts.length) {
        throw new lazy.error.InvalidArgumentError(
          `Expected "contexts" to contain at least one item, got an empty array`
        );
      }

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

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

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

    if (!options.phases.length) {
      throw new lazy.error.InvalidArgumentError(
        `Expected "phases" to contain at least one phase, got an empty array`
      );
    }

    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 [unsupported]
   *     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 [unsupported]
   *     Optional array of header values to replace the headers of the response.
   * @param {string=} options.reasonPhrase [unsupported]
   *     Optional string to replace the status message of the response.
   * @param {number=} options.statusCode [unsupported]
   *     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();
  }

  /**
   * 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 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.array(
      contextIds,
      lazy.pprint`Expected "contexts" to be an array, got ${contextIds}`
    );

    if (!contextIds.length) {
      throw new lazy.error.InvalidArgumentError(
        'Expected "contexts" to contain at least one item, got an empty array'
      );
    }

    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.#getBrowsingContext(contextId);

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

      contexts.add(context);
    }

    lazy.updateCacheBehavior(behavior, contexts);
  }

  /**
   * 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);
    });
  }

  #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}`
      );
    }
  }

  #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;
  }

  #getBrowsingContext(contextId) {
    const context = lazy.TabManager.getBrowsingContextById(contextId);
    if (context === null) {
      throw new lazy.error.NoSuchFrameError(
        `Browsing Context with id ${contextId} not found`
      );
    }

    if (!context.currentWindowGlobal) {
      throw new lazy.error.NoSuchFrameError(
        `No window found for BrowsingContext with id ${contextId}`
      );
    }

    return context;
  }

  #getNetworkIntercepts(event, request, topContextId) {
    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;
  }

  #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 = [];
    const cookies = [];

    for (const [name, value] of request.headers) {
      headers.push(this.#serializeHeader(name, value));
      if (name.toLowerCase() == "cookie") {
        // TODO: Retrieve the actual cookies from the cookie store.
        const headerCookies = value.split(";");
        for (const cookie of headerCookies) {
          const equal = cookie.indexOf("=");
          const cookieName = cookie.substr(0, equal);
          const cookieValue = cookie.substr(equal + 1);
          const serializedCookie = this.#serializeHeader(
            unescape(cookieName.trim()),
            unescape(cookieValue.trim())
          );
          cookies.push(serializedCookie);
        }
      }
    }

    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]);
  }

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

    let isBlocked = false;
    try {
      const browsingContext = lazy.TabManager.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: request.contextId,
      });
      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.TabManager.getBrowsingContextById(
      request.contextId
    );
    if (!browsingContext) {
      // Do not emit events if the context id does not match any existing
      // browsing context.
      return;
    }

    const protocolEventName = "network.beforeRequestSent";

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

    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.supportsInterception) {
      // TODO: Requests suspended in beforeRequestSent still reach the server at
      // the moment. https://bugzilla.mozilla.org/show_bug.cgi?id=1849686
      request.wrappedChannel.suspend(
        this.#getSuspendMarkerText(request, "beforeRequestSent")
      );

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

  #onBeforeStopRequest = (event, data) => {
    this.#decodedBodySizeMap.setDecodedBodySize(
      data.channel.channelId,
      data.decodedBodySize
    );
  };

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

    const browsingContext = lazy.TabManager.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: request.contextId,
    });
    if (!isListening) {
      // If there are no listeners subscribed to this event and this context,
      // bail out.
      return;
    }

    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.TabManager.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";

    const isListening = this._hasListener(protocolEventName, {
      contextId: request.contextId,
    });
    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 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,
        }
      );
    }
  };

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

    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,
    };

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

    return params;
  }

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

  #serializeHeader(name, value) {
    return {
      name,
      value: this.#serializeStringAsBytesValue(value),
    };
  }

  #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;
  }

  /**
   * Serialize a string value as BytesValue.
   *
   * Note: This does not attempt to fully implement serialize protocol bytes
   * (https://w3c.github.io/webdriver-bidi/#serialize-protocol-bytes) as the
   * header values read from the Channel are already serialized as strings at
   * the moment.
   *
   * @param {string} value
   *     The value to serialize.
   */
  #serializeStringAsBytesValue(value) {
    // TODO: For now, we handle all headers and cookies with the "string" type.
    // See Bug 1835216 to add support for "base64" type and handle non-utf8
    // values.
    return {
      type: BytesValueType.String,
      value,
    };
  }

  #startListening(event) {
    if (this.#subscribedEvents.size == 0) {
      this.#networkListener.startListening();
      this.#beforeStopRequestListener.startListening();
    }
    this.#subscribedEvents.add(event);
  }

  #stopListening(event) {
    this.#subscribedEvents.delete(event);
    if (this.#subscribedEvents.size == 0) {
      this.#networkListener.stopListening();
      this.#beforeStopRequestListener.stopListening();
    }
  }

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

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

  /**
   * 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);
      }
    }
  }

  _sendEventsForCachedResource(params) {
    this.#onBeforeRequestSent("beforerequest-sent", params);
    this.#onResponseEvent("response-started", params);
    this.#onResponseEvent("response-completed", params);
  }

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

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

/**
 * Deserialize a network BytesValue.
 *
 * @param {BytesValue} bytesValue
 *     The BytesValue to deserialize.
 * @returns {string}
 *     The deserialized value.
 */
export function deserializeBytesValue(bytesValue) {
  const { type, value } = bytesValue;

  if (type === BytesValueType.String) {
    return value;
  }

  // For type === BytesValueType.Base64.
  return atob(value);
}

export const network = NetworkModule;
