/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  CommonUtils: "resource://services-common/utils.sys.mjs",
  EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",

  ChannelEventSinkFactory:
    "chrome://remote/content/cdp/observers/ChannelEventSink.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "gActivityDistributor",
  "@mozilla.org/network/http-activity-distributor;1",
  "nsIHttpActivityDistributor"
);

const CC = Components.Constructor;

ChromeUtils.defineLazyGetter(lazy, "BinaryInputStream", () => {
  return CC(
    "@mozilla.org/binaryinputstream;1",
    "nsIBinaryInputStream",
    "setInputStream"
  );
});

ChromeUtils.defineLazyGetter(lazy, "BinaryOutputStream", () => {
  return CC(
    "@mozilla.org/binaryoutputstream;1",
    "nsIBinaryOutputStream",
    "setOutputStream"
  );
});

ChromeUtils.defineLazyGetter(lazy, "StorageStream", () => {
  return CC("@mozilla.org/storagestream;1", "nsIStorageStream", "init");
});

// Cap response storage with 100Mb per tracked tab.
const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024;

export class NetworkObserver {
  constructor() {
    lazy.EventEmitter.decorate(this);
    this._browserSessionCount = new Map();
    lazy.gActivityDistributor.addObserver(this);
    lazy.ChannelEventSinkFactory.getService().registerCollector(this);

    this._redirectMap = new Map();

    // Request interception state.
    this._browserSuspendedChannels = new Map();
    this._extraHTTPHeaders = new Map();
    this._browserResponseStorages = new Map();

    this._onRequest = this._onRequest.bind(this);
    this._onExamineResponse = this._onResponse.bind(
      this,
      false /* fromCache */
    );
    this._onCachedResponse = this._onResponse.bind(this, true /* fromCache */);
  }

  dispose() {
    lazy.gActivityDistributor.removeObserver(this);
    lazy.ChannelEventSinkFactory.getService().unregisterCollector(this);

    Services.obs.removeObserver(this._onRequest, "http-on-modify-request");
    Services.obs.removeObserver(
      this._onExamineResponse,
      "http-on-examine-response"
    );
    Services.obs.removeObserver(
      this._onCachedResponse,
      "http-on-examine-cached-response"
    );
    Services.obs.removeObserver(
      this._onCachedResponse,
      "http-on-examine-merged-response"
    );
  }

  setExtraHTTPHeaders(browser, headers) {
    if (!headers) {
      this._extraHTTPHeaders.delete(browser);
    } else {
      this._extraHTTPHeaders.set(browser, headers);
    }
  }

  enableRequestInterception(browser) {
    if (!this._browserSuspendedChannels.has(browser)) {
      this._browserSuspendedChannels.set(browser, new Map());
    }
  }

  disableRequestInterception(browser) {
    const suspendedChannels = this._browserSuspendedChannels.get(browser);
    if (!suspendedChannels) {
      return;
    }
    this._browserSuspendedChannels.delete(browser);
    for (const channel of suspendedChannels.values()) {
      channel.resume();
    }
  }

  resumeSuspendedRequest(browser, requestId, headers) {
    const suspendedChannels = this._browserSuspendedChannels.get(browser);
    if (!suspendedChannels) {
      throw new Error(`Request interception is not enabled`);
    }
    const httpChannel = suspendedChannels.get(requestId);
    if (!httpChannel) {
      throw new Error(`Cannot find request "${requestId}"`);
    }
    if (headers) {
      // 1. Clear all previous headers.
      for (const header of requestHeaders(httpChannel)) {
        httpChannel.setRequestHeader(header.name, "", false /* merge */);
      }
      // 2. Set new headers.
      for (const header of headers) {
        httpChannel.setRequestHeader(
          header.name,
          header.value,
          false /* merge */
        );
      }
    }
    suspendedChannels.delete(requestId);
    httpChannel.resume();
  }

