"use strict";

const HTTP_STATUS_CODES = require("http").STATUS_CODES;
const { spawnSync } = require("child_process");
const { URL } = require("whatwg-url");
const whatwgEncoding = require("whatwg-encoding");
const tough = require("tough-cookie");
const MIMEType = require("whatwg-mimetype");
const conversions = require("webidl-conversions");

const xhrUtils = require("./xhr-utils");
const DOMException = require("domexception");
const xhrSymbols = require("./xmlhttprequest-symbols");
const { addConstants } = require("../utils");
const { documentBaseURLSerialized } = require("./helpers/document-base-url");
const { asciiCaseInsensitiveMatch } = require("./helpers/strings");
const idlUtils = require("./generated/utils");
const Document = require("./generated/Document");
const Blob = require("./generated/Blob");
const FormData = require("./generated/FormData");
const XMLHttpRequestEventTarget = require("./generated/XMLHttpRequestEventTarget");
const XMLHttpRequestUpload = require("./generated/XMLHttpRequestUpload");
const ProgressEvent = require("./generated/ProgressEvent");
const { parseIntoDocument } = require("../browser/parser");
const { fragmentSerialization } = require("./domparsing/serialization");
const { setupForSimpleEventAccessors } = require("./helpers/create-event-accessor");
const { parseJSONFromBytes } = require("./helpers/json");
const { fireAnEvent } = require("./helpers/events");

const syncWorkerFile = require.resolve ? require.resolve("./xhr-sync-worker.js") : null;

const tokenRegexp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
const fieldValueRegexp = /^[ \t]*(?:[\x21-\x7E\x80-\xFF](?:[ \t][\x21-\x7E\x80-\xFF])?)*[ \t]*$/;

const forbiddenRequestHeaders = new Set([
  "accept-charset",
  "accept-encoding",
  "access-control-request-headers",
  "access-control-request-method",
  "connection",
  "content-length",
  "cookie",
  "cookie2",
  "date",
  "dnt",
  "expect",
  "host",
  "keep-alive",
  "origin",
  "referer",
  "te",
  "trailer",
  "transfer-encoding",
  "upgrade",
  "via"
]);
const forbiddenResponseHeaders = new Set([
  "set-cookie",
  "set-cookie2"
]);
const uniqueResponseHeaders = new Set([
  "content-type",
  "content-length",
  "user-agent",
  "referer",
  "host",
  "authorization",
  "proxy-authorization",
  "if-modified-since",
  "if-unmodified-since",
  "from",
  "location",
  "max-forwards"
]);
const corsSafeResponseHeaders = new Set([
  "cache-control",
  "content-language",
  "content-type",
  "expires",
  "last-modified",
  "pragma"
]);


const allowedRequestMethods = new Set(["OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE"]);
const forbiddenRequestMethods = new Set(["TRACK", "TRACE", "CONNECT"]);

const XMLHttpRequestResponseType = new Set([
  "",
  "arraybuffer",
  "blob",
  "document",
  "json",
  "text"
]);

