// Copyright 2011 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type * as ProtocolProxyApi from '../../generated/protocol-proxy-api.js';
import * as Protocol from '../../generated/protocol.js';
import * as TextUtils from '../../models/text_utils/text_utils.js';
import * as Common from '../common/common.js';
import * as i18n from '../i18n/i18n.js';
import * as Platform from '../platform/platform.js';
import * as Root from '../root/root.js';

import {Cookie} from './Cookie.js';
import {
  type BlockedCookieWithReason,
  DirectSocketChunkType,
  DirectSocketStatus,
  DirectSocketType,
  Events as NetworkRequestEvents,
  type ExtraRequestInfo,
  type ExtraResponseInfo,
  type IncludedCookieWithReason,
  type NameValue,
  NetworkRequest,
} from './NetworkRequest.js';
import {SDKModel} from './SDKModel.js';
import {Capability, type Target} from './Target.js';
import {type SDKModelObserver, TargetManager} from './TargetManager.js';

const UIStrings = {
  /**
   * @description Explanation why no content is shown for WebSocket connection.
   */
  noContentForWebSocket: 'Content for WebSockets is currently not supported',
  /**
   * @description Explanation why no content is shown for Server-Sent Events (SSE).
   */
  noContentForSSE: 'Content for Server-Sent Events (SSE) is currently not supported',
  /**
   * @description Explanation why no content is shown for redirect response.
   */
  noContentForRedirect: 'No content available because this request was redirected',
  /**
   * @description Explanation why no content is shown for preflight request.
   */
  noContentForPreflight: 'No content available for preflight request',
  /**
   * @description Text to indicate that network throttling is disabled
   */
  noThrottling: 'No throttling',
  /**
   * @description Text to indicate the network connectivity is offline
   */
  offline: 'Offline',
  /**
   * @description Text in Network Manager representing the "3G" throttling preset.
   */
  slowG: '3G',  // Named `slowG` for legacy reasons and because this value
                // is serialized locally on the user's machine: if we
                // change it we break their stored throttling settings.
                // (See crrev.com/c/2947255)
  /**
   * @description Text in Network Manager representing the "Slow 4G" throttling preset
   */
  fastG: 'Slow 4G',  // Named `fastG` for legacy reasons and because this value
                     // is serialized locally on the user's machine: if we
                     // change it we break their stored throttling settings.
                     // (See crrev.com/c/2947255)
  /**
   * @description Text in Network Manager representing the "Fast 4G" throttling preset
   */
  fast4G: 'Fast 4G',
  /**
   * @description Text in Network Manager representing the "Blocking" throttling preset
   */
  block: 'Block',
  /**
   * @description Text in Network Manager
   * @example {https://example.com} PH1
   */
  requestWasBlockedByDevtoolsS: 'Request was blocked by DevTools: "{PH1}"',
  /**
   * @description Message in Network Manager
   * @example {XHR} PH1
   * @example {GET} PH2
   * @example {https://example.com} PH3
   */
  sFailedLoadingSS: '{PH1} failed loading: {PH2} "{PH3}".',
  /**
   * @description Message in Network Manager
   * @example {XHR} PH1
   * @example {GET} PH2
   * @example {https://example.com} PH3
   */
  sFinishedLoadingSS: '{PH1} finished loading: {PH2} "{PH3}".',
  /**
   * @description One of direct socket connection statuses
   */
  directSocketStatusOpening: 'Opening',
  /**
   * @description One of direct socket connection statuses
   */
  directSocketStatusOpen: 'Open',
  /**
   * @description One of direct socket connection statuses
   */
  directSocketStatusClosed: 'Closed',
  /**
   * @description One of direct socket connection statuses
   */
  directSocketStatusAborted: 'Aborted',
} as const;
const str_ = i18n.i18n.registerUIStrings('core/sdk/NetworkManager.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const i18nLazyString = i18n.i18n.getLazilyComputedLocalizedString.bind(undefined, str_);

const requestToManagerMap = new WeakMap<NetworkRequest, NetworkManager>();

const CONNECTION_TYPES = new Map([
  ['2g', Protocol.Network.ConnectionType.Cellular2g],
  ['3g', Protocol.Network.ConnectionType.Cellular3g],
  ['4g', Protocol.Network.ConnectionType.Cellular4g],
  ['bluetooth', Protocol.Network.ConnectionType.Bluetooth],
  ['wifi', Protocol.Network.ConnectionType.Wifi],
  ['wimax', Protocol.Network.ConnectionType.Wimax],
]);

/**
 * We store two settings to disk to persist network throttling.
 * 1. The custom conditions that the user has defined.
 * 2. The active `key` that applies the correct current preset.
 * The reason the setting creation functions are defined here is because they are referred
 * to in multiple places, and this ensures we don't have accidental typos which
 * mean extra settings get mistakenly created.
 */
export function customUserNetworkConditionsSetting(
    settings: Common.Settings.Settings = Common.Settings.Settings.instance()): Common.Settings.Setting<Conditions[]> {
  return settings.moduleSetting<Conditions[]>('custom-network-conditions');
}

export function activeNetworkThrottlingKeySetting(
    settings: Common.Settings.Settings =
        Common.Settings.Settings.instance()): Common.Settings.Setting<ThrottlingConditionKey> {
  return settings.createSetting('active-network-condition-key', PredefinedThrottlingConditionKey.NO_THROTTLING);
}

export class NetworkManager extends SDKModel<EventTypes> {
  readonly dispatcher: NetworkDispatcher;
  readonly fetchDispatcher: FetchDispatcher;
  readonly #networkAgent: ProtocolProxyApi.NetworkApi;
  readonly #bypassServiceWorkerSetting: Common.Settings.Setting<boolean>;

  readonly activeNetworkThrottlingKey: Common.Settings.Setting<ThrottlingConditionKey>;

  constructor(target: Target) {
    super(target);
    this.dispatcher = new NetworkDispatcher(this);
    this.fetchDispatcher = new FetchDispatcher(target.fetchAgent(), this);
    this.#networkAgent = target.networkAgent();
    target.registerNetworkDispatcher(this.dispatcher);
    target.registerFetchDispatcher(this.fetchDispatcher);

    const settings = this.target().targetManager().settings;
    this.activeNetworkThrottlingKey = activeNetworkThrottlingKeySetting(settings);

    if (settings.moduleSetting('cache-disabled').get()) {
      void this.#networkAgent.invoke_setCacheDisabled({cacheDisabled: true});
    }

    void this.#networkAgent.invoke_enable({
      maxPostDataSize: MAX_EAGER_POST_REQUEST_BODY_LENGTH,
      enableDurableMessages: Root.Runtime.hostConfig.devToolsEnableDurableMessages?.enabled,
      maxTotalBufferSize: MAX_RESPONSE_BODY_TOTAL_BUFFER_LENGTH,
      reportDirectSocketTraffic: true,
    });
    void this.#networkAgent.invoke_setAttachDebugStack({enabled: true});

    this.#bypassServiceWorkerSetting = settings.createSetting('bypass-service-worker', false);
    if (this.#bypassServiceWorkerSetting.get()) {
      this.bypassServiceWorkerChanged();
    }
    this.#bypassServiceWorkerSetting.addChangeListener(this.bypassServiceWorkerChanged, this);

    settings.moduleSetting('cache-disabled').addChangeListener(this.cacheDisabledSettingChanged, this);
  }

  static forRequest(request: NetworkRequest): NetworkManager|null {
    return requestToManagerMap.get(request) || null;
  }

  static canReplayRequest(request: NetworkRequest): boolean {
    return Boolean(requestToManagerMap.get(request)) && Boolean(request.backendRequestId()) && !request.isRedirect() &&
        request.resourceType() === Common.ResourceType.resourceTypes.XHR;
  }

  static replayRequest(request: NetworkRequest): void {
    const manager = requestToManagerMap.get(request);
    const requestId = request.backendRequestId();
    if (!manager || !requestId || request.isRedirect()) {
      return;
    }
    void manager.#networkAgent.invoke_replayXHR({requestId});
  }

  static async searchInRequest(request: NetworkRequest, query: string, caseSensitive: boolean, isRegex: boolean):
      Promise<TextUtils.ContentProvider.SearchMatch[]> {
    const manager = NetworkManager.forRequest(request);
    const requestId = request.backendRequestId();
    if (!manager || !requestId || request.isRedirect()) {
      return [];
    }
    const response =
        await manager.#networkAgent.invoke_searchInResponseBody({requestId, query, caseSensitive, isRegex});
    return TextUtils.TextUtils.performSearchInSearchMatches(response.result || [], query, caseSensitive, isRegex);
  }

  static async requestContentData(request: NetworkRequest): Promise<TextUtils.ContentData.ContentDataOrError> {
    if (request.resourceType() === Common.ResourceType.resourceTypes.WebSocket) {
      return {error: i18nString(UIStrings.noContentForWebSocket)};
    }
    if (!request.finished) {
      if (Boolean(request.eventSourceMessages()?.length)) {
        return {error: i18nString(UIStrings.noContentForSSE)};
      }
      await request.once(NetworkRequestEvents.FINISHED_LOADING);
    }
    if (request.isRedirect()) {
      return {error: i18nString(UIStrings.noContentForRedirect)};
    }
    if (request.isPreflightRequest()) {
      return {error: i18nString(UIStrings.noContentForPreflight)};
    }
    const manager = NetworkManager.forRequest(request);
    if (!manager) {
      return {error: 'No network manager for request'};
    }
    const requestId = request.backendRequestId();
    if (!requestId) {
      return {error: 'No backend request id for request'};
    }
    const response = await manager.#networkAgent.invoke_getResponseBody({requestId});
    const error = response.getError();
    if (error) {
      return {error};
    }
    return new TextUtils.ContentData.ContentData(
        response.body, response.base64Encoded, request.mimeType, request.charset() ?? undefined);
  }

  /**
   * Returns the already received bytes for an in-flight request. After calling this method
   * "dataReceived" events will contain additional data.
   */
  static async streamResponseBody(request: NetworkRequest): Promise<TextUtils.ContentData.ContentDataOrError> {
    if (request.finished) {
      return {error: 'Streaming the response body is only available for in-flight requests.'};
    }
    const manager = NetworkManager.forRequest(request);
    if (!manager) {
      return {error: 'No network manager for request'};
    }
    const requestId = request.backendRequestId();
    if (!requestId) {
      return {error: 'No backend request id for request'};
    }
    const response = await manager.#networkAgent.invoke_streamResourceContent({requestId});
    const error = response.getError();
    if (error) {
      return {error};
    }
    // Wait for at least the `responseReceived event so we have accurate mimetype and charset.
    await request.waitForResponseReceived();
    return new TextUtils.ContentData.ContentData(
        response.bufferedData, /* isBase64=*/ true, request.mimeType, request.charset() ?? undefined);
  }

  static async requestPostData(request: NetworkRequest): Promise<string|null> {
    const manager = NetworkManager.forRequest(request);
    if (!manager) {
      console.error('No network manager for request');
      return null;
    }
    const requestId = request.backendRequestId();
    if (!requestId) {
      console.error('No backend request id for request');
      return null;
    }
    try {
      const {postData, base64Encoded} = await manager.#networkAgent.invoke_getRequestPostData({requestId});
      if (base64Encoded && postData) {
        // Decode base64 to get raw bytes as an ArrayBuffer.
        const binaryString = window.atob(postData);
        const bytes = new Uint8Array(binaryString.length);
        for (let i = 0; i < binaryString.length; i++) {
          bytes[i] = binaryString.charCodeAt(i);
        }

        // Extract charset from request Content-Type header, defaulting to utf-8.
        const requestContentType = request.requestContentType();
        const charset =
            requestContentType ? Platform.MimeType.parseContentType(requestContentType).charset ?? 'utf-8' : 'utf-8';

        // If the request body is compressed, attempt to decompress it.
        const contentEncoding = request.requestContentEncoding()?.toLowerCase();
        if (contentEncoding) {
          const decompressed = await NetworkManager.#tryDecompressBody(bytes.buffer, contentEncoding, charset);
          if (decompressed !== null) {
            return decompressed;
          }
        }

        // Not compressed or decompression not applicable -- decode as text.
        return new TextDecoder(charset).decode(bytes);
      }
      return postData;
    } catch (e) {
      return e.message;
    }
  }

  /**
   * Attempts to decompress a compressed request body.
   * Returns the decompressed string, or null if decompression is not applicable.
   */
  static async #tryDecompressBody(buffer: ArrayBuffer, encoding: string, charset: string): Promise<string|null> {
    try {
      if (encoding.includes('gzip') && Common.Gzip.isGzip(buffer)) {
        return await Common.Gzip.decompress(buffer, charset);
      }
      if (encoding.includes('deflate')) {
        return await Common.Gzip.decompressDeflate(buffer, charset);
      }
    } catch (e) {
      console.warn('Failed to decompress request body:', e);
    }
    return null;
  }

  static connectionType(conditions: Conditions): Protocol.Network.ConnectionType {
    if (!conditions.download && !conditions.upload) {
      return Protocol.Network.ConnectionType.None;
    }
    try {
      const title =
          typeof conditions.title === 'function' ? conditions.title().toLowerCase() : conditions.title.toLowerCase();
      for (const [name, protocolType] of CONNECTION_TYPES) {
        if (title.includes(name)) {
          return protocolType;
        }
      }
    } catch {
      // If the i18nKey for this condition has changed, calling conditions.title() will break, so in that case we reset to NONE
      return Protocol.Network.ConnectionType.None;
    }

    return Protocol.Network.ConnectionType.Other;
  }

  static lowercaseHeaders(headers: Protocol.Network.Headers): Protocol.Network.Headers {
    const newHeaders: Protocol.Network.Headers = {};
    for (const headerName in headers) {
      newHeaders[headerName.toLowerCase()] = headers[headerName];
    }
    return newHeaders;
  }

  requestForURL(url: Platform.DevToolsPath.UrlString): NetworkRequest|null {
    return this.dispatcher.requestForURL(url);
  }

  requestForId(id: string): NetworkRequest|null {
    return this.dispatcher.requestForId(id);
  }

  requestForLoaderId(loaderId: Protocol.Network.LoaderId): NetworkRequest|null {
    return this.dispatcher.requestForLoaderId(loaderId);
  }

  private cacheDisabledSettingChanged({data: enabled}: Common.EventTarget.EventTargetEvent<boolean>): void {
    void this.#networkAgent.invoke_setCacheDisabled({cacheDisabled: enabled});
  }

  override dispose(): void {
    const settings = this.target().targetManager().settings;
    settings.moduleSetting('cache-disabled').removeChangeListener(this.cacheDisabledSettingChanged, this);
  }

  private bypassServiceWorkerChanged(): void {
    void this.#networkAgent.invoke_setBypassServiceWorker({bypass: this.#bypassServiceWorkerSetting.get()});
  }

  async getSecurityIsolationStatus(frameId: Protocol.Page.FrameId|null):
      Promise<Protocol.Network.SecurityIsolationStatus|null> {
    const result = await this.#networkAgent.invoke_getSecurityIsolationStatus({frameId: frameId ?? undefined});
    if (result.getError()) {
      return null;
    }
    return result.status;
  }

  async enableReportingApi(enable = true): Promise<Promise<Protocol.ProtocolResponseWithError>> {
    return await this.#networkAgent.invoke_enableReportingApi({enable});
  }

  async enableDeviceBoundSessions(enable = true): Promise<Promise<Protocol.ProtocolResponseWithError>> {
    return await this.#networkAgent.invoke_enableDeviceBoundSessions({enable});
  }

  async loadNetworkResource(
      frameId: Protocol.Page.FrameId|null, url: Platform.DevToolsPath.UrlString,
      options: Protocol.Network.LoadNetworkResourceOptions): Promise<Protocol.Network.LoadNetworkResourcePageResult> {
    const result = await this.#networkAgent.invoke_loadNetworkResource({frameId: frameId ?? undefined, url, options});
    if (result.getError()) {
      throw new Error(result.getError());
    }
    return result.resource;
  }

  clearRequests(): void {
    this.dispatcher.clearRequests();
  }
}