  getResponseBody(browser, requestId) {
    const responseStorage = this._browserResponseStorages.get(browser);
    if (!responseStorage) {
      throw new Error("Responses are not tracked for the given browser");
    }
    return responseStorage.getBase64EncodedResponse(requestId);
  }

  abortSuspendedRequest(browser, aRequestId) {
    const suspendedChannels = this._browserSuspendedChannels.get(browser);
    if (!suspendedChannels) {
      throw new Error(`Request interception is not enabled`);
    }
    const httpChannel = suspendedChannels.get(aRequestId);
    if (!httpChannel) {
      throw new Error(`Cannot find request "${aRequestId}"`);
    }
    suspendedChannels.delete(aRequestId);
    httpChannel.cancel(Cr.NS_ERROR_FAILURE);
    httpChannel.resume();
    this.emit("requestfailed", httpChannel, {
      requestId: requestId(httpChannel),
      errorCode: getNetworkErrorStatusText(httpChannel.status),
    });
  }

  _onChannelRedirect(oldChannel, newChannel) {
    // We can be called with any nsIChannel, but are interested only in HTTP channels
    try {
      oldChannel.QueryInterface(Ci.nsIHttpChannel);
      newChannel.QueryInterface(Ci.nsIHttpChannel);
    } catch (ex) {
      return;
    }

    const httpChannel = oldChannel.QueryInterface(Ci.nsIHttpChannel);
    const loadContext = getLoadContext(httpChannel);
    if (
      !loadContext ||
      !this._browserSessionCount.has(loadContext.topFrameElement)
    ) {
      return;
    }
    this._redirectMap.set(newChannel, oldChannel);
  }

  _onRequest(channel) {
    const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel);
    const loadContext = getLoadContext(httpChannel);
    const browser = loadContext?.topFrameElement;
    if (!loadContext || !this._browserSessionCount.has(browser)) {
      return;
    }

    const extraHeaders = this._extraHTTPHeaders.get(browser);
    if (extraHeaders) {
      for (const header of extraHeaders) {
        httpChannel.setRequestHeader(
          header.name,
          header.value,
          false /* merge */
        );
      }
    }
    const causeType = httpChannel.loadInfo
      ? httpChannel.loadInfo.externalContentPolicyType
      : Ci.nsIContentPolicy.TYPE_OTHER;

    const suspendedChannels = this._browserSuspendedChannels.get(browser);
    if (suspendedChannels) {
      httpChannel.suspend();
      suspendedChannels.set(requestId(httpChannel), httpChannel);
    }

    const oldChannel = this._redirectMap.get(httpChannel);
    this._redirectMap.delete(httpChannel);

    // Install response body hooks.
    new ResponseBodyListener(this, browser, httpChannel);