module.exports = function createXMLHttpRequest(window) {
  class XMLHttpRequest extends XMLHttpRequestEventTarget.interface {
    constructor() { // eslint-disable-line constructor-super
      const theThis = Object.create(new.target.prototype);
      XMLHttpRequestEventTarget.setup(theThis);
      theThis.upload = XMLHttpRequestUpload.create();
      theThis.upload._ownerDocument = window.document;

      theThis[xhrSymbols.flag] = {
        synchronous: false,
        withCredentials: false,
        mimeType: null,
        auth: null,
        method: undefined,
        responseType: "",
        requestHeaders: {},
        referrer: theThis._ownerDocument.URL,
        uri: "",
        timeout: 0,
        body: undefined,
        formData: false,
        preflight: false,
        requestManager: theThis._ownerDocument._requestManager,
        strictSSL: window._resourceLoader._strictSSL,
        proxy: window._resourceLoader._proxy,
        cookieJar: theThis._ownerDocument._cookieJar,
        encoding: theThis._ownerDocument._encoding,
        origin: theThis._ownerDocument.origin,
        userAgent: window.navigator.userAgent
      };

      theThis[xhrSymbols.properties] = {
        beforeSend: false,
        send: false,
        timeoutStart: 0,
        timeoutId: 0,
        timeoutFn: null,
        client: null,
        responseHeaders: {},
        filteredResponseHeaders: [],
        responseBuffer: null,
        responseCache: null,
        responseTextCache: null,
        responseXMLCache: null,
        responseURL: "",
        readyState: XMLHttpRequest.UNSENT,
        status: 0,
        statusText: "",
        error: "",
        uploadComplete: false,
        uploadListener: false,

        // Signifies that we're calling abort() from xhr-utils.js because of a window shutdown.
        // In that case the termination reason is "fatal", not "end-user abort".
        abortError: false,

        cookieJar: theThis._ownerDocument._cookieJar,
        bufferStepSize: 1 * 1024 * 1024, // pre-allocate buffer increase step size. init value is 1MB
        totalReceivedChunkSize: 0
      };

      return theThis;
    }

    get readyState() {
      return this[xhrSymbols.properties].readyState;
    }
    get status() {
      return this[xhrSymbols.properties].status;
    }
    get statusText() {
      return this[xhrSymbols.properties].statusText;
    }
    get responseType() {
      return this[xhrSymbols.flag].responseType;
    }
    set responseType(responseType) {
      const flag = this[xhrSymbols.flag];
      if (this.readyState === XMLHttpRequest.LOADING || this.readyState === XMLHttpRequest.DONE) {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }
      if (this.readyState === XMLHttpRequest.OPENED && flag.synchronous) {
        throw new DOMException("The object does not support the operation or argument.", "InvalidAccessError");
      }
      if (!XMLHttpRequestResponseType.has(responseType)) {
        responseType = "";
      }
      flag.responseType = responseType;
    }
    get response() {
      const properties = this[xhrSymbols.properties];
      if (properties.responseCache) {
        return properties.responseCache;
      }
      let res = "";

      const responseBuffer = properties.responseBuffer ?
                             properties.responseBuffer.slice(0, properties.totalReceivedChunkSize) :
                             null;

      switch (this.responseType) {
        case "":
        case "text": {
          res = this.responseText;
          break;
        }
        case "arraybuffer": {
          if (!responseBuffer) {
            return null;
          }
          res = (new Uint8Array(responseBuffer)).buffer;
          break;
        }
        case "blob": {
          if (!responseBuffer) {
            return null;
          }
          const contentType = finalMIMEType(this);
          res = Blob.create([
            [new Uint8Array(responseBuffer)],
            { type: contentType || "" }
          ]);
          break;
        }
        case "document": {
          res = this.responseXML;
          break;
        }
        case "json": {
          if (this.readyState !== XMLHttpRequest.DONE || !responseBuffer) {
            res = null;
          }

          try {
            res = parseJSONFromBytes(responseBuffer);
          } catch (e) {
            res = null;
          }
          break;
        }
      }
      properties.responseCache = res;
      return res;
    }
    get responseText() {
      const properties = this[xhrSymbols.properties];
      if (this.responseType !== "" && this.responseType !== "text") {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }
      if (this.readyState !== XMLHttpRequest.LOADING && this.readyState !== XMLHttpRequest.DONE) {
        return "";
      }
      if (properties.responseTextCache) {
        return properties.responseTextCache;
      }
      const responseBuffer = properties.responseBuffer ?
                             properties.responseBuffer.slice(0, properties.totalReceivedChunkSize) :
                             null;

      if (!responseBuffer) {
        return "";
      }

      const fallbackEncoding = finalCharset(this) || whatwgEncoding.getBOMEncoding(responseBuffer) || "UTF-8";
      const res = whatwgEncoding.decode(responseBuffer, fallbackEncoding);

      properties.responseTextCache = res;
      return res;
    }
    get responseXML() {
      const flag = this[xhrSymbols.flag];
      const properties = this[xhrSymbols.properties];
      if (this.responseType !== "" && this.responseType !== "document") {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }
      if (this.readyState !== XMLHttpRequest.DONE) {
        return null;
      }
      if (properties.responseXMLCache) {
        return properties.responseXMLCache;
      }
      const responseBuffer = properties.responseBuffer ?
                             properties.responseBuffer.slice(0, properties.totalReceivedChunkSize) :
                             null;

      if (!responseBuffer) {
        return null;
      }

      const contentType = finalMIMEType(this);
      let isHTML = false;
      let isXML = false;
      const parsed = MIMEType.parse(contentType);
      if (parsed) {
        isHTML = parsed.isHTML();
        isXML = parsed.isXML();
        if (!isXML && !isHTML) {
          return null;
        }
      }

      if (this.responseType === "" && isHTML) {
        return null;
      }

      const encoding = finalCharset(this) || whatwgEncoding.getBOMEncoding(responseBuffer) || "UTF-8";
      const resText = whatwgEncoding.decode(responseBuffer, encoding);

      if (!resText) {
        return null;
      }
      const res = Document.create([], { options: {
        url: flag.uri,
        lastModified: new Date(getResponseHeader(this, "last-modified")),
        parsingMode: isHTML ? "html" : "xml",
        cookieJar: { setCookieSync: () => undefined, getCookieStringSync: () => "" },
        encoding,
        parseOptions: this._ownerDocument._parseOptions
      } });
      const resImpl = idlUtils.implForWrapper(res);
      try {
        parseIntoDocument(resText, resImpl);
      } catch (e) {
        properties.responseXMLCache = null;
        return null;
      }
      res.close();
      properties.responseXMLCache = res;
      return res;
    }

    get responseURL() {
      return this[xhrSymbols.properties].responseURL;
    }

    get timeout() {
      return this[xhrSymbols.flag].timeout;
    }
    set timeout(val) {
      const flag = this[xhrSymbols.flag];
      const properties = this[xhrSymbols.properties];
      if (flag.synchronous) {
        throw new DOMException("The object does not support the operation or argument.", "InvalidAccessError");
      }
      flag.timeout = val;
      clearTimeout(properties.timeoutId);
      if (val > 0 && properties.timeoutFn) {
        properties.timeoutId = setTimeout(
          properties.timeoutFn,
          Math.max(0, val - ((new Date()).getTime() - properties.timeoutStart))
        );
      } else {
        properties.timeoutFn = null;
        properties.timeoutStart = 0;
      }
    }
    get withCredentials() {
      return this[xhrSymbols.flag].withCredentials;
    }
    set withCredentials(val) {
      const flag = this[xhrSymbols.flag];
      const properties = this[xhrSymbols.properties];
      if (!(this.readyState === XMLHttpRequest.UNSENT || this.readyState === XMLHttpRequest.OPENED)) {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }
      if (properties.send) {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }
      flag.withCredentials = val;
    }

    abort() {
      const properties = this[xhrSymbols.properties];

      // Terminate the request
      clearTimeout(properties.timeoutId);
      properties.timeoutFn = null;
      properties.timeoutStart = 0;

      const { client } = properties;
      if (client) {
        client.abort();
        properties.client = null;
      }

      if (properties.abortError) {
        // Special case that ideally shouldn't be going through the public API at all.
        // Run the https://xhr.spec.whatwg.org/#handle-errors "fatal" steps.
        properties.readyState = XMLHttpRequest.DONE;
        properties.send = false;
        xhrUtils.setResponseToNetworkError(this);
        return;
      }

      if ((this.readyState === XMLHttpRequest.OPENED && properties.send) ||
          this.readyState === XMLHttpRequest.HEADERS_RECEIVED ||
          this.readyState === XMLHttpRequest.LOADING) {
        xhrUtils.requestErrorSteps(this, "abort");
      }

      if (this.readyState === XMLHttpRequest.DONE) {
        properties.readyState = XMLHttpRequest.UNSENT;

        xhrUtils.setResponseToNetworkError(this);
      }
    }
    getAllResponseHeaders() {
      const properties = this[xhrSymbols.properties];
      const { readyState } = this;
      if (readyState === XMLHttpRequest.UNSENT || readyState === XMLHttpRequest.OPENED) {
        return "";
      }
      return Object.keys(properties.responseHeaders)
        .filter(key => properties.filteredResponseHeaders.indexOf(key) === -1)
        .map(key => [conversions.ByteString(key).toLowerCase(), properties.responseHeaders[key]].join(": "))
        .join("\r\n");
    }

    getResponseHeader(header) {
      const properties = this[xhrSymbols.properties];
      const { readyState } = this;
      if (readyState === XMLHttpRequest.UNSENT || readyState === XMLHttpRequest.OPENED) {
        return null;
      }
      const lcHeader = conversions.ByteString(header).toLowerCase();
      if (properties.filteredResponseHeaders.find(filtered => lcHeader === filtered.toLowerCase())) {
        return null;
      }
      return getResponseHeader(this, lcHeader);
    }

    open(method, uri, asynchronous, user, password) {
      if (!this._ownerDocument) {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }
      const flag = this[xhrSymbols.flag];
      const properties = this[xhrSymbols.properties];
      const argumentCount = arguments.length;
      if (argumentCount < 2) {
        throw new TypeError("Not enough arguments (expected at least 2)");
      }

      method = conversions.ByteString(method);
      uri = conversions.USVString(uri);
      if (user) {
        user = conversions.USVString(user);
      }
      if (password) {
        password = conversions.USVString(password);
      }

      if (!tokenRegexp.test(method)) {
        throw new DOMException("The string did not match the expected pattern.", "SyntaxError");
      }
      const upperCaseMethod = method.toUpperCase();
      if (forbiddenRequestMethods.has(upperCaseMethod)) {
        throw new DOMException("The operation is insecure.", "SecurityError");
      }

      const { client } = properties;
      if (client && typeof client.abort === "function") {
        client.abort();
      }

      if (allowedRequestMethods.has(upperCaseMethod)) {
        method = upperCaseMethod;
      }
      if (typeof asynchronous !== "undefined") {
        flag.synchronous = !asynchronous;
      } else {
        flag.synchronous = false;
      }
      if (flag.responseType && flag.synchronous) {
        throw new DOMException("The object does not support the operation or argument.", "InvalidAccessError");
      }
      if (flag.synchronous && flag.timeout) {
        throw new DOMException("The object does not support the operation or argument.", "InvalidAccessError");
      }
      flag.method = method;

      let urlObj;
      try {
        urlObj = new URL(uri, documentBaseURLSerialized(this._ownerDocument));
      } catch (e) {
        throw new DOMException("The string did not match the expected pattern.", "SyntaxError");
      }

      if (user || (password && !urlObj.username)) {
        flag.auth = {
          user,
          pass: password
        };
        urlObj.username = "";
        urlObj.password = "";
      }

      flag.uri = urlObj.href;
      flag.requestHeaders = {};
      flag.preflight = false;

      properties.send = false;
      properties.uploadListener = false;
      properties.requestBuffer = null;
      properties.requestCache = null;
      properties.abortError = false;
      properties.responseURL = "";
      readyStateChange(this, XMLHttpRequest.OPENED);
    }

    overrideMimeType(mime) {
      mime = String(mime);

      const { readyState } = this;
      if (readyState === XMLHttpRequest.LOADING || readyState === XMLHttpRequest.DONE) {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }

      this[xhrSymbols.flag].overrideMIMEType = "application/octet-stream";

      // Waiting for better spec: https://github.com/whatwg/xhr/issues/157
      const parsed = MIMEType.parse(mime);
      if (parsed) {
        this[xhrSymbols.flag].overrideMIMEType = parsed.essence;

        const charset = parsed.parameters.get("charset");
        if (charset) {
          this[xhrSymbols.flag].overrideCharset = whatwgEncoding.labelToName(charset);
        }
      }
    }

    send(body) {
      body = coerceBodyArg(body);

      // Not per spec, but per tests: https://github.com/whatwg/xhr/issues/65
      if (!this._ownerDocument) {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }

      const flag = this[xhrSymbols.flag];
      const properties = this[xhrSymbols.properties];

      if (this.readyState !== XMLHttpRequest.OPENED || properties.send) {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }

      properties.beforeSend = true;

      try {
        if (flag.method === "GET" || flag.method === "HEAD") {
          body = null;
        }

        if (body !== null) {
          let encoding = null;
          let mimeType = null;

          if (Document.isImpl(body)) {
            encoding = "UTF-8";
            mimeType = (body._parsingMode === "html" ? "text/html" : "application/xml") + ";charset=UTF-8";
            flag.body = fragmentSerialization(body, { requireWellFormed: false });
          } else {
            if (typeof body === "string") {
              encoding = "UTF-8";
            }
            const { buffer, formData, contentType } = extractBody(body);
            mimeType = contentType;
            flag.body = buffer || formData;
            flag.formData = Boolean(formData);
          }

          const existingContentType = xhrUtils.getRequestHeader(flag.requestHeaders, "content-type");
          if (mimeType !== null && existingContentType === null) {
            flag.requestHeaders["Content-Type"] = mimeType;
          } else if (existingContentType !== null && encoding !== null) {
            // Waiting for better spec: https://github.com/whatwg/xhr/issues/188. This seems like a good guess at what
            // the spec will be, in the meantime.
            const parsed = MIMEType.parse(existingContentType);
            if (parsed) {
              const charset = parsed.parameters.get("charset");
              if (charset && !asciiCaseInsensitiveMatch(charset, encoding) && encoding !== null) {
                parsed.parameters.set("charset", encoding);
              }
              xhrUtils.updateRequestHeader(flag.requestHeaders, "content-type", parsed.toString());
            }
          }
        }
      } finally {
        if (properties.beforeSend) {
          properties.beforeSend = false;
        } else {
          throw new DOMException("The object is in an invalid state.", "InvalidStateError");
        }
      }

      if (Object.keys(idlUtils.implForWrapper(this.upload)._eventListeners).length > 0) {
        properties.uploadListener = true;
      }

      // request doesn't like zero-length bodies
      if (flag.body && flag.body.byteLength === 0) {
        flag.body = null;
      }

      if (flag.synchronous) {
        const flagStr = JSON.stringify(flag, function (k, v) {
          if (this === flag && k === "requestManager") {
            return null;
          }
          if (this === flag && k === "pool" && v) {
            return { maxSockets: v.maxSockets };
          }
          return v;
        });
        const res = spawnSync(
          process.execPath,
          [syncWorkerFile],
          { input: flagStr }
        );
        if (res.status !== 0) {
          throw new Error(res.stderr.toString());
        }
        if (res.error) {
          if (typeof res.error === "string") {
            res.error = new Error(res.error);
          }
          throw res.error;
        }

        const response = JSON.parse(res.stdout.toString());
        if (response.properties.responseBuffer && response.properties.responseBuffer.data) {
          response.properties.responseBuffer = Buffer.from(response.properties.responseBuffer.data);
        }
        if (response.properties.cookieJar) {
          response.properties.cookieJar = tough.CookieJar.deserializeSync(
            response.properties.cookieJar,
            this._ownerDocument._cookieJar.store
          );
        }

        response.properties.readyState = XMLHttpRequest.LOADING;
        this[xhrSymbols.properties] = response.properties;

        if (response.properties.error) {
          xhrUtils.dispatchError(this);
          throw new DOMException(response.properties.error, "NetworkError");
        } else {
          const { responseBuffer } = this[xhrSymbols.properties];
          const contentLength = getResponseHeader(this, "content-length") || "0";
          const bufferLength = parseInt(contentLength) || responseBuffer.length;
          const progressObj = { lengthComputable: false };
          if (bufferLength !== 0) {
            progressObj.total = bufferLength;
            progressObj.loaded = bufferLength;
            progressObj.lengthComputable = true;
          }
          fireAnEvent("progress", this, ProgressEvent, progressObj);
          readyStateChange(this, XMLHttpRequest.DONE);
          fireAnEvent("load", this, ProgressEvent, progressObj);
          fireAnEvent("loadend", this, ProgressEvent, progressObj);
        }
      } else {
        properties.send = true;

        fireAnEvent("loadstart", this, ProgressEvent);

        const client = xhrUtils.createClient(this);

        properties.client = client;
        // For new client, reset totalReceivedChunkSize and bufferStepSize
        properties.totalReceivedChunkSize = 0;
        properties.bufferStepSize = 1 * 1024 * 1024;

        properties.origin = flag.origin;

        client.on("error", err => {
          client.removeAllListeners();
          properties.error = err;
          xhrUtils.dispatchError(this);
        });

        client.on("response", res => receiveResponse(this, res));

        client.on("redirect", () => {
          const { response } = client;
          const destUrlObj = new URL(response.request.headers.Referer);

          const urlObj = new URL(response.request.uri.href);

          if (destUrlObj.origin !== urlObj.origin && destUrlObj.origin !== flag.origin) {
            properties.origin = "null";
          }

          response.request.headers.Origin = properties.origin;

          if (flag.origin !== destUrlObj.origin &&
              destUrlObj.protocol !== "data:") {
            if (!xhrUtils.validCORSHeaders(this, response, flag, properties, flag.origin)) {
              return;
            }
            if (urlObj.username || urlObj.password) {
              properties.error = "Userinfo forbidden in cors redirect";
              xhrUtils.dispatchError(this);
            }
          }
        });
        if (body !== null && body !== "") {
          properties.uploadComplete = false;
          setDispatchProgressEvents(this);
        } else {
          properties.uploadComplete = true;
        }
        if (this.timeout > 0) {
          properties.timeoutStart = (new Date()).getTime();
          properties.timeoutFn = () => {
            client.abort();
            if (!(this.readyState === XMLHttpRequest.UNSENT ||
                (this.readyState === XMLHttpRequest.OPENED && !properties.send) ||
                this.readyState === XMLHttpRequest.DONE)) {
              properties.send = false;
              let stateChanged = false;
              if (!properties.uploadComplete) {
                fireAnEvent("progress", this.upload, ProgressEvent);
                readyStateChange(this, XMLHttpRequest.DONE);
                fireAnEvent("timeout", this.upload, ProgressEvent);
                fireAnEvent("loadend", this.upload, ProgressEvent);
                stateChanged = true;
              }
              fireAnEvent("progress", this, ProgressEvent);
              if (!stateChanged) {
                readyStateChange(this, XMLHttpRequest.DONE);
              }
              fireAnEvent("timeout", this, ProgressEvent);
              fireAnEvent("loadend", this, ProgressEvent);
            }
            properties.readyState = XMLHttpRequest.UNSENT;
          };
          properties.timeoutId = setTimeout(properties.timeoutFn, this.timeout);
        }
      }
    }

    setRequestHeader(header, value) {
      const flag = this[xhrSymbols.flag];
      const properties = this[xhrSymbols.properties];

      if (arguments.length !== 2) {
        throw new TypeError("2 arguments required for setRequestHeader");
      }
      header = conversions.ByteString(header);
      value = conversions.ByteString(value);

      if (this.readyState !== XMLHttpRequest.OPENED || properties.send) {
        throw new DOMException("The object is in an invalid state.", "InvalidStateError");
      }

      value = normalizeHeaderValue(value);

      if (!tokenRegexp.test(header) || !fieldValueRegexp.test(value)) {
        throw new DOMException("The string did not match the expected pattern.", "SyntaxError");
      }

      const lcHeader = header.toLowerCase();

      if (forbiddenRequestHeaders.has(lcHeader) || lcHeader.startsWith("sec-") || lcHeader.startsWith("proxy-")) {
        return;
      }

      const keys = Object.keys(flag.requestHeaders);
      let n = keys.length;
      while (n--) {
        const key = keys[n];
        if (key.toLowerCase() === lcHeader) {
          flag.requestHeaders[key] += ", " + value;
          return;
        }
      }
      flag.requestHeaders[header] = value;
    }

    get _ownerDocument() {
      return idlUtils.implForWrapper(window.document);
    }
  }

  Object.defineProperty(XMLHttpRequest.prototype, Symbol.toStringTag, {
    value: "XMLHttpRequest",
    writable: false,
    enumerable: false,
    configurable: true
  });

  setupForSimpleEventAccessors(XMLHttpRequest.prototype, ["readystatechange"]);

  addConstants(XMLHttpRequest, {
    UNSENT: 0,
    OPENED: 1,
    HEADERS_RECEIVED: 2,
    LOADING: 3,
    DONE: 4
  });

  function readyStateChange(xhr, readyState) {
    const properties = xhr[xhrSymbols.properties];
    if (properties.readyState === readyState) {
      return;
    }

    properties.readyState = readyState;

    fireAnEvent("readystatechange", xhr);
  }

  function receiveResponse(xhr, response) {
    const properties = xhr[xhrSymbols.properties];
    const flag = xhr[xhrSymbols.flag];

    const { statusCode } = response;

    let byteOffset = 0;

    const headers = {};
    const filteredResponseHeaders = [];
    const headerMap = {};
    const { rawHeaders } = response;
    const n = Number(rawHeaders.length);
    for (let i = 0; i < n; i += 2) {
      const k = rawHeaders[i];
      const kl = k.toLowerCase();
      const v = rawHeaders[i + 1];
      if (uniqueResponseHeaders.has(kl)) {
        if (headerMap[kl] !== undefined) {
          delete headers[headerMap[kl]];
        }
        headers[k] = v;
      } else if (headerMap[kl] !== undefined) {
        headers[headerMap[kl]] += ", " + v;
      } else {
        headers[k] = v;
      }
      headerMap[kl] = k;
    }

    const destUrlObj = new URL(response.request.uri.href);
    if (properties.origin !== destUrlObj.origin &&
        destUrlObj.protocol !== "data:") {
      if (!xhrUtils.validCORSHeaders(xhr, response, flag, properties, properties.origin)) {
        return;
      }
      const acehStr = response.headers["access-control-expose-headers"];
      const aceh = new Set(acehStr ? acehStr.trim().toLowerCase().split(xhrUtils.headerListSeparatorRegexp) : []);
      for (const header in headers) {
        const lcHeader = header.toLowerCase();
        if (!corsSafeResponseHeaders.has(lcHeader) && !aceh.has(lcHeader)) {
          filteredResponseHeaders.push(header);
        }
      }
    }

    for (const header in headers) {
      const lcHeader = header.toLowerCase();
      if (forbiddenResponseHeaders.has(lcHeader)) {
        filteredResponseHeaders.push(header);
      }
    }

    properties.responseURL = destUrlObj.href;

    properties.status = statusCode;
    properties.statusText = response.statusMessage || HTTP_STATUS_CODES[statusCode] || "";

    properties.responseHeaders = headers;
    properties.filteredResponseHeaders = filteredResponseHeaders;

    const contentLength = getResponseHeader(xhr, "content-length") || "0";
    const bufferLength = parseInt(contentLength) || 0;
    const progressObj = { lengthComputable: false };
    let lastProgressReported;
    if (bufferLength !== 0) {
      progressObj.total = bufferLength;
      progressObj.loaded = 0;
      progressObj.lengthComputable = true;
    }
    // pre-allocate buffer.
    properties.responseBuffer = Buffer.alloc(properties.bufferStepSize);
    properties.responseCache = null;
    properties.responseTextCache = null;
    properties.responseXMLCache = null;
    readyStateChange(xhr, XMLHttpRequest.HEADERS_RECEIVED);

    if (!properties.client) {
      // The request was aborted in reaction to the readystatechange event.
      return;
    }

    // Can't use the client since the client gets the post-ungzipping bytes (which can be greater than the
    // Content-Length).
    response.on("data", chunk => {
      byteOffset += chunk.length;
      progressObj.loaded = byteOffset;
    });

    properties.client.on("data", chunk => {
      properties.totalReceivedChunkSize += chunk.length;
      if (properties.totalReceivedChunkSize >= properties.bufferStepSize) {
        properties.bufferStepSize *= 2;
        while (properties.totalReceivedChunkSize >= properties.bufferStepSize) {
          properties.bufferStepSize *= 2;
        }
        const tmpBuf = Buffer.alloc(properties.bufferStepSize);
        properties.responseBuffer.copy(tmpBuf, 0, 0, properties.responseBuffer.length);
        properties.responseBuffer = tmpBuf;
      }
      chunk.copy(properties.responseBuffer, properties.totalReceivedChunkSize - chunk.length, 0, chunk.length);
      properties.responseCache = null;
      properties.responseTextCache = null;
      properties.responseXMLCache = null;

      if (properties.readyState === XMLHttpRequest.HEADERS_RECEIVED) {
        properties.readyState = XMLHttpRequest.LOADING;
      }
      fireAnEvent("readystatechange", xhr);

      if (progressObj.total !== progressObj.loaded || properties.totalReceivedChunkSize === byteOffset) {
        if (lastProgressReported !== progressObj.loaded) {
          // This is a necessary check in the gzip case where we can be getting new data from the client, as it
          // un-gzips, but no new data has been gotten from the response, so we should not fire a progress event.
          lastProgressReported = progressObj.loaded;
          fireAnEvent("progress", xhr, ProgressEvent, progressObj);
        }
      }
    });
    properties.client.on("end", () => {
      clearTimeout(properties.timeoutId);
      properties.timeoutFn = null;
      properties.timeoutStart = 0;
      properties.client = null;
      fireAnEvent("progress", xhr, ProgressEvent, progressObj);
      readyStateChange(xhr, XMLHttpRequest.DONE);
      fireAnEvent("load", xhr, ProgressEvent, progressObj);
      fireAnEvent("loadend", xhr, ProgressEvent, progressObj);
    });
  }

  function setDispatchProgressEvents(xhr) {
    const properties = xhr[xhrSymbols.properties];
    const { client } = properties;
    const { upload } = xhr;

    let total = 0;
    let lengthComputable = false;
    const length = client.headers && parseInt(xhrUtils.getRequestHeader(client.headers, "content-length"));
    if (length) {
      total = length;
      lengthComputable = true;
    }
    const initProgress = {
      lengthComputable,
      total,
      loaded: 0
    };

    if (properties.uploadListener) {
      fireAnEvent("loadstart", upload, ProgressEvent, initProgress);
    }

    client.on("request", req => {
      req.on("response", () => {
        properties.uploadComplete = true;

        if (!properties.uploadListener) {
          return;
        }

        const progress = {
          lengthComputable,
          total,
          loaded: total
        };
        fireAnEvent("progress", upload, ProgressEvent, progress);
        fireAnEvent("load", upload, ProgressEvent, progress);
        fireAnEvent("loadend", upload, ProgressEvent, progress);
      });
    });
  }

  return XMLHttpRequest;
};