export enum Events {
  /* eslint-disable @typescript-eslint/naming-convention -- Used by web_tests. */
  RequestStarted = 'RequestStarted',
  RequestUpdated = 'RequestUpdated',
  RequestFinished = 'RequestFinished',
  RequestUpdateDropped = 'RequestUpdateDropped',
  ResponseReceived = 'ResponseReceived',
  MessageGenerated = 'MessageGenerated',
  RequestRedirected = 'RequestRedirected',
  LoadingFinished = 'LoadingFinished',
  ReportingApiReportAdded = 'ReportingApiReportAdded',
  ReportingApiReportUpdated = 'ReportingApiReportUpdated',
  ReportingApiEndpointsChangedForOrigin = 'ReportingApiEndpointsChangedForOrigin',
  DeviceBoundSessionsAdded = 'DeviceBoundSessionsAdded',
  DeviceBoundSessionEventOccurred = 'DeviceBoundSessionEventOccurred',
  /* eslint-enable @typescript-eslint/naming-convention */
}

export interface RequestStartedEvent {
  request: NetworkRequest;
  originalRequest: Protocol.Network.Request|null;
}

export interface ResponseReceivedEvent {
  request: NetworkRequest;
  response: Protocol.Network.Response;
}

export interface MessageGeneratedEvent {
  message: Common.UIString.LocalizedString;
  requestId: string;
  warning: boolean;
}

export interface EventTypes {
  [Events.RequestStarted]: RequestStartedEvent;
  [Events.RequestUpdated]: NetworkRequest;
  [Events.RequestFinished]: NetworkRequest;
  [Events.RequestUpdateDropped]: RequestUpdateDroppedEventData;
  [Events.ResponseReceived]: ResponseReceivedEvent;
  [Events.MessageGenerated]: MessageGeneratedEvent;
  [Events.RequestRedirected]: NetworkRequest;
  [Events.LoadingFinished]: NetworkRequest;
  [Events.ReportingApiReportAdded]: Protocol.Network.ReportingApiReport;
  [Events.ReportingApiReportUpdated]: Protocol.Network.ReportingApiReport;
  [Events.ReportingApiEndpointsChangedForOrigin]: Protocol.Network.ReportingApiEndpointsChangedForOriginEvent;
  [Events.DeviceBoundSessionsAdded]: Protocol.Network.DeviceBoundSession[];
  [Events.DeviceBoundSessionEventOccurred]: Protocol.Network.DeviceBoundSessionEventOccurredEvent;
}

/**
 * Define some built-in DevTools throttling presets.
 * Note that for the download, upload and RTT values we multiply them by adjustment factors to make DevTools' emulation more accurate.
 * @see https://docs.google.com/document/d/10lfVdS1iDWCRKQXPfbxEn4Or99D64mvNlugP1AQuFlE/edit for historical context.
 * @see https://crbug.com/342406608#comment10 for context around the addition of 4G presets in June 2024.
 */

export const BlockingConditions: ThrottlingConditions = {
  key: PredefinedThrottlingConditionKey.BLOCKING,
  block: true,
  title: i18nLazyString(UIStrings.block),
};

export const NoThrottlingConditions: Conditions = {
  key: PredefinedThrottlingConditionKey.NO_THROTTLING,
  title: i18nLazyString(UIStrings.noThrottling),
  i18nTitleKey: UIStrings.noThrottling,
  download: -1,
  upload: -1,
  latency: 0,
};

export const OfflineConditions: Conditions = {
  key: PredefinedThrottlingConditionKey.OFFLINE,
  title: i18nLazyString(UIStrings.offline),
  i18nTitleKey: UIStrings.offline,
  download: 0,
  upload: 0,
  latency: 0,
};

const slow3GTargetLatency = 400;
export const Slow3GConditions: Conditions = {
  key: PredefinedThrottlingConditionKey.SPEED_3G,
  title: i18nLazyString(UIStrings.slowG),
  i18nTitleKey: UIStrings.slowG,
  // ~500Kbps down
  download: 500 * 1000 / 8 * .8,
  // ~500Kbps up
  upload: 500 * 1000 / 8 * .8,
  // 400ms RTT
  latency: slow3GTargetLatency * 5,
  targetLatency: slow3GTargetLatency,
};

// Note for readers: this used to be called "Fast 3G" but it was renamed in May
// 2024 to align with LH (crbug.com/342406608).
const slow4GTargetLatency = 150;
export const Slow4GConditions: Conditions = {
  key: PredefinedThrottlingConditionKey.SPEED_SLOW_4G,
  title: i18nLazyString(UIStrings.fastG),
  i18nTitleKey: UIStrings.fastG,
  // ~1.6 Mbps down
  download: 1.6 * 1000 * 1000 / 8 * .9,
  // ~0.75 Mbps up
  upload: 750 * 1000 / 8 * .9,
  // 150ms RTT
  latency: slow4GTargetLatency * 3.75,
  targetLatency: slow4GTargetLatency,
};

const fast4GTargetLatency = 60;
export const Fast4GConditions: Conditions = {
  key: PredefinedThrottlingConditionKey.SPEED_FAST_4G,
  title: i18nLazyString(UIStrings.fast4G),
  i18nTitleKey: UIStrings.fast4G,
  // 9 Mbps down
  download: 9 * 1000 * 1000 / 8 * .9,
  // 1.5 Mbps up
  upload: 1.5 * 1000 * 1000 / 8 * .9,
  // 60ms RTT
  latency: fast4GTargetLatency * 2.75,
  targetLatency: fast4GTargetLatency,
};

const MAX_EAGER_POST_REQUEST_BODY_LENGTH = 64 * 1024;             // bytes
const MAX_RESPONSE_BODY_TOTAL_BUFFER_LENGTH = 250 * 1024 * 1024;  // bytes

export class FetchDispatcher implements ProtocolProxyApi.FetchDispatcher {
  readonly #fetchAgent: ProtocolProxyApi.FetchApi;
  readonly #manager: NetworkManager;

  constructor(agent: ProtocolProxyApi.FetchApi, manager: NetworkManager) {
    this.#fetchAgent = agent;
    this.#manager = manager;
  }