    this.emit("request", httpChannel, {
      url: httpChannel.URI.spec,
      suspended: suspendedChannels ? true : undefined,
      requestId: requestId(httpChannel),
      redirectedFrom: oldChannel ? requestId(oldChannel) : undefined,
      postData: readRequestPostData(httpChannel),
      headers: requestHeaders(httpChannel),
      method: httpChannel.requestMethod,
      isNavigationRequest: httpChannel.isMainDocumentChannel,
      cause: causeType,
      causeString: causeTypeToString(causeType),
      frameId: this.frameId(httpChannel),
      // clients expect loaderId == requestId for document navigation
      loaderId: [
        Ci.nsIContentPolicy.TYPE_DOCUMENT,
        Ci.nsIContentPolicy.TYPE_SUBDOCUMENT,
      ].includes(causeType)
        ? requestId(httpChannel)
        : undefined,
    });
  }

  _onResponse(fromCache, httpChannel) {
    const loadContext = getLoadContext(httpChannel);
    if (
      !loadContext ||
      !this._browserSessionCount.has(loadContext.topFrameElement)
    ) {
      return;
    }
    httpChannel.QueryInterface(Ci.nsIHttpChannelInternal);
    const causeType = httpChannel.loadInfo
      ? httpChannel.loadInfo.externalContentPolicyType
      : Ci.nsIContentPolicy.TYPE_OTHER;
    let remoteIPAddress;
    let remotePort;
    try {
      remoteIPAddress = httpChannel.remoteAddress;
      remotePort = httpChannel.remotePort;
    } catch (e) {
      // remoteAddress is not defined for cached requests.
    }

    this.emit("response", httpChannel, {
      requestId: requestId(httpChannel),
      securityDetails: getSecurityDetails(httpChannel),
      fromCache,
      headers: responseHeaders(httpChannel),
      requestHeaders: requestHeaders(httpChannel),
      remoteIPAddress,
      remotePort,
      status: httpChannel.responseStatus,
      statusText: httpChannel.responseStatusText,
      cause: causeType,
      causeString: causeTypeToString(causeType),
      frameId: this.frameId(httpChannel),
      // clients expect loaderId == requestId for document navigation
      loaderId: [
        Ci.nsIContentPolicy.TYPE_DOCUMENT,
        Ci.nsIContentPolicy.TYPE_SUBDOCUMENT,
      ].includes(causeType)
        ? requestId(httpChannel)
        : undefined,
    });
  }

  _onResponseFinished(browser, httpChannel, body) {
    const responseStorage = this._browserResponseStorages.get(browser);
    if (!responseStorage) {
      return;
    }
    responseStorage.addResponseBody(httpChannel, body);
    this.emit("requestfinished", httpChannel, {
      requestId: requestId(httpChannel),
      errorCode: getNetworkErrorStatusText(httpChannel.status),
    });
  }

  isActive(browser) {
    return !!this._browserSessionCount.get(browser);
  }

  startTrackingBrowserNetwork(browser) {
    const value = this._browserSessionCount.get(browser) || 0;
    this._browserSessionCount.set(browser, value + 1);
    if (value === 0) {
      Services.obs.addObserver(this._onRequest, "http-on-modify-request");
      Services.obs.addObserver(
        this._onExamineResponse,
        "http-on-examine-response"
      );
      Services.obs.addObserver(
        this._onCachedResponse,
        "http-on-examine-cached-response"
      );
      Services.obs.addObserver(
        this._onCachedResponse,
        "http-on-examine-merged-response"
      );
      this._browserResponseStorages.set(
        browser,
        new ResponseStorage(
          MAX_RESPONSE_STORAGE_SIZE,
          MAX_RESPONSE_STORAGE_SIZE / 10
        )
      );
    }
    return () => this.stopTrackingBrowserNetwork(browser);
  }

  stopTrackingBrowserNetwork(browser) {
    const value = this._browserSessionCount.get(browser);
    if (value) {
      this._browserSessionCount.set(browser, value - 1);
    } else {
      this._browserSessionCount.delete(browser);
      this._browserResponseStorages.delete(browser);
      this.dispose();
    }
  }

  /**
   * Returns the frameId of the current httpChannel.
   */
  frameId(httpChannel) {
    const loadInfo = httpChannel.loadInfo;
    return loadInfo.frameBrowsingContext?.id || loadInfo.browsingContext.id;
  }
}

const protocolVersionNames = {
  [Ci.nsITransportSecurityInfo.TLS_VERSION_1]: "TLS 1",
  [Ci.nsITransportSecurityInfo.TLS_VERSION_1_1]: "TLS 1.1",
  [Ci.nsITransportSecurityInfo.TLS_VERSION_1_2]: "TLS 1.2",
  [Ci.nsITransportSecurityInfo.TLS_VERSION_1_3]: "TLS 1.3",
};