function finalMIMEType(xhr) {
  const flag = xhr[xhrSymbols.flag];
  return flag.overrideMIMEType || getResponseHeader(xhr, "content-type");
}

function finalCharset(xhr) {
  const flag = xhr[xhrSymbols.flag];
  if (flag.overrideCharset) {
    return flag.overrideCharset;
  }
  const parsedContentType = MIMEType.parse(getResponseHeader(xhr, "content-type"));
  if (parsedContentType) {
    return whatwgEncoding.labelToName(parsedContentType.parameters.get("charset"));
  }
  return null;
}

function getResponseHeader(xhr, lcHeader) {
  const properties = xhr[xhrSymbols.properties];
  const keys = Object.keys(properties.responseHeaders);
  let n = keys.length;
  while (n--) {
    const key = keys[n];
    if (key.toLowerCase() === lcHeader) {
      return properties.responseHeaders[key];
    }
  }
  return null;
}

function normalizeHeaderValue(value) {
  return value.replace(/^[\x09\x0A\x0D\x20]+/, "").replace(/[\x09\x0A\x0D\x20]+$/, "");
}

function coerceBodyArg(body) {
  // Implements the IDL conversion for `optional (Document or BodyInit)? body = null`

  if (body === undefined || body === null) {
    return null;
  }

  if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
    return body;
  }

  const impl = idlUtils.implForWrapper(body);
  if (impl) {
    // TODO: allow URLSearchParams or ReadableStream
    if (Blob.isImpl(impl) || FormData.isImpl(impl) || Document.isImpl(impl)) {
      return impl;
    }
  }

  return conversions.USVString(body);
}