  requestPaused({requestId, request, resourceType, responseStatusCode, responseHeaders, networkId}:
                    Protocol.Fetch.RequestPausedEvent): void {
    const networkRequest = networkId ? this.#manager.requestForId(networkId) : null;
    // If there was no 'Network.responseReceivedExtraInfo' event (e.g. for 'file:/' URLSs),
    // populate 'originalResponseHeaders' with the headers from the 'Fetch.requestPaused' event.
    if (networkRequest?.originalResponseHeaders.length === 0 && responseHeaders) {
      networkRequest.originalResponseHeaders = responseHeaders;
    }
    void MultitargetNetworkManager.instance().requestIntercepted(new InterceptedRequest(
        this.#fetchAgent, request, resourceType, requestId, networkRequest, responseStatusCode, responseHeaders));
  }

  authRequired({}: Protocol.Fetch.AuthRequiredEvent): void {
  }
}

export class NetworkDispatcher implements ProtocolProxyApi.NetworkDispatcher {
  readonly #manager: NetworkManager;
  readonly #requestsById = new Map<string, NetworkRequest>();
  readonly #requestsByURL = new Map<Platform.DevToolsPath.UrlString, NetworkRequest>();
  readonly #requestsByLoaderId = new Map<Protocol.Network.LoaderId, NetworkRequest>();
  readonly #requestIdToExtraInfoBuilder = new Map<string, ExtraInfoBuilder>();
  /**
   * In case of an early abort or a cache hit, the Trust Token done event is
   * reported before the request itself is created in `requestWillBeSent`.
   * This causes the event to be lost as no `NetworkRequest` instance has been
   * created yet.
   * This map caches the events temporarily and populates the NetworkRequest
   * once it is created in `requestWillBeSent`.
   */
  readonly #requestIdToTrustTokenEvent = new Map<string, Protocol.Network.TrustTokenOperationDoneEvent>();

  constructor(manager: NetworkManager) {
    this.#manager = manager;

    MultitargetNetworkManager.instance().addEventListener(
        MultitargetNetworkManager.Events.REQUEST_INTERCEPTED, this.#markAsIntercepted.bind(this));
  }

  #markAsIntercepted(event: Common.EventTarget.EventTargetEvent<string>): void {
    const request = this.requestForId(event.data);
    if (request) {
      request.setWasIntercepted(true);
    }
  }

  private headersMapToHeadersArray(headersMap: Protocol.Network.Headers): NameValue[] {
    const result = [];
    for (const name in headersMap) {
      const values = headersMap[name].split('\n');
      for (let i = 0; i < values.length; ++i) {
        result.push({name, value: values[i]});
      }
    }
    return result;
  }

  private updateNetworkRequestWithRequest(networkRequest: NetworkRequest, request: Protocol.Network.Request): void {
    networkRequest.requestMethod = request.method;
    networkRequest.setRequestHeaders(this.headersMapToHeadersArray(request.headers));
    // If the request body is compressed, discard the inline postData which is
    // garbled (binary-as-text). The getRequestPostData command will provide
    // properly base64-encoded data that we can decompress.
    const isCompressed = Boolean(networkRequest.requestContentEncoding());
    networkRequest.setRequestFormData(Boolean(request.hasPostData), isCompressed ? null : (request.postData || null));
    networkRequest.setInitialPriority(request.initialPriority);
    networkRequest.mixedContentType = request.mixedContentType || Protocol.Security.MixedContentType.None;
    networkRequest.setReferrerPolicy(request.referrerPolicy);
    networkRequest.setIsSameSite(request.isSameSite || false);
    networkRequest.setIsAdRelated(request.isAdRelated || false);
  }

  private updateNetworkRequestWithResponse(networkRequest: NetworkRequest, response: Protocol.Network.Response): void {
    if (response.url && networkRequest.url() !== response.url) {
      networkRequest.setUrl(response.url as Platform.DevToolsPath.UrlString);
    }
    networkRequest.mimeType = response.mimeType;
    networkRequest.setCharset(response.charset);
    if (!networkRequest.statusCode || networkRequest.wasIntercepted()) {
      networkRequest.statusCode = response.status;
    }
    if (!networkRequest.statusText || networkRequest.wasIntercepted()) {
      networkRequest.statusText = response.statusText;
    }
    if (!networkRequest.hasExtraResponseInfo() || networkRequest.wasIntercepted()) {
      networkRequest.responseHeaders = this.headersMapToHeadersArray(response.headers);
    }

    if (response.encodedDataLength >= 0) {
      networkRequest.setTransferSize(response.encodedDataLength);
    }

    if (response.requestHeaders && !networkRequest.hasExtraRequestInfo()) {
      // TODO(http://crbug.com/1004979): Stop using response.requestHeaders and
      //   response.requestHeadersText once shared workers
      //   emit Network.*ExtraInfo events for their network #requests.
      networkRequest.setRequestHeaders(this.headersMapToHeadersArray(response.requestHeaders));
      networkRequest.setRequestHeadersText(response.requestHeadersText || '');
    }

    networkRequest.connectionReused = response.connectionReused;
    networkRequest.connectionId = String(response.connectionId);
    if (response.remoteIPAddress) {
      networkRequest.setRemoteAddress(response.remoteIPAddress, response.remotePort || -1);
    }

    if (response.fromServiceWorker) {
      networkRequest.fetchedViaServiceWorker = true;
    }

    if (response.fromDiskCache) {
      networkRequest.setFromDiskCache();
    }

    if (response.fromPrefetchCache) {
      networkRequest.setFromPrefetchCache();
    }

    if (response.fromEarlyHints) {
      networkRequest.setFromEarlyHints();
    }

    if (response.cacheStorageCacheName) {
      networkRequest.setResponseCacheStorageCacheName(response.cacheStorageCacheName);
    }

    if (response.serviceWorkerRouterInfo) {
      networkRequest.serviceWorkerRouterInfo = response.serviceWorkerRouterInfo;
    }

    if (response.responseTime) {
      networkRequest.setResponseRetrievalTime(new Date(response.responseTime));
    }

    networkRequest.timing = response.timing;

    networkRequest.protocol = response.protocol || '';

    networkRequest.alternateProtocolUsage = response.alternateProtocolUsage;

    if (response.serviceWorkerResponseSource) {
      networkRequest.setServiceWorkerResponseSource(response.serviceWorkerResponseSource);
    }

    networkRequest.setSecurityState(response.securityState);

    if (response.securityDetails) {
      networkRequest.setSecurityDetails(response.securityDetails);
    }

    const newResourceType = Common.ResourceType.ResourceType.fromMimeTypeOverride(networkRequest.mimeType);
    if (newResourceType) {
      networkRequest.setResourceType(newResourceType);
    }
    if (networkRequest.responseReceivedPromiseResolve) {
      // Anyone interested in waiting for response headers being available?
      networkRequest.responseReceivedPromiseResolve();
    } else {
      // If not, make sure no one will wait on it in the future.
      networkRequest.responseReceivedPromise = Promise.resolve();
    }
  }

  requestForId(id: string): NetworkRequest|null {
    return this.#requestsById.get(id) || null;
  }

  requestForURL(url: Platform.DevToolsPath.UrlString): NetworkRequest|null {
    return this.#requestsByURL.get(url) || null;
  }

  requestForLoaderId(loaderId: Protocol.Network.LoaderId): NetworkRequest|null {
    return this.#requestsByLoaderId.get(loaderId) || null;
  }

  resourceChangedPriority({requestId, newPriority}: Protocol.Network.ResourceChangedPriorityEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (networkRequest) {
      networkRequest.setPriority(newPriority);
    }
  }

  signedExchangeReceived({requestId, info}: Protocol.Network.SignedExchangeReceivedEvent): void {
    // While loading a signed exchange, a signedExchangeReceived event is sent
    // between two requestWillBeSent events.
    // 1. The first requestWillBeSent is sent while starting the navigation (or
    //    prefetching).
    // 2. This signedExchangeReceived event is sent when the browser detects the
    //    signed exchange.
    // 3. The second requestWillBeSent is sent with the generated redirect
    //    response and a new redirected request which URL is the inner request
    //    URL of the signed exchange.
    let networkRequest = this.#requestsById.get(requestId);
    // |requestId| is available only for navigation #requests. If the request was
    // sent from a renderer process for prefetching, it is not available. In the
    // case, need to fallback to look for the URL.
    // TODO(crbug/841076): Sends the request ID of prefetching to the browser
    // process and DevTools to find the matching request.
    if (!networkRequest) {
      networkRequest = this.#requestsByURL.get(info.outerResponse.url as Platform.DevToolsPath.UrlString);
      if (!networkRequest) {
        return;
      }
      // Or clause is never hit, but is here because we can't use non-null assertions.
      const backendRequestId = networkRequest.backendRequestId() || requestId;
      requestId = backendRequestId;
    }
    networkRequest.setSignedExchangeInfo(info);
    networkRequest.setResourceType(Common.ResourceType.resourceTypes.SignedExchange);

    this.updateNetworkRequestWithResponse(networkRequest, info.outerResponse);
    this.updateNetworkRequest(networkRequest);
    this.getExtraInfoBuilder(requestId).addHasExtraInfo(info.hasExtraInfo);

    this.#manager.dispatchEventToListeners(
        Events.ResponseReceived, {request: networkRequest, response: info.outerResponse});
  }

  requestWillBeSent({
    requestId,
    loaderId,
    documentURL,
    request,
    timestamp,
    wallTime,
    initiator,
    redirectHasExtraInfo,
    redirectResponse,
    type,
    frameId,
    hasUserGesture,
    renderBlockingBehavior,
  }: Protocol.Network.RequestWillBeSentEvent): void {
    let networkRequest = this.#requestsById.get(requestId);
    if (networkRequest) {
      // FIXME: move this check to the backend.
      if (!redirectResponse) {
        return;
      }
      // If signedExchangeReceived event has already been sent for the request,
      // ignores the internally generated |redirectResponse|. The
      // |outerResponse| of SignedExchangeInfo was set to |networkRequest| in
      // signedExchangeReceived().
      if (!networkRequest.signedExchangeInfo()) {
        this.responseReceived({
          requestId,
          loaderId,
          timestamp,
          type: type || Protocol.Network.ResourceType.Other,
          response: redirectResponse,
          hasExtraInfo: redirectHasExtraInfo,
          frameId,
        });
      }
      networkRequest = this.appendRedirect(requestId, timestamp, request.url as Platform.DevToolsPath.UrlString);
      this.#manager.dispatchEventToListeners(Events.RequestRedirected, networkRequest);
    } else {
      networkRequest = NetworkRequest.create(
          requestId, request.url as Platform.DevToolsPath.UrlString, documentURL as Platform.DevToolsPath.UrlString,
          frameId ?? null, loaderId, initiator, hasUserGesture);
      if (renderBlockingBehavior) {
        networkRequest.setRenderBlockingBehavior(renderBlockingBehavior);
      }
      requestToManagerMap.set(networkRequest, this.#manager);
    }
    networkRequest.hasNetworkData = true;
    this.updateNetworkRequestWithRequest(networkRequest, request);
    networkRequest.setIssueTime(timestamp, wallTime);
    networkRequest.setResourceType(
        type ? Common.ResourceType.resourceTypes[type] : Common.ResourceType.resourceTypes.Other);
    if (request.trustTokenParams) {
      networkRequest.setTrustTokenParams(request.trustTokenParams);
    }
    const maybeTrustTokenEvent = this.#requestIdToTrustTokenEvent.get(requestId);
    if (maybeTrustTokenEvent) {
      networkRequest.setTrustTokenOperationDoneEvent(maybeTrustTokenEvent);
      this.#requestIdToTrustTokenEvent.delete(requestId);
    }

    this.getExtraInfoBuilder(requestId).addRequest(networkRequest);

    this.startNetworkRequest(networkRequest, request);
  }

  requestServedFromCache({requestId}: Protocol.Network.RequestServedFromCacheEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }

    networkRequest.setFromMemoryCache();
  }

  responseReceived({requestId, loaderId, timestamp, type, response, hasExtraInfo, frameId}:
                       Protocol.Network.ResponseReceivedEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    const lowercaseHeaders = NetworkManager.lowercaseHeaders(response.headers);
    if (!networkRequest) {
      const lastModifiedHeader = lowercaseHeaders['last-modified'];
      // We missed the requestWillBeSent.
      const eventData: RequestUpdateDroppedEventData = {
        url: response.url as Platform.DevToolsPath.UrlString,
        frameId: frameId ?? null,
        loaderId,
        resourceType: type,
        mimeType: response.mimeType,
        lastModified: lastModifiedHeader ? new Date(lastModifiedHeader) : null,
      };
      this.#manager.dispatchEventToListeners(Events.RequestUpdateDropped, eventData);
      return;
    }

    networkRequest.responseReceivedTime = timestamp;
    networkRequest.setResourceType(Common.ResourceType.resourceTypes[type]);

    this.updateNetworkRequestWithResponse(networkRequest, response);

    this.updateNetworkRequest(networkRequest);
    this.getExtraInfoBuilder(requestId).addHasExtraInfo(hasExtraInfo);
    this.#manager.dispatchEventToListeners(Events.ResponseReceived, {request: networkRequest, response});
  }

  dataReceived(event: Protocol.Network.DataReceivedEvent): void {
    let networkRequest: NetworkRequest|null|undefined = this.#requestsById.get(event.requestId);
    if (!networkRequest) {
      networkRequest = this.maybeAdoptMainResourceRequest(event.requestId);
    }
    if (!networkRequest) {
      return;
    }
    networkRequest.addDataReceivedEvent(event);
    this.updateNetworkRequest(networkRequest);
  }

  loadingFinished({requestId, timestamp: finishTime, encodedDataLength}: Protocol.Network.LoadingFinishedEvent): void {
    let networkRequest: NetworkRequest|null|undefined = this.#requestsById.get(requestId);
    if (!networkRequest) {
      networkRequest = this.maybeAdoptMainResourceRequest(requestId);
    }
    if (!networkRequest) {
      return;
    }
    this.getExtraInfoBuilder(requestId).finished();
    this.finishNetworkRequest(networkRequest, finishTime, encodedDataLength);
    this.#manager.dispatchEventToListeners(Events.LoadingFinished, networkRequest);
  }

  loadingFailed({
    requestId,
    timestamp: time,
    type: resourceType,
    errorText: localizedDescription,
    canceled,
    blockedReason,
    corsErrorStatus,
  }: Protocol.Network.LoadingFailedEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }

    networkRequest.failed = true;
    networkRequest.setResourceType(Common.ResourceType.resourceTypes[resourceType]);
    networkRequest.canceled = Boolean(canceled);
    if (blockedReason) {
      networkRequest.setBlockedReason(blockedReason);
      if (blockedReason === Protocol.Network.BlockedReason.Inspector) {
        const message = i18nString(UIStrings.requestWasBlockedByDevtoolsS, {PH1: networkRequest.url()});
        this.#manager.dispatchEventToListeners(Events.MessageGenerated, {message, requestId, warning: true});
      }
    }
    if (corsErrorStatus) {
      networkRequest.setCorsErrorStatus(corsErrorStatus);
    }
    networkRequest.localizedFailDescription = localizedDescription;
    this.getExtraInfoBuilder(requestId).finished();
    this.finishNetworkRequest(networkRequest, time, -1);
  }

  webSocketCreated({requestId, url: requestURL, initiator}: Protocol.Network.WebSocketCreatedEvent): void {
    const networkRequest =
        NetworkRequest.createForSocket(requestId, requestURL as Platform.DevToolsPath.UrlString, initiator);
    requestToManagerMap.set(networkRequest, this.#manager);
    networkRequest.setResourceType(Common.ResourceType.resourceTypes.WebSocket);
    this.startNetworkRequest(networkRequest, null);
  }

  webSocketWillSendHandshakeRequest({requestId, timestamp: time, wallTime, request}:
                                        Protocol.Network.WebSocketWillSendHandshakeRequestEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }

    networkRequest.requestMethod = 'GET';
    networkRequest.setRequestHeaders(this.headersMapToHeadersArray(request.headers));
    networkRequest.setIssueTime(time, wallTime);

    this.updateNetworkRequest(networkRequest);
  }

  webSocketHandshakeResponseReceived({requestId, timestamp: time, response}:
                                         Protocol.Network.WebSocketHandshakeResponseReceivedEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }

    networkRequest.statusCode = response.status;
    networkRequest.statusText = response.statusText;
    networkRequest.responseHeaders = this.headersMapToHeadersArray(response.headers);
    networkRequest.responseHeadersText = response.headersText || '';
    if (response.requestHeaders) {
      networkRequest.setRequestHeaders(this.headersMapToHeadersArray(response.requestHeaders));
    }
    if (response.requestHeadersText) {
      networkRequest.setRequestHeadersText(response.requestHeadersText);
    }
    networkRequest.responseReceivedTime = time;
    networkRequest.protocol = 'websocket';