function getSecurityDetails(httpChannel) {
  const securityInfo = httpChannel.securityInfo;
  if (!securityInfo) {
    return null;
  }
  if (!securityInfo.serverCert) {
    return null;
  }
  return {
    protocol: protocolVersionNames[securityInfo.protocolVersion] || "<unknown>",
    subjectName: securityInfo.serverCert.commonName,
    issuer: securityInfo.serverCert.issuerCommonName,
    // Convert to seconds.
    validFrom: securityInfo.serverCert.validity.notBefore / 1000 / 1000,
    validTo: securityInfo.serverCert.validity.notAfter / 1000 / 1000,
  };
}

function readRequestPostData(httpChannel) {
  if (!(httpChannel instanceof Ci.nsIUploadChannel)) {
    return undefined;
  }
  const iStream = httpChannel.uploadStream;
  if (!iStream) {
    return undefined;
  }
  const isSeekableStream = iStream instanceof Ci.nsISeekableStream;

  let prevOffset;
  if (isSeekableStream) {
    prevOffset = iStream.tell();
    iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
  }

  // Read data from the stream.
  let text;
  try {
    text = lazy.NetUtil.readInputStreamToString(iStream, iStream.available());
    const converter = Cc[
      "@mozilla.org/intl/scriptableunicodeconverter"
    ].createInstance(Ci.nsIScriptableUnicodeConverter);
    converter.charset = "UTF-8";
    text = converter.ConvertToUnicode(text);
  } catch (err) {
    text = undefined;
  }

  // Seek locks the file, so seek to the beginning only if necko hasn"t
  // read it yet, since necko doesn"t seek to 0 before reading (at lest
  // not till 459384 is fixed).
  if (isSeekableStream && prevOffset == 0) {
    iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
  }
  return text;
}

function getLoadContext(httpChannel) {
  let loadContext = null;
  try {
    if (httpChannel.notificationCallbacks) {
      loadContext = httpChannel.notificationCallbacks.getInterface(
        Ci.nsILoadContext
      );
    }
  } catch (e) {}
  try {
    if (!loadContext && httpChannel.loadGroup) {
      loadContext = httpChannel.loadGroup.notificationCallbacks.getInterface(
        Ci.nsILoadContext
      );
    }
  } catch (e) {}
  return loadContext;
}

function requestId(httpChannel) {
  return String(httpChannel.channelId);
}

function requestHeaders(httpChannel) {
  const headers = [];
  httpChannel.visitRequestHeaders({
    visitHeader: (name, value) => headers.push({ name, value }),
  });
  return headers;
}

function responseHeaders(httpChannel) {
  const headers = [];
  httpChannel.visitResponseHeaders({
    visitHeader: (name, value) => headers.push({ name, value }),
  });
  return headers;
}

function causeTypeToString(causeType) {
  for (let key in Ci.nsIContentPolicy) {
    if (Ci.nsIContentPolicy[key] === causeType) {
      return key;
    }
  }
  return "TYPE_OTHER";
}

class ResponseStorage {
  constructor(maxTotalSize, maxResponseSize) {
    this._totalSize = 0;
    this._maxResponseSize = maxResponseSize;
    this._maxTotalSize = maxTotalSize;
    this._responses = new Map();
  }

  addResponseBody(httpChannel, body) {
    if (body.length > this._maxResponseSize) {
      this._responses.set(requestId, {
        evicted: true,
        body: "",
      });
      return;
    }
    let encodings = [];
    if (
      httpChannel instanceof Ci.nsIEncodedChannel &&
      httpChannel.contentEncodings &&
      !httpChannel.applyConversion
    ) {
      const encodingHeader = httpChannel.getResponseHeader("Content-Encoding");
      encodings = encodingHeader.split(/\s*\t*,\s*\t*/);
    }
    this._responses.set(requestId(httpChannel), { body, encodings });
    this._totalSize += body.length;
    if (this._totalSize > this._maxTotalSize) {
      for (let [, response] of this._responses) {
        this._totalSize -= response.body.length;
        response.body = "";
        response.evicted = true;
        if (this._totalSize < this._maxTotalSize) {
          break;
        }
      }
    }
  }