function extractBody(bodyInit) {
  // https://fetch.spec.whatwg.org/#concept-bodyinit-extract
  // except we represent the body as a Node.js Buffer instead,
  // or a special case for FormData since we want request to handle that. Probably it would be
  // cleaner (and allow a future without request) if we did the form encoding ourself.

  if (Blob.isImpl(bodyInit)) {
    return {
      buffer: bodyInit._buffer,
      contentType: bodyInit.type === "" ? null : bodyInit.type
    };
  } else if (bodyInit instanceof ArrayBuffer) {
    return {
      buffer: Buffer.from(bodyInit),
      contentType: null
    };
  } else if (ArrayBuffer.isView(bodyInit)) {
    return {
      buffer: Buffer.from(bodyInit.buffer, bodyInit.byteOffset, bodyInit.byteLength),
      contentType: null
    };
  } else if (FormData.isImpl(bodyInit)) {
    const formData = [];
    for (const entry of bodyInit._entries) {
      let val;
      if (Blob.isImpl(entry.value)) {
        const blob = entry.value;
        val = {
          name: entry.name,
          value: blob._buffer,
          options: {
            filename: blob.name,
            contentType: blob.type,
            knownLength: blob.size
          }
        };
      } else {
        val = entry;
      }

      formData.push(val);
    }

    return { formData };
  }

  // Must be a string
  return {
    buffer: Buffer.from(bodyInit, "utf-8"),
    contentType: "text/plain;charset=UTF-8"
  };
}