    this.updateNetworkRequest(networkRequest);
  }

  webSocketFrameReceived({requestId, timestamp: time, response}: Protocol.Network.WebSocketFrameReceivedEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }

    networkRequest.addProtocolFrame(response, time, false);
    networkRequest.responseReceivedTime = time;

    this.updateNetworkRequest(networkRequest);
  }

  webSocketFrameSent({requestId, timestamp: time, response}: Protocol.Network.WebSocketFrameSentEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }

    networkRequest.addProtocolFrame(response, time, true);
    networkRequest.responseReceivedTime = time;

    this.updateNetworkRequest(networkRequest);
  }

  webSocketFrameError({requestId, timestamp: time, errorMessage}: Protocol.Network.WebSocketFrameErrorEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }

    networkRequest.addProtocolFrameError(errorMessage, time);
    networkRequest.responseReceivedTime = time;

    this.updateNetworkRequest(networkRequest);
  }

  webSocketClosed({requestId, timestamp: time}: Protocol.Network.WebSocketClosedEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }
    this.finishNetworkRequest(networkRequest, time, -1);
  }

  eventSourceMessageReceived({requestId, timestamp: time, eventName, eventId, data}:
                                 Protocol.Network.EventSourceMessageReceivedEvent): void {
    const networkRequest = this.#requestsById.get(requestId);
    if (!networkRequest) {
      return;
    }
    networkRequest.addEventSourceMessage(time, eventName, eventId, data);
  }

  requestIntercepted({}: Protocol.Network.RequestInterceptedEvent): void {
  }

  requestWillBeSentExtraInfo({
    requestId,
    associatedCookies,
    headers,
    deviceBoundSessionUsages,
    clientSecurityState,
    connectTiming,
    siteHasCookieInOtherPartition,
    appliedNetworkConditionsId
  }: Protocol.Network.RequestWillBeSentExtraInfoEvent): void {
    const blockedRequestCookies: BlockedCookieWithReason[] = [];
    const includedRequestCookies: IncludedCookieWithReason[] = [];
    for (const {blockedReasons, exemptionReason, cookie} of associatedCookies) {
      if (blockedReasons.length === 0) {
        includedRequestCookies.push({exemptionReason, cookie: Cookie.fromProtocolCookie(cookie)});
      } else {
        blockedRequestCookies.push({blockedReasons, cookie: Cookie.fromProtocolCookie(cookie)});
      }
    }
    const extraRequestInfo: ExtraRequestInfo = {
      blockedRequestCookies,
      includedRequestCookies,
      requestHeaders: this.headersMapToHeadersArray(headers),
      deviceBoundSessionUsages,
      clientSecurityState,
      connectTiming,
      siteHasCookieInOtherPartition,
      appliedNetworkConditionsId,
    };
    this.getExtraInfoBuilder(requestId).addRequestExtraInfo(extraRequestInfo);

    const networkRequest = this.#requestsById.get(requestId);
    if (appliedNetworkConditionsId && networkRequest) {
      networkRequest.setAppliedNetworkConditions(appliedNetworkConditionsId);
      this.updateNetworkRequest(networkRequest);
    }
  }

  responseReceivedEarlyHints({
    requestId,
    headers,
  }: Protocol.Network.ResponseReceivedEarlyHintsEvent): void {
    this.getExtraInfoBuilder(requestId).setEarlyHintsHeaders(this.headersMapToHeadersArray(headers));
  }

  responseReceivedExtraInfo({
    requestId,
    blockedCookies,
    headers,
    headersText,
    resourceIPAddressSpace,
    statusCode,
    cookiePartitionKey,
    cookiePartitionKeyOpaque,
    exemptedCookies,
  }: Protocol.Network.ResponseReceivedExtraInfoEvent): void {
    const extraResponseInfo: ExtraResponseInfo = {
      blockedResponseCookies:
          blockedCookies.map(blockedCookie => ({
                               blockedReasons: blockedCookie.blockedReasons,
                               cookieLine: blockedCookie.cookieLine,
                               cookie: blockedCookie.cookie ? Cookie.fromProtocolCookie(blockedCookie.cookie) : null,
                             })),
      responseHeaders: this.headersMapToHeadersArray(headers),
      responseHeadersText: headersText,
      resourceIPAddressSpace,
      statusCode,
      cookiePartitionKey,
      cookiePartitionKeyOpaque,
      exemptedResponseCookies: exemptedCookies?.map(exemptedCookie => ({
                                                      cookie: Cookie.fromProtocolCookie(exemptedCookie.cookie),
                                                      cookieLine: exemptedCookie.cookieLine,
                                                      exemptionReason: exemptedCookie.exemptionReason,
                                                    })),
    };
    this.getExtraInfoBuilder(requestId).addResponseExtraInfo(extraResponseInfo);
  }

  private getExtraInfoBuilder(requestId: string): ExtraInfoBuilder {
    let builder: ExtraInfoBuilder;
    if (!this.#requestIdToExtraInfoBuilder.has(requestId)) {
      builder = new ExtraInfoBuilder();
      this.#requestIdToExtraInfoBuilder.set(requestId, builder);
    } else {
      builder = (this.#requestIdToExtraInfoBuilder.get(requestId) as ExtraInfoBuilder);
    }
    return builder;
  }

  private appendRedirect(
      requestId: Protocol.Network.RequestId, time: number,
      redirectURL: Platform.DevToolsPath.UrlString): NetworkRequest {
    const originalNetworkRequest = this.#requestsById.get(requestId);
    if (!originalNetworkRequest) {
      throw new Error(`Could not find original network request for ${requestId}`);
    }
    let redirectCount = 0;
    for (let redirect = originalNetworkRequest.redirectSource(); redirect; redirect = redirect.redirectSource()) {
      redirectCount++;
    }

    originalNetworkRequest.markAsRedirect(redirectCount);
    this.finishNetworkRequest(originalNetworkRequest, time, -1);
    const newNetworkRequest = NetworkRequest.create(
        requestId, redirectURL, originalNetworkRequest.documentURL, originalNetworkRequest.frameId,
        originalNetworkRequest.loaderId, originalNetworkRequest.initiator(),
        originalNetworkRequest.hasUserGesture() ?? undefined);
    requestToManagerMap.set(newNetworkRequest, this.#manager);
    newNetworkRequest.setRedirectSource(originalNetworkRequest);
    originalNetworkRequest.setRedirectDestination(newNetworkRequest);
    return newNetworkRequest;
  }

  private maybeAdoptMainResourceRequest(requestId: string): NetworkRequest|null {
    const request = MultitargetNetworkManager.instance().inflightMainResourceRequests.get(requestId);
    if (!request) {
      return null;
    }
    const oldDispatcher = (NetworkManager.forRequest(request) as NetworkManager).dispatcher;
    oldDispatcher.#requestsById.delete(requestId);
    oldDispatcher.#requestsByURL.delete(request.url());
    const loaderId = request.loaderId;
    if (loaderId) {
      oldDispatcher.#requestsByLoaderId.delete(loaderId);
    }
    const builder = oldDispatcher.#requestIdToExtraInfoBuilder.get(requestId);
    oldDispatcher.#requestIdToExtraInfoBuilder.delete(requestId);
    this.#requestsById.set(requestId, request);
    this.#requestsByURL.set(request.url(), request);
    if (loaderId) {
      this.#requestsByLoaderId.set(loaderId, request);
    }
    if (builder) {
      this.#requestIdToExtraInfoBuilder.set(requestId, builder);
    }
    requestToManagerMap.set(request, this.#manager);
    return request;
  }

  private startNetworkRequest(networkRequest: NetworkRequest, originalRequest: Protocol.Network.Request|null): void {
    this.#requestsById.set(networkRequest.requestId(), networkRequest);
    this.#requestsByURL.set(networkRequest.url(), networkRequest);
    const loaderId = networkRequest.loaderId;
    if (loaderId) {
      this.#requestsByLoaderId.set(loaderId, networkRequest);
    }
    // The following relies on the fact that loaderIds and requestIds
    // are globally unique and that the main request has them equal. If
    // loaderId is an empty string, it indicates a worker request. For the
    // request to fetch the main worker script, the request ID is the future
    // worker target ID and, therefore, it is unique.
    if (networkRequest.loaderId === networkRequest.requestId() || networkRequest.loaderId === '') {
      MultitargetNetworkManager.instance().inflightMainResourceRequests.set(networkRequest.requestId(), networkRequest);
    }

    this.#manager.dispatchEventToListeners(Events.RequestStarted, {request: networkRequest, originalRequest});
  }

  private updateNetworkRequest(networkRequest: NetworkRequest): void {
    this.#manager.dispatchEventToListeners(Events.RequestUpdated, networkRequest);
  }

  private finishNetworkRequest(
      networkRequest: NetworkRequest,
      finishTime: number,
      encodedDataLength: number,
      ): void {
    networkRequest.endTime = finishTime;
    networkRequest.finished = true;
    if (encodedDataLength >= 0) {
      const redirectSource = networkRequest.redirectSource();
      if (redirectSource?.signedExchangeInfo()) {
        networkRequest.setTransferSize(0);
        redirectSource.setTransferSize(encodedDataLength);
        this.updateNetworkRequest(redirectSource);
      } else {
        networkRequest.setTransferSize(encodedDataLength);
      }
    }
    this.#manager.dispatchEventToListeners(Events.RequestFinished, networkRequest);
    MultitargetNetworkManager.instance().inflightMainResourceRequests.delete(networkRequest.requestId());

    const settings = this.#manager.target().targetManager().settings;
    if (settings.moduleSetting('monitoring-xhr-enabled').get() &&
        networkRequest.resourceType().category() === Common.ResourceType.resourceCategories.XHR) {
      let message;
      const failedToLoad = networkRequest.failed || networkRequest.hasErrorStatusCode();
      if (failedToLoad) {
        message = i18nString(
            UIStrings.sFailedLoadingSS,
            {PH1: networkRequest.resourceType().title(), PH2: networkRequest.requestMethod, PH3: networkRequest.url()});
      } else {
        message = i18nString(
            UIStrings.sFinishedLoadingSS,
            {PH1: networkRequest.resourceType().title(), PH2: networkRequest.requestMethod, PH3: networkRequest.url()});
      }

      this.#manager.dispatchEventToListeners(
          Events.MessageGenerated, {message, requestId: networkRequest.requestId(), warning: false});
    }
  }

  clearRequests(): void {
    for (const [requestId, request] of this.#requestsById) {
      if (request.finished) {
        this.#requestsById.delete(requestId);
      }
    }
    for (const [requestURL, request] of this.#requestsByURL) {
      if (request.finished) {
        this.#requestsByURL.delete(requestURL);
      }
    }
    for (const [requestLoaderId, request] of this.#requestsByLoaderId) {
      if (request.finished) {
        this.#requestsByLoaderId.delete(requestLoaderId);
      }
    }
    for (const [requestId, builder] of this.#requestIdToExtraInfoBuilder) {
      if (builder.isFinished()) {
        this.#requestIdToExtraInfoBuilder.delete(requestId);
      }
    }
  }

  webTransportCreated({transportId, url: requestURL, timestamp: time, initiator}:
                          Protocol.Network.WebTransportCreatedEvent): void {
    const networkRequest =
        NetworkRequest.createForSocket(transportId, requestURL as Platform.DevToolsPath.UrlString, initiator);
    networkRequest.hasNetworkData = true;
    requestToManagerMap.set(networkRequest, this.#manager);
    networkRequest.setResourceType(Common.ResourceType.resourceTypes.WebTransport);
    networkRequest.setIssueTime(time, 0);
    // TODO(yoichio): Add appropreate events to address abort cases.
    this.startNetworkRequest(networkRequest, null);
  }

  webTransportConnectionEstablished({transportId, timestamp: time}:
                                        Protocol.Network.WebTransportConnectionEstablishedEvent): void {
    const networkRequest = this.#requestsById.get(transportId);
    if (!networkRequest) {
      return;
    }

    // This dummy deltas are needed to show this request as being
    // downloaded(blue) given typical WebTransport is kept for a while.
    // TODO(yoichio): Add appropreate events to fix these dummy datas.
    // DNS lookup?
    networkRequest.responseReceivedTime = time;
    networkRequest.endTime = time + 0.001;
    this.updateNetworkRequest(networkRequest);
  }

  webTransportClosed({transportId, timestamp: time}: Protocol.Network.WebTransportClosedEvent): void {
    const networkRequest = this.#requestsById.get(transportId);
    if (!networkRequest) {
      return;
    }

    networkRequest.endTime = time;
    this.finishNetworkRequest(networkRequest, time, 0);
  }

  directTCPSocketCreated(event: Protocol.Network.DirectTCPSocketCreatedEvent): void {
    const requestURL = this.concatHostPort(event.remoteAddr, event.remotePort);
    const networkRequest = NetworkRequest.createForSocket(
        event.identifier, requestURL as Platform.DevToolsPath.UrlString, event.initiator);
    networkRequest.hasNetworkData = true;
    networkRequest.setRemoteAddress(event.remoteAddr, event.remotePort);
    networkRequest.protocol = i18n.i18n.lockedString('tcp');

    networkRequest.statusText = i18nString(UIStrings.directSocketStatusOpening);
    networkRequest.directSocketInfo = {
      type: DirectSocketType.TCP,
      status: DirectSocketStatus.OPENING,
      createOptions: {
        remoteAddr: event.remoteAddr,
        remotePort: event.remotePort,
        noDelay: event.options.noDelay,
        keepAliveDelay: event.options.keepAliveDelay,
        sendBufferSize: event.options.sendBufferSize,
        receiveBufferSize: event.options.receiveBufferSize,
        dnsQueryType: event.options.dnsQueryType,
      }
    };
    networkRequest.setResourceType(Common.ResourceType.resourceTypes.DirectSocket);
    networkRequest.setIssueTime(event.timestamp, event.timestamp);

    requestToManagerMap.set(networkRequest, this.#manager);
    this.startNetworkRequest(networkRequest, null);
  }

  directTCPSocketOpened(event: Protocol.Network.DirectTCPSocketOpenedEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest?.directSocketInfo) {
      return;
    }
    networkRequest.responseReceivedTime = event.timestamp;
    networkRequest.directSocketInfo.status = DirectSocketStatus.OPEN;
    networkRequest.statusText = i18nString(UIStrings.directSocketStatusOpen);
    networkRequest.directSocketInfo.openInfo = {
      remoteAddr: event.remoteAddr,
      remotePort: event.remotePort,
      localAddr: event.localAddr,
      localPort: event.localPort,
    };
    networkRequest.setRemoteAddress(event.remoteAddr, event.remotePort);
    const requestURL = this.concatHostPort(event.remoteAddr, event.remotePort);
    networkRequest.setUrl(requestURL as Platform.DevToolsPath.UrlString);
    this.updateNetworkRequest(networkRequest);
  }

  directTCPSocketAborted(event: Protocol.Network.DirectTCPSocketAbortedEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest?.directSocketInfo) {
      return;
    }
    networkRequest.failed = true;
    networkRequest.directSocketInfo.status = DirectSocketStatus.ABORTED;
    networkRequest.statusText = i18nString(UIStrings.directSocketStatusAborted);
    networkRequest.directSocketInfo.errorMessage = event.errorMessage;
    this.finishNetworkRequest(networkRequest, event.timestamp, 0);
  }

  directTCPSocketClosed(event: Protocol.Network.DirectTCPSocketClosedEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest?.directSocketInfo) {
      return;
    }
    networkRequest.statusText = i18nString(UIStrings.directSocketStatusClosed);
    networkRequest.directSocketInfo.status = DirectSocketStatus.CLOSED;
    this.finishNetworkRequest(networkRequest, event.timestamp, 0);
  }

  directTCPSocketChunkSent(event: Protocol.Network.DirectTCPSocketChunkSentEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest) {
      return;
    }

    networkRequest.addDirectSocketChunk({
      data: event.data,
      type: DirectSocketChunkType.SEND,
      timestamp: event.timestamp,
    });
    networkRequest.responseReceivedTime = event.timestamp;

    this.updateNetworkRequest(networkRequest);
  }

  directTCPSocketChunkReceived(event: Protocol.Network.DirectTCPSocketChunkReceivedEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest) {
      return;
    }

    networkRequest.addDirectSocketChunk({
      data: event.data,
      type: DirectSocketChunkType.RECEIVE,
      timestamp: event.timestamp,
    });
    networkRequest.responseReceivedTime = event.timestamp;

    this.updateNetworkRequest(networkRequest);
  }

  directUDPSocketCreated(event: Protocol.Network.DirectUDPSocketCreatedEvent): void {
    let requestURL = '';
    let type: DirectSocketType;
    if (event.options.remoteAddr && event.options.remotePort) {
      requestURL = this.concatHostPort(event.options.remoteAddr, event.options.remotePort);
      type = DirectSocketType.UDP_CONNECTED;
    } else if (event.options.localAddr) {
      requestURL = this.concatHostPort(event.options.localAddr, event.options.localPort);
      type = DirectSocketType.UDP_BOUND;
    } else {
      // Must be present in a valid command if remoteAddr
      // is not specified.
      return;
    }
    const networkRequest = NetworkRequest.createForSocket(
        event.identifier, requestURL as Platform.DevToolsPath.UrlString, event.initiator);
    networkRequest.hasNetworkData = true;
    if (event.options.remoteAddr && event.options.remotePort) {
      networkRequest.setRemoteAddress(event.options.remoteAddr, event.options.remotePort);
    }
    networkRequest.protocol = i18n.i18n.lockedString('udp');

    networkRequest.statusText = i18nString(UIStrings.directSocketStatusOpening);
    networkRequest.directSocketInfo = {
      type,
      status: DirectSocketStatus.OPENING,
      createOptions: {
        remoteAddr: event.options.remoteAddr,
        remotePort: event.options.remotePort,
        localAddr: event.options.localAddr,
        localPort: event.options.localPort,
        sendBufferSize: event.options.sendBufferSize,
        receiveBufferSize: event.options.receiveBufferSize,
        dnsQueryType: event.options.dnsQueryType,
        multicastLoopback: event.options.multicastLoopback,
        multicastTimeToLive: event.options.multicastTimeToLive,
        multicastAllowAddressSharing: event.options.multicastAllowAddressSharing,
      },
      joinedMulticastGroups: new Set(),
    };
    networkRequest.setResourceType(Common.ResourceType.resourceTypes.DirectSocket);
    networkRequest.setIssueTime(event.timestamp, event.timestamp);

    requestToManagerMap.set(networkRequest, this.#manager);
    this.startNetworkRequest(networkRequest, null);
  }

  directUDPSocketOpened(event: Protocol.Network.DirectUDPSocketOpenedEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest?.directSocketInfo) {
      return;
    }
    let requestURL: string;
    if (networkRequest.directSocketInfo.type === DirectSocketType.UDP_CONNECTED) {
      if (!event.remoteAddr || !event.remotePort) {
        // Connected socket must have remoteAdd and remotePort.
        return;
      }
      networkRequest.setRemoteAddress(event.remoteAddr, event.remotePort);
      requestURL = this.concatHostPort(event.remoteAddr, event.remotePort);
    } else {
      requestURL = this.concatHostPort(event.localAddr, event.localPort);
    }

    networkRequest.setUrl(requestURL as Platform.DevToolsPath.UrlString);
    networkRequest.responseReceivedTime = event.timestamp;
    networkRequest.directSocketInfo.status = DirectSocketStatus.OPEN;
    networkRequest.statusText = i18nString(UIStrings.directSocketStatusOpen);
    networkRequest.directSocketInfo.openInfo = {
      remoteAddr: event.remoteAddr,
      remotePort: event.remotePort,
      localAddr: event.localAddr,
      localPort: event.localPort,
    };

    this.updateNetworkRequest(networkRequest);
  }

  directUDPSocketAborted(event: Protocol.Network.DirectUDPSocketAbortedEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest?.directSocketInfo) {
      return;
    }
    networkRequest.failed = true;
    networkRequest.directSocketInfo.status = DirectSocketStatus.ABORTED;
    networkRequest.statusText = i18nString(UIStrings.directSocketStatusAborted);
    networkRequest.directSocketInfo.errorMessage = event.errorMessage;
    this.finishNetworkRequest(networkRequest, event.timestamp, 0);
  }

  directUDPSocketClosed(event: Protocol.Network.DirectUDPSocketClosedEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest?.directSocketInfo) {
      return;
    }
    networkRequest.statusText = i18nString(UIStrings.directSocketStatusClosed);
    networkRequest.directSocketInfo.status = DirectSocketStatus.CLOSED;
    this.finishNetworkRequest(networkRequest, event.timestamp, 0);
  }

  directUDPSocketChunkSent(event: Protocol.Network.DirectUDPSocketChunkSentEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest) {
      return;
    }

    networkRequest.addDirectSocketChunk({
      data: event.message.data,
      type: DirectSocketChunkType.SEND,
      timestamp: event.timestamp,
      remoteAddress: event.message.remoteAddr,
      remotePort: event.message.remotePort
    });
    networkRequest.responseReceivedTime = event.timestamp;

    this.updateNetworkRequest(networkRequest);
  }

  directUDPSocketChunkReceived(event: Protocol.Network.DirectUDPSocketChunkReceivedEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest) {
      return;
    }

    networkRequest.addDirectSocketChunk({
      data: event.message.data,
      type: DirectSocketChunkType.RECEIVE,
      timestamp: event.timestamp,
      remoteAddress: event.message.remoteAddr,
      remotePort: event.message.remotePort
    });
    networkRequest.responseReceivedTime = event.timestamp;

    this.updateNetworkRequest(networkRequest);
  }

  directUDPSocketJoinedMulticastGroup(event: Protocol.Network.DirectUDPSocketJoinedMulticastGroupEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest?.directSocketInfo) {
      return;
    }
    if (!networkRequest.directSocketInfo.joinedMulticastGroups) {
      networkRequest.directSocketInfo.joinedMulticastGroups = new Set();
    }
    if (!networkRequest.directSocketInfo.joinedMulticastGroups.has(event.IPAddress)) {
      networkRequest.directSocketInfo.joinedMulticastGroups.add(event.IPAddress);
      this.updateNetworkRequest(networkRequest);
    }
  }

  directUDPSocketLeftMulticastGroup(event: Protocol.Network.DirectUDPSocketLeftMulticastGroupEvent): void {
    const networkRequest = this.#requestsById.get(event.identifier);
    if (!networkRequest?.directSocketInfo?.joinedMulticastGroups) {
      return;
    }
    if (networkRequest.directSocketInfo.joinedMulticastGroups.delete(event.IPAddress)) {
      this.updateNetworkRequest(networkRequest);
    }
  }

  trustTokenOperationDone(event: Protocol.Network.TrustTokenOperationDoneEvent): void {
    const request = this.#requestsById.get(event.requestId);
    if (!request) {
      this.#requestIdToTrustTokenEvent.set(event.requestId, event);
      return;
    }
    request.setTrustTokenOperationDoneEvent(event);
  }

  reportingApiReportAdded(data: Protocol.Network.ReportingApiReportAddedEvent): void {
    this.#manager.dispatchEventToListeners(Events.ReportingApiReportAdded, data.report);
  }

  reportingApiReportUpdated(data: Protocol.Network.ReportingApiReportUpdatedEvent): void {
    this.#manager.dispatchEventToListeners(Events.ReportingApiReportUpdated, data.report);
  }

  reportingApiEndpointsChangedForOrigin(data: Protocol.Network.ReportingApiEndpointsChangedForOriginEvent): void {
    this.#manager.dispatchEventToListeners(Events.ReportingApiEndpointsChangedForOrigin, data);
  }

  deviceBoundSessionsAdded(_params: Protocol.Network.DeviceBoundSessionsAddedEvent): void {
    this.#manager.dispatchEventToListeners(Events.DeviceBoundSessionsAdded, _params.sessions);
  }

  deviceBoundSessionEventOccurred(_params: Protocol.Network.DeviceBoundSessionEventOccurredEvent): void {
    this.#manager.dispatchEventToListeners(Events.DeviceBoundSessionEventOccurred, _params);
  }

  policyUpdated(): void {
  }

  /**
   * @deprecated
   * This method is only kept for usage in a web test.
   */
  protected createNetworkRequest(
      requestId: Protocol.Network.RequestId, frameId: Protocol.Page.FrameId, loaderId: Protocol.Network.LoaderId,
      url: string, documentURL: string, initiator: Protocol.Network.Initiator|null): NetworkRequest {
    const request = NetworkRequest.create(
        requestId, url as Platform.DevToolsPath.UrlString, documentURL as Platform.DevToolsPath.UrlString, frameId,
        loaderId, initiator);
    requestToManagerMap.set(request, this.#manager);
    return request;
  }

  private concatHostPort(host: string, port?: number): string {
    if (!port || port === 0) {
      return host;
    }
    return `${host}:${port}`;
  }
}