  getBase64EncodedResponse(requestId) {
    const response = this._responses.get(requestId);
    if (!response) {
      throw new Error(`Request "${requestId}" is not found`);
    }
    if (response.evicted) {
      return { base64body: "", evicted: true };
    }
    let result = response.body;
    if (response.encodings && response.encodings.length) {
      for (const encoding of response.encodings) {
        result = lazy.CommonUtils.convertString(
          result,
          encoding,
          "uncompressed"
        );
      }
    }
    return { base64body: btoa(result) };
  }
}

class ResponseBodyListener {
  constructor(networkObserver, browser, httpChannel) {
    this._networkObserver = networkObserver;
    this._browser = browser;
    this._httpChannel = httpChannel;
    this._chunks = [];
    this.QueryInterface = ChromeUtils.generateQI(["nsIStreamListener"]);
    httpChannel.QueryInterface(Ci.nsITraceableChannel);
    this.originalListener = httpChannel.setNewListener(this);
  }

  onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
    const iStream = new lazy.BinaryInputStream(aInputStream);
    const sStream = new lazy.StorageStream(8192, aCount, null);
    const oStream = new lazy.BinaryOutputStream(sStream.getOutputStream(0));

    // Copy received data as they come.
    const data = iStream.readBytes(aCount);
    this._chunks.push(data);

    oStream.writeBytes(data, aCount);
    this.originalListener.onDataAvailable(
      aRequest,
      sStream.newInputStream(0),
      aOffset,
      aCount
    );
  }

  onStartRequest(aRequest) {
    this.originalListener.onStartRequest(aRequest);
  }

  onStopRequest(aRequest, aStatusCode) {
    this.originalListener.onStopRequest(aRequest, aStatusCode);
    const body = this._chunks.join("");
    delete this._chunks;
    this._networkObserver._onResponseFinished(
      this._browser,
      this._httpChannel,
      body
    );
  }
}

function getNetworkErrorStatusText(status) {
  if (!status) {
    return null;
  }
  for (const key of Object.keys(Cr)) {
    if (Cr[key] === status) {
      return key;
    }
  }
  // Security module. The following is taken from
  // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/How_to_check_the_secruity_state_of_an_XMLHTTPRequest_over_SSL
  if ((status & 0xff0000) === 0x5a0000) {
    // NSS_SEC errors (happen below the base value because of negative vals)
    if (
      (status & 0xffff) <
      Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE)
    ) {
      // The bases are actually negative, so in our positive numeric space, we
      // need to subtract the base off our value.
      const nssErr =
        Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff);
      switch (nssErr) {
        case 11:
          return "SEC_ERROR_EXPIRED_CERTIFICATE";
        case 12:
          return "SEC_ERROR_REVOKED_CERTIFICATE";
        case 13:
          return "SEC_ERROR_UNKNOWN_ISSUER";
        case 20:
          return "SEC_ERROR_UNTRUSTED_ISSUER";
        case 21:
          return "SEC_ERROR_UNTRUSTED_CERT";
        case 36:
          return "SEC_ERROR_CA_CERT_INVALID";
        case 90:
          return "SEC_ERROR_INADEQUATE_KEY_USAGE";
        case 176:
          return "SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED";
        default:
          return "SEC_ERROR_UNKNOWN";
      }
    }
    const sslErr =
      Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff);
    switch (sslErr) {
      case 3:
        return "SSL_ERROR_NO_CERTIFICATE";
      case 4:
        return "SSL_ERROR_BAD_CERTIFICATE";
      case 8:
        return "SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE";
      case 9:
        return "SSL_ERROR_UNSUPPORTED_VERSION";
      case 12:
        return "SSL_ERROR_BAD_CERT_DOMAIN";
      default:
        return "SSL_ERROR_UNKNOWN";
    }
  }
  return "<unknown error>";
}