export type RequestConditionsSetting = {
  url: string,
  enabled: boolean,
}|{
  urlPattern: URLPatternConstructorString,
  conditions: ThrottlingConditionKey,
  enabled: boolean,
};

export type URLPatternConstructorString = Platform.Brand.Brand<string, 'URLPatternConstructorString'>;

export const enum RequestURLPatternValidity {
  VALID = 'valid',
  FAILED_TO_PARSE = 'failed-to-parse',
  HAS_REGEXP_GROUPS = 'has-regexp-groups',
}

export class RequestURLPattern {
  private constructor(readonly constructorString: URLPatternConstructorString, readonly pattern: URLPattern) {
    if (pattern.hasRegExpGroups) {
      throw new Error('RegExp groups are not allowed');
    }
  }

  static isValidPattern(pattern: string): RequestURLPatternValidity {
    try {
      const urlPattern = new URLPattern(pattern);
      return urlPattern.hasRegExpGroups ? RequestURLPatternValidity.HAS_REGEXP_GROUPS : RequestURLPatternValidity.VALID;
    } catch {
      return RequestURLPatternValidity.FAILED_TO_PARSE;
    }
  }

  static create(constructorString: URLPatternConstructorString): RequestURLPattern|null {
    try {
      const urlPattern = new URLPattern(constructorString);
      return urlPattern.hasRegExpGroups ? null : new RequestURLPattern(constructorString, urlPattern);
    } catch {
      return null;
    }
  }

  static upgradeFromWildcard(pattern: string): RequestURLPattern|null {
    const tryCreate = (constructorString: string): RequestURLPattern|null => {
      const result = this.create(constructorString as URLPatternConstructorString);
      if (result?.pattern.protocol === 'localhost' && result?.pattern.hostname === '') {
        // localhost:1234 parses as a valid pattern, do the right thing here instead
        return tryCreate(`*://${constructorString}`);
      }
      return result;
    };

    return tryCreate(pattern)  // try as is
        ??
        // Try to upgrade patterns created from the network panel, which either blocks the full url (sans
        // protocol) or just the domain name. In both cases the wildcard patterns had implicit wildcards at the end.
        // We explicitly add that here, which will match both domain names without path (implicitly setting pathname
        // to '*') and urls with path (appending * to the pathname).
        tryCreate(`*://${pattern}*`);
  }
}

export class RequestCondition extends Common.ObjectWrapper.ObjectWrapper<RequestCondition.EventTypes> {
  #pattern: RequestURLPattern|{wildcardURL: string, upgradedPattern?: RequestURLPattern};
  #enabled: boolean;
  #conditions: ThrottlingConditions;
  #ruleIds = new Set<string>();

  static createFromSetting(
      setting: RequestConditionsSetting,
      settings: Common.Settings.Settings = Common.Settings.Settings.instance()): RequestCondition {
    if ('urlPattern' in setting) {
      const pattern = RequestURLPattern.create(setting.urlPattern) ?? {
        wildcardURL: setting.urlPattern,
        upgradedPattern: RequestURLPattern.upgradeFromWildcard(setting.urlPattern) ?? undefined,
      };

      const conditions = getPredefinedOrBlockingCondition(setting.conditions) ??
          customUserNetworkConditionsSetting(settings).get().find(condition => condition.key === setting.conditions) ??
          NoThrottlingConditions;

      return new this(pattern, setting.enabled, conditions);
    }

    const pattern = {
      wildcardURL: setting.url,
      upgradedPattern: RequestURLPattern.upgradeFromWildcard(setting.url) ?? undefined
    };
    return new this(pattern, setting.enabled, BlockingConditions);
  }

  static create(pattern: RequestURLPattern, conditions: ThrottlingConditions): RequestCondition {
    return new this(pattern, /* enabled=*/ true, conditions);
  }

  private constructor(
      pattern: RequestURLPattern|{wildcardURL: string, upgradedPattern?: RequestURLPattern}, enabled: boolean,
      conditions: ThrottlingConditions) {
    super();
    this.#pattern = pattern;
    this.#enabled = enabled;
    this.#conditions = conditions;
  }

  get isBlocking(): boolean {
    return this.conditions === BlockingConditions;
  }

  get ruleIds(): Set<string> {
    return this.#ruleIds;
  }

  get constructorString(): string|undefined {
    return this.#pattern instanceof RequestURLPattern ? this.#pattern.constructorString :
                                                        this.#pattern.upgradedPattern?.constructorString;
  }

  get wildcardURL(): string|undefined {
    return 'wildcardURL' in this.#pattern ? this.#pattern.wildcardURL : undefined;
  }

  get constructorStringOrWildcardURL(): string {
    return this.#pattern instanceof RequestURLPattern ?
        this.#pattern.constructorString :
        (this.#pattern.upgradedPattern?.constructorString ?? this.#pattern.wildcardURL);
  }

  set pattern(pattern: RequestURLPattern) {
    this.#pattern = pattern;
    this.dispatchEventToListeners(RequestCondition.Events.REQUEST_CONDITION_CHANGED);
  }

  get enabled(): boolean {
    return this.#enabled;
  }

  set enabled(enabled: boolean) {
    this.#enabled = enabled;
    this.dispatchEventToListeners(RequestCondition.Events.REQUEST_CONDITION_CHANGED);
  }

  get conditions(): ThrottlingConditions {
    return this.#conditions;
  }

  set conditions(conditions: ThrottlingConditions) {
    this.#conditions = conditions;
    this.#ruleIds = new Set();
    this.dispatchEventToListeners(RequestCondition.Events.REQUEST_CONDITION_CHANGED);
  }

  toSetting(): RequestConditionsSetting {
    const enabled = this.enabled;
    if (this.#pattern instanceof RequestURLPattern) {
      return {enabled, urlPattern: this.#pattern.constructorString, conditions: this.#conditions.key};
    }
    if (this.#conditions !== BlockingConditions && this.#pattern.upgradedPattern) {
      return {enabled, urlPattern: this.#pattern.upgradedPattern.constructorString, conditions: this.#conditions.key};
    }
    return {enabled, url: this.#pattern.wildcardURL};
  }

  get originalOrUpgradedURLPattern(): URLPattern|undefined {
    return this.#pattern instanceof RequestURLPattern ? this.#pattern.pattern : this.#pattern.upgradedPattern?.pattern;
  }
}

export namespace RequestCondition {
  export const enum Events {
    REQUEST_CONDITION_CHANGED = 'request-condition-changed',
  }

  export interface EventTypes {
    [Events.REQUEST_CONDITION_CHANGED]: void;
  }
}

export class RequestConditions extends Common.ObjectWrapper.ObjectWrapper<RequestConditions.EventTypes> {
  readonly #setting: Common.Settings.Setting<RequestConditionsSetting[]>;
  readonly #conditionsEnabledSetting: Common.Settings.Setting<boolean>;
  readonly #conditions: RequestCondition[] = [];
  readonly #requestConditionsById = new Map<string, {
    conditions: Conditions,
    urlPattern?: string,
  }>();
  #conditionsAppliedForTestPromise: Promise<unknown> = Promise.resolve();

  constructor(settings: Common.Settings.Settings) {
    super();
    this.#setting = settings.createSetting<RequestConditionsSetting[]>('network-blocked-patterns', []);
    this.#conditionsEnabledSetting = settings.moduleSetting<boolean>('request-blocking-enabled');
    for (const condition of this.#setting.get()) {
      try {
        this.#conditions.push(RequestCondition.createFromSetting(condition, settings));
      } catch (e) {
        console.error('Error loading throttling settings: ', e);
      }
    }
    for (const condition of this.#conditions) {
      condition.addEventListener(RequestCondition.Events.REQUEST_CONDITION_CHANGED, this.#conditionsChanged, this);
    }
    this.#conditionsEnabledSetting.addChangeListener(
        () => this.dispatchEventToListeners(RequestConditions.Events.REQUEST_CONDITIONS_CHANGED));
  }

  get count(): number {
    return this.#conditions.length;
  }

  get conditionsEnabled(): boolean {
    return this.#conditionsEnabledSetting.get();
  }

  set conditionsEnabled(enabled: boolean) {
    if (this.#conditionsEnabledSetting.get() === enabled) {
      return;
    }
    this.#conditionsEnabledSetting.set(enabled);
  }

  findCondition(pattern: string): RequestCondition|undefined {
    return this.#conditions.find(condition => condition.constructorString === pattern);
  }

  has(url: string): boolean {
    return Boolean(this.findCondition(url));
  }

  add(...conditions: RequestCondition[]): void {
    this.#conditions.push(...conditions);
    for (const condition of conditions) {
      condition.addEventListener(RequestCondition.Events.REQUEST_CONDITION_CHANGED, this.#conditionsChanged, this);
    }
    this.#conditionsChanged();
  }

  decreasePriority(condition: RequestCondition): void {
    const index = this.#conditions.indexOf(condition);
    if (index < 0 || index >= this.#conditions.length - 1) {
      return;
    }

    Platform.ArrayUtilities.swap(this.#conditions, index, index + 1);
    this.#conditionsChanged();
  }

  increasePriority(condition: RequestCondition): void {
    const index = this.#conditions.indexOf(condition);
    if (index <= 0) {
      return;
    }

    Platform.ArrayUtilities.swap(this.#conditions, index - 1, index);
    this.#conditionsChanged();
  }

  delete(condition: RequestCondition): void {
    const index = this.#conditions.indexOf(condition);
    if (index < 0) {
      return;
    }
    condition.removeEventListener(RequestCondition.Events.REQUEST_CONDITION_CHANGED, this.#conditionsChanged, this);
    this.#conditions.splice(index, 1);
    this.#conditionsChanged();
  }

  clear(): void {
    this.#conditions.splice(0);
    this.#conditionsChanged();
    for (const condition of this.#conditions) {
      condition.removeEventListener(RequestCondition.Events.REQUEST_CONDITION_CHANGED, this.#conditionsChanged, this);
    }
  }

  #conditionsChanged(): void {
    this.#setting.set(this.#conditions.map(condition => condition.toSetting()));
    this.dispatchEventToListeners(RequestConditions.Events.REQUEST_CONDITIONS_CHANGED);
  }

  get conditions(): IteratorObject<RequestCondition> {
    return this.#conditions.values();
  }

  applyConditions(offline: boolean, globalConditions: Conditions|null, ...agents: ProtocolProxyApi.NetworkApi[]):
      boolean {
    function isNonBlockingCondition(condition: ThrottlingConditions): condition is Conditions {
      return !('block' in condition);
    }
    const urlPatterns: Protocol.Network.BlockPattern[] = [];
    // We store all this info out-of-band to prevent races with changing conditions while the promise is still pending
    const matchedNetworkConditions: Array<{conditions: Conditions, ruleIds?: Set<string>, urlPattern?: string}> = [];
    if (this.conditionsEnabled) {
      for (const condition of this.#conditions) {
        const urlPattern = condition.constructorString;
        const conditions = condition.conditions;
        if (!condition.enabled || !urlPattern || conditions === NoThrottlingConditions) {
          continue;
        }
        const block = !isNonBlockingCondition(conditions);
        urlPatterns.push({urlPattern, block});
        if (!block) {
          const {ruleIds} = condition;
          matchedNetworkConditions.push({ruleIds, urlPattern, conditions});
        }
      }
    }

    if (globalConditions) {
      matchedNetworkConditions.push({conditions: globalConditions});
    }

    const promises: Array<Promise<unknown>> = [];

    for (const agent of agents) {
      promises.push(agent.invoke_setBlockedURLs({urlPatterns}));
      promises.push(agent
                        .invoke_emulateNetworkConditionsByRule({
                          offline,
                          matchedNetworkConditions: matchedNetworkConditions.map(
                              ({urlPattern, conditions}) => ({
                                urlPattern: urlPattern ?? '',
                                latency: conditions.latency,
                                downloadThroughput: conditions.download < 0 ? 0 : conditions.download,
                                uploadThroughput: conditions.upload < 0 ? 0 : conditions.upload,
                                packetLoss: (conditions.packetLoss ?? 0) < 0 ? 0 : conditions.packetLoss,
                                packetQueueLength: conditions.packetQueueLength,
                                packetReordering: conditions.packetReordering,
                                connectionType: NetworkManager.connectionType(conditions),
                              }))
                        })
                        .then(response => {
                          if (!response.getError()) {
                            for (let i = 0; i < response.ruleIds.length; ++i) {
                              const ruleId = response.ruleIds[i];
                              const {ruleIds, conditions, urlPattern} = matchedNetworkConditions[i];
                              if (ruleIds) {
                                this.#requestConditionsById.set(ruleId, {urlPattern, conditions});
                                matchedNetworkConditions[i].ruleIds?.add(ruleId);
                              }
                            }
                          }
                        }));
      promises.push(agent.invoke_overrideNetworkState({
        offline,
        latency: globalConditions?.latency ?? 0,
        downloadThroughput: globalConditions?.download ?? -1,
        uploadThroughput: globalConditions?.upload ?? -1,
        connectionType: globalConditions ? NetworkManager.connectionType(globalConditions) :
                                           Protocol.Network.ConnectionType.None,
      }));
    }

    this.#conditionsAppliedForTestPromise = this.#conditionsAppliedForTestPromise.then(() => Promise.all(promises));
    return urlPatterns.length > 0;
  }

  conditionsAppliedForTest(): Promise<unknown> {
    return this.#conditionsAppliedForTestPromise;
  }

  conditionsForId(appliedNetworkConditionsId: string): AppliedNetworkConditions|undefined {
    const requestConditions = this.#requestConditionsById.get(appliedNetworkConditionsId);
    if (!requestConditions) {
      return undefined;
    }
    const {conditions, urlPattern} = requestConditions;
    return new AppliedNetworkConditions(conditions, appliedNetworkConditionsId, urlPattern);
  }
}

export namespace RequestConditions {
  export const enum Events {
    REQUEST_CONDITIONS_CHANGED = 'request-conditions-changed',
  }
  export interface EventTypes {
    [Events.REQUEST_CONDITIONS_CHANGED]: void;
  }
}

export class AppliedNetworkConditions {
  constructor(
      readonly conditions: Conditions, readonly appliedNetworkConditionsId: string, readonly urlPattern?: string) {
  }
}

export class MultitargetNetworkManager extends Common.ObjectWrapper.ObjectWrapper<MultitargetNetworkManager.EventTypes>
    implements SDKModelObserver<NetworkManager> {
  readonly #targetManager: TargetManager;
  #userAgentOverride = '';
  #userAgentMetadataOverride: Protocol.Emulation.UserAgentMetadata|null = null;
  #customAcceptedEncodings: Protocol.Network.ContentEncoding[]|null = null;
  readonly #networkAgents = new Set<ProtocolProxyApi.NetworkApi>();
  readonly #fetchAgents = new Set<ProtocolProxyApi.FetchApi>();
  readonly inflightMainResourceRequests = new Map<string, NetworkRequest>();
  #networkConditions: Conditions = NoThrottlingConditions;
  #updatingInterceptionPatternsPromise: Promise<void>|null = null;
  readonly #requestConditions: RequestConditions;
  readonly #urlsForRequestInterceptor:
      Platform.MapUtilities.Multimap<(arg0: InterceptedRequest) => Promise<void>, InterceptionPattern> =
      new Platform.MapUtilities.Multimap();
  #extraHeaders?: Protocol.Network.Headers;
  #customUserAgent?: string;
  #isBlocking = false;

  constructor(targetManager: TargetManager) {
    super();
    this.#targetManager = targetManager;
    const settings = targetManager.settings;
    this.#requestConditions = new RequestConditions(settings);

    // TODO(allada) Remove these and merge it with request interception.
    const blockedPatternChanged: () => void = () => {
      this.updateBlockedPatterns();
      this.dispatchEventToListeners(MultitargetNetworkManager.Events.BLOCKED_PATTERNS_CHANGED);
    };
    this.#requestConditions.addEventListener(
        RequestConditions.Events.REQUEST_CONDITIONS_CHANGED, blockedPatternChanged);
    this.updateBlockedPatterns();

    this.#targetManager.observeModels(NetworkManager, this);
  }

  static instance(opts: {
    forceNew: boolean|null,
    targetManager?: TargetManager,
  } = {forceNew: null}): MultitargetNetworkManager {
    const {forceNew, targetManager} = opts;
    if (!Root.DevToolsContext.globalInstance().has(MultitargetNetworkManager) || forceNew) {
      Root.DevToolsContext.globalInstance().set(
          MultitargetNetworkManager, new MultitargetNetworkManager(targetManager ?? TargetManager.instance()));
    }

    return Root.DevToolsContext.globalInstance().get(MultitargetNetworkManager);
  }

  static dispose(): void {
    Root.DevToolsContext.globalInstance().delete(MultitargetNetworkManager);
  }

  static patchUserAgentWithChromeVersion(uaString: string): string {
    // Patches Chrome/ChrOS version from user #agent ("1.2.3.4" when user #agent is: "Chrome/1.2.3.4").
    // Otherwise, ignore it. This assumes additional appVersions appear after the Chrome version.
    const chromeVersion = Root.Runtime.getChromeVersion();
    if (chromeVersion.length > 0) {
      // "1.2.3.4" becomes "1.0.100.0"
      const additionalAppVersion = chromeVersion.split('.', 1)[0] + '.0.100.0';
      return Platform.StringUtilities.sprintf(uaString, chromeVersion, additionalAppVersion);
    }
    return uaString;
  }

  static patchUserAgentMetadataWithChromeVersion(userAgentMetadata: Protocol.Emulation.UserAgentMetadata): void {
    // Patches Chrome/ChrOS version from user #agent metadata ("1.2.3.4" when user #agent is: "Chrome/1.2.3.4").
    // Otherwise, ignore it. This assumes additional appVersions appear after the Chrome version.
    if (!userAgentMetadata.brands) {
      return;
    }
    const chromeVersion = Root.Runtime.getChromeVersion();
    if (chromeVersion.length === 0) {
      return;
    }

    const majorVersion = chromeVersion.split('.', 1)[0];
    for (const brand of userAgentMetadata.brands) {
      if (brand.version.includes('%s')) {
        brand.version = Platform.StringUtilities.sprintf(brand.version, majorVersion);
      }
    }

    if (userAgentMetadata.fullVersion) {
      if (userAgentMetadata.fullVersion.includes('%s')) {
        userAgentMetadata.fullVersion = Platform.StringUtilities.sprintf(userAgentMetadata.fullVersion, chromeVersion);
      }
    }
  }

  modelAdded(networkManager: NetworkManager): void {
    const networkAgent = networkManager.target().networkAgent();
    const fetchAgent = networkManager.target().fetchAgent();
    if (this.#extraHeaders) {
      void networkAgent.invoke_setExtraHTTPHeaders({headers: this.#extraHeaders});
    }
    if (this.currentUserAgent()) {
      void networkAgent.invoke_setUserAgentOverride(
          {userAgent: this.currentUserAgent(), userAgentMetadata: this.#userAgentMetadataOverride || undefined});
    }
    this.#requestConditions.applyConditions(
        this.isOffline(), this.isThrottling() ? this.#networkConditions : null, networkAgent);
    if (this.isIntercepting()) {
      void fetchAgent.invoke_enable({patterns: this.#urlsForRequestInterceptor.valuesArray()});
    }
    if (this.#customAcceptedEncodings === null) {
      void networkAgent.invoke_clearAcceptedEncodingsOverride();
    } else {
      void networkAgent.invoke_setAcceptedEncodings({encodings: this.#customAcceptedEncodings});
    }
    this.#networkAgents.add(networkAgent);
    this.#fetchAgents.add(fetchAgent);
  }

  modelRemoved(networkManager: NetworkManager): void {
    for (const entry of this.inflightMainResourceRequests) {
      const manager = NetworkManager.forRequest((entry[1]));
      if (manager !== networkManager) {
        continue;
      }
      this.inflightMainResourceRequests.delete((entry[0]));
    }
    this.#networkAgents.delete(networkManager.target().networkAgent());
    this.#fetchAgents.delete(networkManager.target().fetchAgent());
  }

  isThrottling(): boolean {
    return this.#networkConditions.download >= 0 || this.#networkConditions.upload >= 0 ||
        this.#networkConditions.latency > 0;
  }

  isOffline(): boolean {
    return !this.#networkConditions.download && !this.#networkConditions.upload;
  }

  setNetworkConditions(conditions: Conditions): void {
    this.#networkConditions = conditions;
    this.#requestConditions.applyConditions(
        this.isOffline(), this.isThrottling() ? this.#networkConditions : null, ...this.#networkAgents);
    this.dispatchEventToListeners(MultitargetNetworkManager.Events.CONDITIONS_CHANGED);
  }

  networkConditions(): Conditions {
    return this.#networkConditions;
  }

  private updateNetworkConditions(networkAgent: ProtocolProxyApi.NetworkApi): void {
    const conditions = this.#networkConditions;
    if (!this.isThrottling()) {
      void networkAgent.invoke_emulateNetworkConditions({
        offline: false,
        latency: 0,
        downloadThroughput: 0,
        uploadThroughput: 0,
      });
    } else {
      void networkAgent.invoke_emulateNetworkConditions({
        offline: this.isOffline(),
        latency: conditions.latency,
        downloadThroughput: conditions.download < 0 ? 0 : conditions.download,
        uploadThroughput: conditions.upload < 0 ? 0 : conditions.upload,
        packetLoss: (conditions.packetLoss ?? 0) < 0 ? 0 : conditions.packetLoss,
        packetQueueLength: conditions.packetQueueLength,
        packetReordering: conditions.packetReordering,
        connectionType: NetworkManager.connectionType(conditions),
      });
    }
  }

  setExtraHTTPHeaders(headers: Protocol.Network.Headers): void {
    this.#extraHeaders = headers;
    for (const agent of this.#networkAgents) {
      void agent.invoke_setExtraHTTPHeaders({headers: this.#extraHeaders});
    }
  }

  currentUserAgent(): string {
    return this.#customUserAgent ? this.#customUserAgent : this.#userAgentOverride;
  }

  private updateUserAgentOverride(): void {
    const userAgent = this.currentUserAgent();
    for (const agent of this.#networkAgents) {
      void agent.invoke_setUserAgentOverride(
          {userAgent, userAgentMetadata: this.#userAgentMetadataOverride || undefined});
    }
  }

  setUserAgentOverride(userAgent: string, userAgentMetadataOverride: Protocol.Emulation.UserAgentMetadata|null): void {
    const uaChanged = (this.#userAgentOverride !== userAgent);
    this.#userAgentOverride = userAgent;
    if (!this.#customUserAgent) {
      this.#userAgentMetadataOverride = userAgentMetadataOverride;
      this.updateUserAgentOverride();
    } else {
      this.#userAgentMetadataOverride = null;
    }

    if (uaChanged) {
      this.dispatchEventToListeners(MultitargetNetworkManager.Events.USER_AGENT_CHANGED);
    }
  }

  setCustomUserAgentOverride(
      userAgent: string, userAgentMetadataOverride: Protocol.Emulation.UserAgentMetadata|null = null): void {
    this.#customUserAgent = userAgent;
    this.#userAgentMetadataOverride = userAgentMetadataOverride;
    this.updateUserAgentOverride();
  }

  setCustomAcceptedEncodingsOverride(acceptedEncodings: Protocol.Network.ContentEncoding[]): void {
    this.#customAcceptedEncodings = acceptedEncodings;
    this.updateAcceptedEncodingsOverride();
    this.dispatchEventToListeners(MultitargetNetworkManager.Events.ACCEPTED_ENCODINGS_CHANGED);
  }

  clearCustomAcceptedEncodingsOverride(): void {
    this.#customAcceptedEncodings = null;
    this.updateAcceptedEncodingsOverride();
    this.dispatchEventToListeners(MultitargetNetworkManager.Events.ACCEPTED_ENCODINGS_CHANGED);
  }

  isAcceptedEncodingOverrideSet(): boolean {
    return this.#customAcceptedEncodings !== null;
  }

  private updateAcceptedEncodingsOverride(): void {
    const customAcceptedEncodings = this.#customAcceptedEncodings;
    for (const agent of this.#networkAgents) {
      if (customAcceptedEncodings === null) {
        void agent.invoke_clearAcceptedEncodingsOverride();
      } else {
        void agent.invoke_setAcceptedEncodings({encodings: customAcceptedEncodings});
      }
    }
  }

  get requestConditions(): RequestConditions {
    return this.#requestConditions;
  }

  isBlocking(): boolean {
    return this.#isBlocking && this.requestConditions.conditionsEnabled;
  }

  private updateBlockedPatterns(): void {
    this.#isBlocking = this.#requestConditions.applyConditions(
        this.isOffline(), this.isThrottling() ? this.#networkConditions : null, ...this.#networkAgents);
  }

  isIntercepting(): boolean {
    return Boolean(this.#urlsForRequestInterceptor.size);
  }

  setInterceptionHandlerForPatterns(
      patterns: InterceptionPattern[], requestInterceptor: (arg0: InterceptedRequest) => Promise<void>): Promise<void> {
    // Note: requestInterceptors may receive interception #requests for patterns they did not subscribe to.
    this.#urlsForRequestInterceptor.deleteAll(requestInterceptor);
    for (const newPattern of patterns) {
      this.#urlsForRequestInterceptor.set(requestInterceptor, newPattern);
    }
    return this.updateInterceptionPatternsOnNextTick();
  }

  private updateInterceptionPatternsOnNextTick(): Promise<void> {
    // This is used so we can register and unregister patterns in loops without sending lots of protocol messages.
    if (!this.#updatingInterceptionPatternsPromise) {
      this.#updatingInterceptionPatternsPromise = Promise.resolve().then(this.updateInterceptionPatterns.bind(this));
    }
    return this.#updatingInterceptionPatternsPromise;
  }

  private async updateInterceptionPatterns(): Promise<void> {
    const settings = this.#targetManager.settings;
    if (!settings.moduleSetting('cache-disabled').get()) {
      settings.moduleSetting('cache-disabled').set(true);
    }
    this.#updatingInterceptionPatternsPromise = null;
    const promises = ([] as Array<Promise<unknown>>);
    for (const agent of this.#fetchAgents) {
      promises.push(agent.invoke_enable({patterns: this.#urlsForRequestInterceptor.valuesArray()}));
    }
    this.dispatchEventToListeners(MultitargetNetworkManager.Events.INTERCEPTORS_CHANGED);
    await Promise.all(promises);
  }

  async requestIntercepted(interceptedRequest: InterceptedRequest): Promise<void> {
    for (const requestInterceptor of this.#urlsForRequestInterceptor.keysArray()) {
      await requestInterceptor(interceptedRequest);
      if (interceptedRequest.hasResponded() && interceptedRequest.networkRequest) {
        this.dispatchEventToListeners(
            MultitargetNetworkManager.Events.REQUEST_INTERCEPTED, interceptedRequest.networkRequest.requestId());
        return;
      }
    }
    if (!interceptedRequest.hasResponded()) {
      interceptedRequest.continueRequestWithoutChange();
    }
  }

  clearBrowserCache(): void {
    for (const agent of this.#networkAgents) {
      void agent.invoke_clearBrowserCache();
    }
  }

  clearBrowserCookies(): void {
    for (const agent of this.#networkAgents) {
      void agent.invoke_clearBrowserCookies();
    }
  }

  async getCertificate(origin: string): Promise<string[]> {
    const target = this.#targetManager.primaryPageTarget();
    if (!target) {
      return [];
    }
    const certificate = await target.networkAgent().invoke_getCertificate({origin});
    if (!certificate) {
      return [];
    }
    return certificate.tableNames;
  }

  appliedRequestConditions(requestInternal: NetworkRequest): AppliedNetworkConditions|undefined {
    if (!requestInternal.appliedNetworkConditionsId) {
      return undefined;
    }
    return this.requestConditions.conditionsForId(requestInternal.appliedNetworkConditionsId);
  }
}

export namespace MultitargetNetworkManager {
  export const enum Events {
    BLOCKED_PATTERNS_CHANGED = 'BlockedPatternsChanged',
    CONDITIONS_CHANGED = 'ConditionsChanged',
    USER_AGENT_CHANGED = 'UserAgentChanged',
    INTERCEPTORS_CHANGED = 'InterceptorsChanged',
    ACCEPTED_ENCODINGS_CHANGED = 'AcceptedEncodingsChanged',
    REQUEST_INTERCEPTED = 'RequestIntercepted',
    REQUEST_FULFILLED = 'RequestFulfilled',
  }

  export interface EventTypes {
    [Events.BLOCKED_PATTERNS_CHANGED]: void;
    [Events.CONDITIONS_CHANGED]: void;
    [Events.USER_AGENT_CHANGED]: void;
    [Events.INTERCEPTORS_CHANGED]: void;
    [Events.ACCEPTED_ENCODINGS_CHANGED]: void;
    [Events.REQUEST_INTERCEPTED]: string;
    [Events.REQUEST_FULFILLED]: Platform.DevToolsPath.UrlString;
  }
}

export class InterceptedRequest {
  readonly #fetchAgent: ProtocolProxyApi.FetchApi;
  #hasResponded = false;
  request: Protocol.Network.Request;
  resourceType: Protocol.Network.ResourceType;
  responseStatusCode: number|undefined;
  responseHeaders: Protocol.Fetch.HeaderEntry[]|undefined;
  requestId: Protocol.Fetch.RequestId;
  networkRequest: NetworkRequest|null;

  constructor(
      fetchAgent: ProtocolProxyApi.FetchApi,
      request: Protocol.Network.Request,
      resourceType: Protocol.Network.ResourceType,
      requestId: Protocol.Fetch.RequestId,
      networkRequest: NetworkRequest|null,
      responseStatusCode?: number,
      responseHeaders?: Protocol.Fetch.HeaderEntry[],
  ) {
    this.#fetchAgent = fetchAgent;
    this.request = request;
    this.resourceType = resourceType;
    this.responseStatusCode = responseStatusCode;
    this.responseHeaders = responseHeaders;
    this.requestId = requestId;
    this.networkRequest = networkRequest;
  }

  hasResponded(): boolean {
    return this.#hasResponded;
  }

  static mergeSetCookieHeaders(
      originalSetCookieHeaders: Protocol.Fetch.HeaderEntry[],
      setCookieHeadersFromOverrides: Protocol.Fetch.HeaderEntry[]): Protocol.Fetch.HeaderEntry[] {
    // Generates a map containing the `set-cookie` headers. Valid `set-cookie`
    // headers are stored by the cookie name. Malformed `set-cookie` headers are
    // stored by the whole header value. Duplicates are allowed.
    const generateHeaderMap = (headers: Protocol.Fetch.HeaderEntry[]): Map<string, string[]> => {
      const result = new Map<string, string[]>();
      for (const header of headers) {
        // The regex matches cookie headers of the form '<header-name>=<header-value>'.
        // <header-name> is a token as defined in https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens.
        // The shape of <header-value> is not being validated at all here.
        const match = header.value.match(/^([a-zA-Z0-9!#$%&'*+.^_`|~-]+=)(.*)$/);
        if (match) {
          if (result.has(match[1])) {
            result.get(match[1])?.push(header.value);
          } else {
            result.set(match[1], [header.value]);
          }
        } else if (result.has(header.value)) {
          result.get(header.value)?.push(header.value);
        } else {
          result.set(header.value, [header.value]);
        }
      }
      return result;
    };

    const originalHeadersMap = generateHeaderMap(originalSetCookieHeaders);
    const overridesHeaderMap = generateHeaderMap(setCookieHeadersFromOverrides);

    // Iterate over original headers. If the same key is found among the
    // overrides, use those instead.
    const mergedHeaders: Protocol.Fetch.HeaderEntry[] = [];
    for (const [key, headerValues] of originalHeadersMap) {
      if (overridesHeaderMap.has(key)) {
        for (const headerValue of overridesHeaderMap.get(key) || []) {
          mergedHeaders.push({name: 'set-cookie', value: headerValue});
        }
      } else {
        for (const headerValue of headerValues) {
          mergedHeaders.push({name: 'set-cookie', value: headerValue});
        }
      }
    }

    // Finally add all overrides which have not been added yet.
    for (const [key, headerValues] of overridesHeaderMap) {
      if (originalHeadersMap.has(key)) {
        continue;
      }
      for (const headerValue of headerValues) {
        mergedHeaders.push({name: 'set-cookie', value: headerValue});
      }
    }
    return mergedHeaders;
  }

  async continueRequestWithContent(
      contentBlob: Blob, encoded: boolean, responseHeaders: Protocol.Fetch.HeaderEntry[],
      isBodyOverridden: boolean): Promise<void> {
    this.#hasResponded = true;
    const body = encoded ? await contentBlob.text() : await Common.Base64.encode(contentBlob).catch(err => {
      console.error(err);
      return '';
    });
    const responseCode = isBodyOverridden ? 200 : (this.responseStatusCode || 200);

    if (this.networkRequest) {
      const originalSetCookieHeaders =
          this.networkRequest?.originalResponseHeaders.filter(header => header.name === 'set-cookie') || [];
      const setCookieHeadersFromOverrides = responseHeaders.filter(header => header.name === 'set-cookie');
      this.networkRequest.setCookieHeaders =
          InterceptedRequest.mergeSetCookieHeaders(originalSetCookieHeaders, setCookieHeadersFromOverrides);
      this.networkRequest.hasOverriddenContent = isBodyOverridden;
    }

    void this.#fetchAgent.invoke_fulfillRequest({requestId: this.requestId, responseCode, body, responseHeaders});
    MultitargetNetworkManager.instance().dispatchEventToListeners(
        MultitargetNetworkManager.Events.REQUEST_FULFILLED, this.request.url as Platform.DevToolsPath.UrlString);
  }

  continueRequestWithoutChange(): void {
    console.assert(!this.#hasResponded);
    this.#hasResponded = true;
    void this.#fetchAgent.invoke_continueRequest({requestId: this.requestId});
  }

  async responseBody(): Promise<TextUtils.ContentData.ContentDataOrError> {
    const response = await this.#fetchAgent.invoke_getResponseBody({requestId: this.requestId});
    const error = response.getError();
    if (error) {
      return {error};
    }

    const {mimeType, charset} = this.getMimeTypeAndCharset();
    return new TextUtils.ContentData.ContentData(
        response.body, response.base64Encoded, mimeType ?? 'application/octet-stream', charset ?? undefined);
  }

  isRedirect(): boolean {
    return this.responseStatusCode !== undefined && this.responseStatusCode >= 300 && this.responseStatusCode < 400;
  }

  /**
   * Tries to determine the MIME type and charset for this intercepted request.
   * Looks at the intercepted response headers first (for Content-Type header), then
   * checks the `NetworkRequest` if we have one.
   */
  getMimeTypeAndCharset(): {mimeType: string|null, charset: string|null} {
    for (const header of this.responseHeaders ?? []) {
      if (header.name.toLowerCase() === 'content-type') {
        return Platform.MimeType.parseContentType(header.value);
      }
    }

    const mimeType = this.networkRequest?.mimeType ?? null;
    const charset = this.networkRequest?.charset() ?? null;
    return {mimeType, charset};
  }
}

/**
 * Helper class to match #requests created from requestWillBeSent with
 * requestWillBeSentExtraInfo and responseReceivedExtraInfo when they have the
 * same requestId due to redirects.
 */
class ExtraInfoBuilder {
  readonly #requests: NetworkRequest[] = [];
  #responseExtraInfoFlag: Array<boolean|null> = [];
  #requestExtraInfos: Array<ExtraRequestInfo|null> = [];
  #responseExtraInfos: Array<ExtraResponseInfo|null> = [];
  #responseEarlyHintsHeaders: NameValue[] = [];
  #finished = false;

  addRequest(req: NetworkRequest): void {
    this.#requests.push(req);
    this.sync(this.#requests.length - 1);
  }

  addHasExtraInfo(hasExtraInfo: boolean): void {
    this.#responseExtraInfoFlag.push(hasExtraInfo);
    // This comes in response, so it can't come before request or after next
    // request in the redirect chain.
    console.assert(this.#requests.length === this.#responseExtraInfoFlag.length, 'request/response count mismatch');
    if (!hasExtraInfo) {
      // We may potentially have gotten extra infos from the next redirect
      // request already. Account for that by inserting null for missing
      // extra infos at current position.
      this.#requestExtraInfos.splice(this.#requests.length - 1, 0, null);
      this.#responseExtraInfos.splice(this.#requests.length - 1, 0, null);
    }
    this.sync(this.#requests.length - 1);
  }

  addRequestExtraInfo(info: ExtraRequestInfo): void {
    this.#requestExtraInfos.push(info);
    this.sync(this.#requestExtraInfos.length - 1);
  }

  addResponseExtraInfo(info: ExtraResponseInfo): void {
    this.#responseExtraInfos.push(info);
    this.sync(this.#responseExtraInfos.length - 1);
  }

  setEarlyHintsHeaders(earlyHintsHeaders: NameValue[]): void {
    this.#responseEarlyHintsHeaders = earlyHintsHeaders;
    this.updateFinalRequest();
  }

  finished(): void {
    this.#finished = true;
    // We may have missed responseReceived event in case of failure.
    // That said, the ExtraInfo events still may be here, so mark them
    // as present. Event if they are not, this is harmless.
    // TODO(caseq): consider if we need to report hasExtraInfo in the
    // loadingFailed event.
    if (this.#responseExtraInfoFlag.length < this.#requests.length) {
      this.#responseExtraInfoFlag.push(true);
      this.sync(this.#responseExtraInfoFlag.length - 1);
    }
    console.assert(
        this.#requests.length === this.#responseExtraInfoFlag.length,
        'request/response count mismatch when request finished');
    this.updateFinalRequest();
  }

  isFinished(): boolean {
    return this.#finished;
  }

  private sync(index: number): void {
    const req = this.#requests[index];
    if (!req) {
      return;
    }

    // No response yet, so we don't know if extra info would
    // be there, bail out for now.
    if (index >= this.#responseExtraInfoFlag.length) {
      return;
    }
    if (!this.#responseExtraInfoFlag[index]) {
      return;
    }

    const requestExtraInfo = this.#requestExtraInfos[index];
    if (requestExtraInfo) {
      req.addExtraRequestInfo(requestExtraInfo);
      this.#requestExtraInfos[index] = null;
    }

    const responseExtraInfo = this.#responseExtraInfos[index];
    if (responseExtraInfo) {
      req.addExtraResponseInfo(responseExtraInfo);
      this.#responseExtraInfos[index] = null;
    }
  }

  finalRequest(): NetworkRequest|null {
    if (!this.#finished) {
      return null;
    }
    return this.#requests[this.#requests.length - 1] || null;
  }

  private updateFinalRequest(): void {
    if (!this.#finished) {
      return;
    }
    const finalRequest = this.finalRequest();
    finalRequest?.setEarlyHintsHeaders(this.#responseEarlyHintsHeaders);
  }
}

SDKModel.register(NetworkManager, {capabilities: Capability.NETWORK, autostart: true});

export function networkConditionsEqual(first: ThrottlingConditions, second: ThrottlingConditions): boolean {
  if ('block' in first || 'block' in second) {
    if ('block' in first && 'block' in second) {
      const firstTitle = (typeof first.title === 'function' ? first.title() : first.title);
      const secondTitle = (typeof second.title === 'function' ? second.title() : second.title);
      return firstTitle === secondTitle && first.block === second.block;
    }
    return false;
  }
  // Caution: titles might be different function instances, which produce
  // the same value.
  // We prefer to use the i18nTitleKey to prevent against locale changes or
  // UIString changes that might change the value vs what the user has stored
  // locally.
  const firstTitle = first.i18nTitleKey || (typeof first.title === 'function' ? first.title() : first.title);
  const secondTitle = second.i18nTitleKey || (typeof second.title === 'function' ? second.title() : second.title);

  return second.download === first.download && second.upload === first.upload && second.latency === first.latency &&
      first.packetLoss === second.packetLoss && first.packetQueueLength === second.packetQueueLength &&
      first.packetReordering === second.packetReordering && secondTitle === firstTitle;
}

/**
 * IMPORTANT: this key is used as the value that is persisted so we remember
 * the user's throttling settings
 *
 * This means that it is very important that;
 * 1. Each Conditions that is defined must have a unique key.
 * 2. The keys & values DO NOT CHANGE for a particular condition, else we might break
 *    DevTools when restoring a user's persisted setting.
 *
 * If you do want to change them, you need to handle that in a migration, but
 * please talk to jacktfranklin@ first.
 */
export const enum PredefinedThrottlingConditionKey {
  BLOCKING = 'BLOCKING',
  NO_THROTTLING = 'NO_THROTTLING',
  OFFLINE = 'OFFLINE',
  SPEED_3G = 'SPEED_3G',
  SPEED_SLOW_4G = 'SPEED_SLOW_4G',
  SPEED_FAST_4G = 'SPEED_FAST_4G',
}

export type UserDefinedThrottlingConditionKey = `USER_CUSTOM_SETTING_${number}`;
export type ThrottlingConditionKey = PredefinedThrottlingConditionKey|UserDefinedThrottlingConditionKey;

export const THROTTLING_CONDITIONS_LOOKUP: ReadonlyMap<PredefinedThrottlingConditionKey, Conditions> = new Map([
  [PredefinedThrottlingConditionKey.NO_THROTTLING, NoThrottlingConditions],
  [PredefinedThrottlingConditionKey.OFFLINE, OfflineConditions],
  [PredefinedThrottlingConditionKey.SPEED_3G, Slow3GConditions],
  [PredefinedThrottlingConditionKey.SPEED_SLOW_4G, Slow4GConditions],
  [PredefinedThrottlingConditionKey.SPEED_FAST_4G, Fast4GConditions]
]);

function keyIsPredefined(key: ThrottlingConditionKey): key is PredefinedThrottlingConditionKey {
  return !key.startsWith('USER_CUSTOM_SETTING_');
}
export function keyIsCustomUser(key: ThrottlingConditionKey): key is UserDefinedThrottlingConditionKey {
  return key.startsWith('USER_CUSTOM_SETTING_');
}

export function getPredefinedCondition(key: ThrottlingConditionKey): Conditions|null {
  if (!keyIsPredefined(key)) {
    return null;
  }
  return THROTTLING_CONDITIONS_LOOKUP.get(key) ?? null;
}

export function getPredefinedOrBlockingCondition(key: ThrottlingConditionKey): ThrottlingConditions|null {
  return key === PredefinedThrottlingConditionKey.BLOCKING ? BlockingConditions : getPredefinedCondition(key);
}

export type ThrottlingConditions = Conditions|{
  readonly key: ThrottlingConditionKey,
  block: true,
  title: string | (() => string),
};
export interface Conditions {
  readonly key: ThrottlingConditionKey;
  download: number;
  upload: number;
  latency: number;
  packetLoss?: number;
  packetQueueLength?: number;
  packetReordering?: boolean;
  // TODO(crbug.com/422682525): make this just a function because we use lazy string everywhere.
  title: string|(() => string);
  // Instances may be serialized to local storage, so localized titles
  // should not be irrecoverably baked, just in case the string changes
  // (or the user switches locales).
  // TODO(crbug.com/422682525): get rid of this, there is no need to store on
  // the condition now we do not rely on it to reload a setting from disk.
  i18nTitleKey?: string;
  /**
   * RTT values are multiplied by adjustment factors to make DevTools' emulation more accurate.
   * This value represents the RTT value *before* the adjustment factor is applied.
   * @see https://docs.google.com/document/d/10lfVdS1iDWCRKQXPfbxEn4Or99D64mvNlugP1AQuFlE/edit for historical context.
   */
  targetLatency?: number;
}

export interface Message {
  message: string;
  requestId: string;
  warning: boolean;
}

export interface InterceptionPattern {
  urlPattern: string;
  requestStage: Protocol.Fetch.RequestStage;
}

export type RequestInterceptor = (request: InterceptedRequest) => Promise<void>;

export interface RequestUpdateDroppedEventData {
  url: Platform.DevToolsPath.UrlString;
  frameId: Protocol.Page.FrameId|null;
  loaderId: Protocol.Network.LoaderId;
  resourceType: Protocol.Network.ResourceType;
  mimeType: string;
  lastModified: Date|null;
}

/**
 * For the given Round Trip Time (in MilliSeconds), return the best throttling conditions.
 */
export function getRecommendedNetworkPreset(rtt: number): Conditions|null {
  const RTT_COMPARISON_THRESHOLD = 200;
  const RTT_MINIMUM = 60;

  if (!Number.isFinite(rtt)) {
    return null;
  }

  if (rtt < RTT_MINIMUM) {
    return null;
  }

  // We pick from the set of presets in the panel but do not want to allow
  // the "No Throttling" option to be picked.
  const presets = THROTTLING_CONDITIONS_LOOKUP.values()
                      .filter(condition => {
                        return condition !== NoThrottlingConditions;
                      })
                      .toArray();

  let closestPreset: Conditions|null = null;
  let smallestDiff = Infinity;
  for (const preset of presets) {
    const {targetLatency} = preset;
    if (!targetLatency) {
      continue;
    }

    const diff = Math.abs(targetLatency - rtt);
    if (diff > RTT_COMPARISON_THRESHOLD) {
      continue;
    }

    if (smallestDiff < diff) {
      continue;
    }

    closestPreset = preset;
    smallestDiff = diff;
  }

  return closestPreset;
}
