// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.

((window) => {
  const core = window.Deno.core;

  // provided by "deno_web"
  const { URLSearchParams } = window.__bootstrap.url;

  const { requiredArguments } = window.__bootstrap.fetchUtil;
  const { ReadableStream, isReadableStreamDisturbed } =
    window.__bootstrap.streams;
  const { DomIterableMixin } = window.__bootstrap.domIterable;
  const { Headers } = window.__bootstrap.headers;

  // FIXME(bartlomieju): stubbed out, needed in blob
  const build = {
    os: "",
  };

  const MAX_SIZE = 2 ** 32 - 2;

  // `off` is the offset into `dst` where it will at which to begin writing values
  // from `src`.
  // Returns the number of bytes copied.
  function copyBytes(src, dst, off = 0) {
    const r = dst.byteLength - off;
    if (src.byteLength > r) {
      src = src.subarray(0, r);
    }
    dst.set(src, off);
    return src.byteLength;
  }

  class Buffer {
    #buf = null; // contents are the bytes buf[off : len(buf)]
    #off = 0; // read at buf[off], write at buf[buf.byteLength]

    constructor(ab) {
      if (ab == null) {
        this.#buf = new Uint8Array(0);
        return;
      }

      this.#buf = new Uint8Array(ab);
    }

    bytes(options = { copy: true }) {
      if (options.copy === false) return this.#buf.subarray(this.#off);
      return this.#buf.slice(this.#off);
    }

    empty() {
      return this.#buf.byteLength <= this.#off;
    }

    get length() {
      return this.#buf.byteLength - this.#off;
    }

    get capacity() {
      return this.#buf.buffer.byteLength;
    }

    reset() {
      this.#reslice(0);
      this.#off = 0;
    }

    #tryGrowByReslice = (n) => {
      const l = this.#buf.byteLength;
      if (n <= this.capacity - l) {
        this.#reslice(l + n);
        return l;
      }
      return -1;
    };

    #reslice = (len) => {
      if (!(len <= this.#buf.buffer.byteLength)) {
        throw new Error("assert");
      }
      this.#buf = new Uint8Array(this.#buf.buffer, 0, len);
    };

    writeSync(p) {
      const m = this.#grow(p.byteLength);
      return copyBytes(p, this.#buf, m);
    }

    write(p) {
      const n = this.writeSync(p);
      return Promise.resolve(n);
    }

    #grow = (n) => {
      const m = this.length;
      // If buffer is empty, reset to recover space.
      if (m === 0 && this.#off !== 0) {
        this.reset();
      }
      // Fast: Try to grow by means of a reslice.
      const i = this.#tryGrowByReslice(n);
      if (i >= 0) {
        return i;
      }
      const c = this.capacity;
      if (n <= Math.floor(c / 2) - m) {
        // We can slide things down instead of allocating a new
        // ArrayBuffer. We only need m+n <= c to slide, but
        // we instead let capacity get twice as large so we
        // don't spend all our time copying.
        copyBytes(this.#buf.subarray(this.#off), this.#buf);
      } else if (c + n > MAX_SIZE) {
        throw new Error("The buffer cannot be grown beyond the maximum size.");
      } else {
        // Not enough space anywhere, we need to allocate.
        const buf = new Uint8Array(Math.min(2 * c + n, MAX_SIZE));
        copyBytes(this.#buf.subarray(this.#off), buf);
        this.#buf = buf;
      }
      // Restore this.#off and len(this.#buf).
      this.#off = 0;
      this.#reslice(Math.min(m + n, MAX_SIZE));
      return m;
    };

    grow(n) {
      if (n < 0) {
        throw Error("Buffer.grow: negative count");
      }
      const m = this.#grow(n);
      this.#reslice(m);
    }
  }

  function isTypedArray(x) {
    return ArrayBuffer.isView(x) && !(x instanceof DataView);
  }

  function hasHeaderValueOf(s, value) {
    return new RegExp(`^${value}[\t\s]*;?`).test(s);
  }

  function getHeaderValueParams(value) {
    const params = new Map();
    // Forced to do so for some Map constructor param mismatch
    value
      .split(";")
      .slice(1)
      .map((s) => s.trim().split("="))
      .filter((arr) => arr.length > 1)
      .map(([k, v]) => [k, v.replace(/^"([^"]*)"$/, "$1")])
      .forEach(([k, v]) => params.set(k, v));
    return params;
  }

  const decoder = new TextDecoder();
  const encoder = new TextEncoder();
  const CR = "\r".charCodeAt(0);
  const LF = "\n".charCodeAt(0);

  const dataSymbol = Symbol("data");
  const bytesSymbol = Symbol("bytes");

  function containsOnlyASCII(str) {
    if (typeof str !== "string") {
      return false;
    }
    // deno-lint-ignore no-control-regex
    return /^[\x00-\x7F]*$/.test(str);
  }

  function convertLineEndingsToNative(s) {
    const nativeLineEnd = build.os == "windows" ? "\r\n" : "\n";

    let position = 0;

    let collectionResult = collectSequenceNotCRLF(s, position);

    let token = collectionResult.collected;
    position = collectionResult.newPosition;

    let result = token;

    while (position < s.length) {
      const c = s.charAt(position);
      if (c == "\r") {
        result += nativeLineEnd;
        position++;
        if (position < s.length && s.charAt(position) == "\n") {
          position++;
        }
      } else if (c == "\n") {
        position++;
        result += nativeLineEnd;
      }

      collectionResult = collectSequenceNotCRLF(s, position);

      token = collectionResult.collected;
      position = collectionResult.newPosition;

      result += token;
    }

    return result;
  }

  function collectSequenceNotCRLF(
    s,
    position,
  ) {
    const start = position;
    for (
      let c = s.charAt(position);
      position < s.length && !(c == "\r" || c == "\n");
      c = s.charAt(++position)
    );
    return { collected: s.slice(start, position), newPosition: position };
  }

  function toUint8Arrays(
    blobParts,
    doNormalizeLineEndingsToNative,
  ) {
    const ret = [];
    const enc = new TextEncoder();
    for (const element of blobParts) {
      if (typeof element === "string") {
        let str = element;
        if (doNormalizeLineEndingsToNative) {
          str = convertLineEndingsToNative(element);
        }
        ret.push(enc.encode(str));
        // eslint-disable-next-line @typescript-eslint/no-use-before-define
      } else if (element instanceof Blob) {
        ret.push(element[bytesSymbol]);
      } else if (element instanceof Uint8Array) {
        ret.push(element);
      } else if (element instanceof Uint16Array) {
        const uint8 = new Uint8Array(element.buffer);
        ret.push(uint8);
      } else if (element instanceof Uint32Array) {
        const uint8 = new Uint8Array(element.buffer);
        ret.push(uint8);
      } else if (ArrayBuffer.isView(element)) {
        // Convert view to Uint8Array.
        const uint8 = new Uint8Array(element.buffer);
        ret.push(uint8);
      } else if (element instanceof ArrayBuffer) {
        // Create a new Uint8Array view for the given ArrayBuffer.
        const uint8 = new Uint8Array(element);
        ret.push(uint8);
      } else {
        ret.push(enc.encode(String(element)));
      }
    }
    return ret;
  }

  function processBlobParts(
    blobParts,
    options,
  ) {
    const normalizeLineEndingsToNative = options.ending === "native";
    // ArrayBuffer.transfer is not yet implemented in V8, so we just have to
    // pre compute size of the array buffer and do some sort of static allocation
    // instead of dynamic allocation.
    const uint8Arrays = toUint8Arrays(blobParts, normalizeLineEndingsToNative);
    const byteLength = uint8Arrays
      .map((u8) => u8.byteLength)
      .reduce((a, b) => a + b, 0);
    const ab = new ArrayBuffer(byteLength);
    const bytes = new Uint8Array(ab);
    let courser = 0;
    for (const u8 of uint8Arrays) {
      bytes.set(u8, courser);
      courser += u8.byteLength;
    }

    return bytes;
  }

  function getStream(blobBytes) {
    // TODO: Align to spec https://fetch.spec.whatwg.org/#concept-construct-readablestream
    return new ReadableStream({
      type: "bytes",
      start: (controller) => {
        controller.enqueue(blobBytes);
        controller.close();
      },
    });
  }

  async function readBytes(
    reader,
  ) {
    const chunks = [];
    while (true) {
      const { done, value } = await reader.read();
      if (!done && value instanceof Uint8Array) {
        chunks.push(value);
      } else if (done) {
        const size = chunks.reduce((p, i) => p + i.byteLength, 0);
        const bytes = new Uint8Array(size);
        let offs = 0;
        for (const chunk of chunks) {
          bytes.set(chunk, offs);
          offs += chunk.byteLength;
        }
        return bytes.buffer;
      } else {
        throw new TypeError("Invalid reader result.");
      }
    }
  }

  // A WeakMap holding blob to byte array mapping.
  // Ensures it does not impact garbage collection.
  // const blobBytesWeakMap = new WeakMap();

  class Blob {
    constructor(blobParts, options) {
      if (arguments.length === 0) {
        this[bytesSymbol] = new Uint8Array();
        return;
      }

      const { ending = "transparent", type = "" } = options ?? {};
      // Normalize options.type.
      let normalizedType = type;
      if (!containsOnlyASCII(type)) {
        normalizedType = "";
      } else {
        if (type.length) {
          for (let i = 0; i < type.length; ++i) {
            const char = type[i];
            if (char < "\u0020" || char > "\u007E") {
              normalizedType = "";
              break;
            }
          }
          normalizedType = type.toLowerCase();
        }
      }
      const bytes = processBlobParts(blobParts, { ending, type });
      // Set Blob object's properties.
      this[bytesSymbol] = bytes;
      this.size = bytes.byteLength;
      this.type = normalizedType;
    }

    slice(start, end, contentType) {
      return new Blob([this[bytesSymbol].slice(start, end)], {
        type: contentType || this.type,
      });
    }

    stream() {
      return getStream(this[bytesSymbol]);
    }

    async text() {
      const reader = getStream(this[bytesSymbol]).getReader();
      const decoder = new TextDecoder();
      return decoder.decode(await readBytes(reader));
    }

    arrayBuffer() {
      return readBytes(getStream(this[bytesSymbol]).getReader());
    }
  }

  class DomFile extends Blob {
    constructor(
      fileBits,
      fileName,
      options,
    ) {
      const { lastModified = Date.now(), ...blobPropertyBag } = options ?? {};
      super(fileBits, blobPropertyBag);

      // 4.1.2.1 Replace any "/" character (U+002F SOLIDUS)
      // with a ":" (U + 003A COLON)
      this.name = String(fileName).replace(/\u002F/g, "\u003A");
      // 4.1.3.3 If lastModified is not provided, set lastModified to the current
      // date and time represented in number of milliseconds since the Unix Epoch.
      this.lastModified = lastModified;
    }
  }

  function parseFormDataValue(value, filename) {
    if (value instanceof DomFile) {
      return new DomFile([value], filename || value.name, {
        type: value.type,
        lastModified: value.lastModified,
      });
    } else if (value instanceof Blob) {
      return new DomFile([value], filename || "blob", {
        type: value.type,
      });
    } else {
      return String(value);
    }
  }

  class FormDataBase {
    [dataSymbol] = [];

    append(name, value, filename) {
      requiredArguments("FormData.append", arguments.length, 2);
      name = String(name);
      this[dataSymbol].push([name, parseFormDataValue(value, filename)]);
    }

    delete(name) {
      requiredArguments("FormData.delete", arguments.length, 1);
      name = String(name);
      let i = 0;
      while (i < this[dataSymbol].length) {
        if (this[dataSymbol][i][0] === name) {
          this[dataSymbol].splice(i, 1);
        } else {
          i++;
        }
      }
    }

    getAll(name) {
      requiredArguments("FormData.getAll", arguments.length, 1);
      name = String(name);
      const values = [];
      for (const entry of this[dataSymbol]) {
        if (entry[0] === name) {
          values.push(entry[1]);
        }
      }

      return values;
    }

    get(name) {
      requiredArguments("FormData.get", arguments.length, 1);
      name = String(name);
      for (const entry of this[dataSymbol]) {
        if (entry[0] === name) {
          return entry[1];
        }
      }

      return null;
    }

    has(name) {
      requiredArguments("FormData.has", arguments.length, 1);
      name = String(name);
      return this[dataSymbol].some((entry) => entry[0] === name);
    }

    set(name, value, filename) {
      requiredArguments("FormData.set", arguments.length, 2);
      name = String(name);

      // If there are any entries in the context object’s entry list whose name
      // is name, replace the first such entry with entry and remove the others
      let found = false;
      let i = 0;
      while (i < this[dataSymbol].length) {
        if (this[dataSymbol][i][0] === name) {
          if (!found) {
            this[dataSymbol][i][1] = parseFormDataValue(value, filename);
            found = true;
          } else {
            this[dataSymbol].splice(i, 1);
            continue;
          }
        }
        i++;
      }

      // Otherwise, append entry to the context object’s entry list.
      if (!found) {
        this[dataSymbol].push([name, parseFormDataValue(value, filename)]);
      }
    }

    get [Symbol.toStringTag]() {
      return "FormData";
    }
  }

  class FormData extends DomIterableMixin(FormDataBase, dataSymbol) {}

  class MultipartBuilder {
    constructor(formData, boundary) {
      this.formData = formData;
      this.boundary = boundary ?? this.#createBoundary();
      this.writer = new Buffer();
    }

    getContentType() {
      return `multipart/form-data; boundary=${this.boundary}`;
    }

    getBody() {
      for (const [fieldName, fieldValue] of this.formData.entries()) {
        if (fieldValue instanceof DomFile) {
          this.#writeFile(fieldName, fieldValue);
        } else this.#writeField(fieldName, fieldValue);
      }

      this.writer.writeSync(encoder.encode(`\r\n--${this.boundary}--`));

      return this.writer.bytes();
    }

    #createBoundary = () => {
      return (
        "----------" +
        Array.from(Array(32))
          .map(() => Math.random().toString(36)[2] || 0)
          .join("")
      );
    };

    #writeHeaders = (headers) => {
      let buf = this.writer.empty() ? "" : "\r\n";

      buf += `--${this.boundary}\r\n`;
      for (const [key, value] of headers) {
        buf += `${key}: ${value}\r\n`;
      }
      buf += `\r\n`;

      // FIXME(Bartlomieju): this should use `writeSync()`
      this.writer.write(encoder.encode(buf));
    };

    #writeFileHeaders = (
      field,
      filename,
      type,
    ) => {
      const headers = [
        [
          "Content-Disposition",
          `form-data; name="${field}"; filename="${filename}"`,
        ],
        ["Content-Type", type || "application/octet-stream"],
      ];
      return this.#writeHeaders(headers);
    };

    #writeFieldHeaders = (field) => {
      const headers = [["Content-Disposition", `form-data; name="${field}"`]];
      return this.#writeHeaders(headers);
    };

    #writeField = (field, value) => {
      this.#writeFieldHeaders(field);
      this.writer.writeSync(encoder.encode(value));
    };

    #writeFile = (field, value) => {
      this.#writeFileHeaders(field, value.name, value.type);
      this.writer.writeSync(value[bytesSymbol]);
    };
  }

  class MultipartParser {
    constructor(body, boundary) {
      if (!boundary) {
        throw new TypeError("multipart/form-data must provide a boundary");
      }

      this.boundary = `--${boundary}`;
      this.body = body;
      this.boundaryChars = encoder.encode(this.boundary);
    }

    #parseHeaders = (headersText) => {
      const headers = new Headers();
      const rawHeaders = headersText.split("\r\n");
      for (const rawHeader of rawHeaders) {
        const sepIndex = rawHeader.indexOf(":");
        if (sepIndex < 0) {
          continue; // Skip this header
        }
        const key = rawHeader.slice(0, sepIndex);
        const value = rawHeader.slice(sepIndex + 1);
        headers.set(key, value);
      }

      return {
        headers,
        disposition: getHeaderValueParams(
          headers.get("Content-Disposition") ?? "",
        ),
      };
    };

    parse() {
      const formData = new FormData();
      let headerText = "";
      let boundaryIndex = 0;
      let state = 0;
      let fileStart = 0;

      for (let i = 0; i < this.body.length; i++) {
        const byte = this.body[i];
        const prevByte = this.body[i - 1];
        const isNewLine = byte === LF && prevByte === CR;

        if (state === 1 || state === 2 || state == 3) {
          headerText += String.fromCharCode(byte);
        }
        if (state === 0 && isNewLine) {
          state = 1;
        } else if (state === 1 && isNewLine) {
          state = 2;
          const headersDone = this.body[i + 1] === CR &&
            this.body[i + 2] === LF;

          if (headersDone) {
            state = 3;
          }
        } else if (state === 2 && isNewLine) {
          state = 3;
        } else if (state === 3 && isNewLine) {
          state = 4;
          fileStart = i + 1;
        } else if (state === 4) {
          if (this.boundaryChars[boundaryIndex] !== byte) {
            boundaryIndex = 0;
          } else {
            boundaryIndex++;
          }

          if (boundaryIndex >= this.boundary.length) {
            const { headers, disposition } = this.#parseHeaders(headerText);
            const content = this.body.subarray(
              fileStart,
              i - boundaryIndex - 1,
            );
            // https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata
            const filename = disposition.get("filename");
            const name = disposition.get("name");

            state = 5;
            // Reset
            boundaryIndex = 0;
            headerText = "";

            if (!name) {
              continue; // Skip, unknown name
            }

            if (filename) {
              const blob = new Blob([content], {
                type: headers.get("Content-Type") || "application/octet-stream",
              });
              formData.append(name, blob, filename);
            } else {
              formData.append(name, decoder.decode(content));
            }
          }
        } else if (state === 5 && isNewLine) {
          state = 1;
        }
      }

      return formData;
    }
  }

  function validateBodyType(owner, bodySource) {
    if (isTypedArray(bodySource)) {
      return true;
    } else if (bodySource instanceof ArrayBuffer) {
      return true;
    } else if (typeof bodySource === "string") {
      return true;
    } else if (bodySource instanceof ReadableStream) {
      return true;
    } else if (bodySource instanceof FormData) {
      return true;
    } else if (bodySource instanceof URLSearchParams) {
      return true;
    } else if (!bodySource) {
      return true; // null body is fine
    }
    throw new Error(
      `Bad ${owner.constructor.name} body type: ${bodySource.constructor.name}`,
    );
  }

  async function bufferFromStream(
    stream,
    size,
  ) {
    const encoder = new TextEncoder();
    const buffer = new Buffer();

    if (size) {
      // grow to avoid unnecessary allocations & copies
      buffer.grow(size);
    }

    while (true) {
      const { done, value } = await stream.read();

      if (done) break;

      if (typeof value === "string") {
        buffer.writeSync(encoder.encode(value));
      } else if (value instanceof ArrayBuffer) {
        buffer.writeSync(new Uint8Array(value));
      } else if (value instanceof Uint8Array) {
        buffer.writeSync(value);
      } else if (!value) {
        // noop for undefined
      } else {
        throw new Error("unhandled type on stream read");
      }
    }

    return buffer.bytes().buffer;
  }

  function bodyToArrayBuffer(bodySource) {
    if (isTypedArray(bodySource)) {
      return bodySource.buffer;
    } else if (bodySource instanceof ArrayBuffer) {
      return bodySource;
    } else if (typeof bodySource === "string") {
      const enc = new TextEncoder();
      return enc.encode(bodySource).buffer;
    } else if (bodySource instanceof ReadableStream) {
      throw new Error(
        `Can't convert stream to ArrayBuffer (try bufferFromStream)`,
      );
    } else if (
      bodySource instanceof FormData ||
      bodySource instanceof URLSearchParams
    ) {
      const enc = new TextEncoder();
      return enc.encode(bodySource.toString()).buffer;
    } else if (!bodySource) {
      return new ArrayBuffer(0);
    }
    throw new Error(
      `Body type not implemented: ${bodySource.constructor.name}`,
    );
  }

  const BodyUsedError =
    "Failed to execute 'clone' on 'Body': body is already used";

  class Body {
    #contentType = "";
    #size = undefined;

    constructor(_bodySource, meta) {
      validateBodyType(this, _bodySource);
      this._bodySource = _bodySource;
      this.#contentType = meta.contentType;
      this.#size = meta.size;
      this._stream = null;
    }

    get body() {
      if (this._stream) {
        return this._stream;
      }

      if (!this._bodySource) {
        return null;
      } else if (this._bodySource instanceof ReadableStream) {
        this._stream = this._bodySource;
      } else {
        const buf = bodyToArrayBuffer(this._bodySource);
        if (!(buf instanceof ArrayBuffer)) {
          throw new Error(
            `Expected ArrayBuffer from body`,
          );
        }

        this._stream = new ReadableStream({
          start(controller) {
            controller.enqueue(new Uint8Array(buf));
            controller.close();
          },
        });
      }

      return this._stream;
    }

    get bodyUsed() {
      if (this.body && isReadableStreamDisturbed(this.body)) {
        return true;
      }
      return false;
    }

    async blob() {
      return new Blob([await this.arrayBuffer()], {
        type: this.#contentType,
      });
    }

    // ref: https://fetch.spec.whatwg.org/#body-mixin
    async formData() {
      const formData = new FormData();
      if (hasHeaderValueOf(this.#contentType, "multipart/form-data")) {
        const params = getHeaderValueParams(this.#contentType);

        // ref: https://tools.ietf.org/html/rfc2046#section-5.1
        const boundary = params.get("boundary");
        const body = new Uint8Array(await this.arrayBuffer());
        const multipartParser = new MultipartParser(body, boundary);

        return multipartParser.parse();
      } else if (
        hasHeaderValueOf(this.#contentType, "application/x-www-form-urlencoded")
      ) {
        // From https://github.com/github/fetch/blob/master/fetch.js
        // Copyright (c) 2014-2016 GitHub, Inc. MIT License
        const body = await this.text();
        try {
          body
            .trim()
            .split("&")
            .forEach((bytes) => {
              if (bytes) {
                const split = bytes.split("=");
                const name = split.shift().replace(/\+/g, " ");
                const value = split.join("=").replace(/\+/g, " ");
                formData.append(
                  decodeURIComponent(name),
                  decodeURIComponent(value),
                );
              }
            });
        } catch (e) {
          throw new TypeError("Invalid form urlencoded format");
        }
        return formData;
      } else {
        throw new TypeError("Invalid form data");
      }
    }

    async text() {
      if (typeof this._bodySource === "string") {
        return this._bodySource;
      }

      const ab = await this.arrayBuffer();
      const decoder = new TextDecoder("utf-8");
      return decoder.decode(ab);
    }

    async json() {
      const raw = await this.text();
      return JSON.parse(raw);
    }

    arrayBuffer() {
      if (this._bodySource instanceof ReadableStream) {
        return bufferFromStream(this._bodySource.getReader(), this.#size);
      }
      return Promise.resolve(bodyToArrayBuffer(this._bodySource));
    }
  }

  function createHttpClient(options) {
    return new HttpClient(opCreateHttpClient(options));
  }

  function opCreateHttpClient(args) {
    return core.jsonOpSync("op_create_http_client", args);
  }

  class HttpClient {
    constructor(rid) {
      this.rid = rid;
    }
    close() {
      core.close(this.rid);
    }
  }

  function opFetch(args, body) {
    let zeroCopy;
    if (body != null) {
      zeroCopy = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
    }

    return core.jsonOpAsync("op_fetch", args, ...(zeroCopy ? [zeroCopy] : []));
  }

  const NULL_BODY_STATUS = [101, 204, 205, 304];
  const REDIRECT_STATUS = [301, 302, 303, 307, 308];

  function byteUpperCase(s) {
    return String(s).replace(/[a-z]/g, function byteUpperCaseReplace(c) {
      return c.toUpperCase();
    });
  }

  function normalizeMethod(m) {
    const u = byteUpperCase(m);
    if (
      u === "DELETE" ||
      u === "GET" ||
      u === "HEAD" ||
      u === "OPTIONS" ||
      u === "POST" ||
      u === "PUT"
    ) {
      return u;
    }
    return m;
  }

  class Request extends Body {
    constructor(input, init) {
      if (arguments.length < 1) {
        throw TypeError("Not enough arguments");
      }

      if (!init) {
        init = {};
      }

      let b;

      // prefer body from init
      if (init.body) {
        b = init.body;
      } else if (input instanceof Request && input._bodySource) {
        if (input.bodyUsed) {
          throw TypeError(BodyUsedError);
        }
        b = input._bodySource;
      } else if (typeof input === "object" && "body" in input && input.body) {
        if (input.bodyUsed) {
          throw TypeError(BodyUsedError);
        }
        b = input.body;
      } else {
        b = "";
      }

      let headers;

      // prefer headers from init
      if (init.headers) {
        headers = new Headers(init.headers);
      } else if (input instanceof Request) {
        headers = input.headers;
      } else {
        headers = new Headers();
      }

      const contentType = headers.get("content-type") || "";
      super(b, { contentType });
      this.headers = headers;

      // readonly attribute ByteString method;
      this.method = "GET";

      // readonly attribute USVString url;
      this.url = "";

      // readonly attribute RequestCredentials credentials;
      this.credentials = "omit";

      if (input instanceof Request) {
        if (input.bodyUsed) {
          throw TypeError(BodyUsedError);
        }
        this.method = input.method;
        this.url = input.url;
        this.headers = new Headers(input.headers);
        this.credentials = input.credentials;
        this._stream = input._stream;
      } else {
        // TODO(nayeemrmn): Base from `--location` when implemented and set.
        this.url = new URL(String(input)).href;
      }

      if (init && "method" in init && init.method) {
        this.method = normalizeMethod(init.method);
      }

      if (
        init &&
        "credentials" in init &&
        init.credentials &&
        ["omit", "same-origin", "include"].indexOf(init.credentials) !== -1
      ) {
        this.credentials = init.credentials;
      }
    }

    clone() {
      if (this.bodyUsed) {
        throw TypeError(BodyUsedError);
      }

      const iterators = this.headers.entries();
      const headersList = [];
      for (const header of iterators) {
        headersList.push(header);
      }

      let body2 = this._bodySource;

      if (this._bodySource instanceof ReadableStream) {
        const tees = this._bodySource.tee();
        this._stream = this._bodySource = tees[0];
        body2 = tees[1];
      }

      return new Request(this.url, {
        body: body2,
        method: this.method,
        headers: new Headers(headersList),
        credentials: this.credentials,
      });
    }
  }

  const responseData = new WeakMap();
  class Response extends Body {
    constructor(body = null, init) {
      init = init ?? {};

      if (typeof init !== "object") {
        throw new TypeError(`'init' is not an object`);
      }

      const extraInit = responseData.get(init) || {};
      let { type = "default", url = "" } = extraInit;

      let status = init.status === undefined ? 200 : Number(init.status || 0);
      let statusText = init.statusText ?? "";
      let headers = init.headers instanceof Headers
        ? init.headers
        : new Headers(init.headers);

      if (init.status !== undefined && (status < 200 || status > 599)) {
        throw new RangeError(
          `The status provided (${init.status}) is outside the range [200, 599]`,
        );
      }

      // null body status
      if (body && NULL_BODY_STATUS.includes(status)) {
        throw new TypeError("Response with null body status cannot have body");
      }

      if (!type) {
        type = "default";
      } else {
        if (type == "error") {
          // spec: https://fetch.spec.whatwg.org/#concept-network-error
          status = 0;
          statusText = "";
          headers = new Headers();
          body = null;
          /* spec for other Response types:
           https://fetch.spec.whatwg.org/#concept-filtered-response-basic
           Please note that type "basic" is not the same thing as "default".*/
        } else if (type == "basic") {
          for (const h of headers) {
            /* Forbidden Response-Header Names:
             https://fetch.spec.whatwg.org/#forbidden-response-header-name */
            if (["set-cookie", "set-cookie2"].includes(h[0].toLowerCase())) {
              headers.delete(h[0]);
            }
          }
        } else if (type == "cors") {
          /* CORS-safelisted Response-Header Names:
             https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name */
          const allowedHeaders = [
            "Cache-Control",
            "Content-Language",
            "Content-Length",
            "Content-Type",
            "Expires",
            "Last-Modified",
            "Pragma",
          ].map((c) => c.toLowerCase());
          for (const h of headers) {
            /* Technically this is still not standards compliant because we are
             supposed to allow headers allowed in the
             'Access-Control-Expose-Headers' header in the 'internal response'
             However, this implementation of response doesn't seem to have an
             easy way to access the internal response, so we ignore that
             header.
             TODO(serverhiccups): change how internal responses are handled
             so we can do this properly. */
            if (!allowedHeaders.includes(h[0].toLowerCase())) {
              headers.delete(h[0]);
            }
          }
          /* TODO(serverhiccups): Once I fix the 'internal response' thing,
           these actually need to treat the internal response differently */
        } else if (type == "opaque" || type == "opaqueredirect") {
          url = "";
          status = 0;
          statusText = "";
          headers = new Headers();
          body = null;
        }
      }

      const contentType = headers.get("content-type") || "";
      const size = Number(headers.get("content-length")) || undefined;

      super(body, { contentType, size });

      this.url = url;
      this.statusText = statusText;
      this.status = extraInit.status || status;
      this.headers = headers;
      this.redirected = extraInit.redirected || false;
      this.type = type;
    }

    get ok() {
      return 200 <= this.status && this.status < 300;
    }

    clone() {
      if (this.bodyUsed) {
        throw TypeError(BodyUsedError);
      }

      const iterators = this.headers.entries();
      const headersList = [];
      for (const header of iterators) {
        headersList.push(header);
      }

      let resBody = this._bodySource;

      if (this._bodySource instanceof ReadableStream) {
        const tees = this._bodySource.tee();
        this._stream = this._bodySource = tees[0];
        resBody = tees[1];
      }

      return new Response(resBody, {
        status: this.status,
        statusText: this.statusText,
        headers: new Headers(headersList),
      });
    }

    static redirect(url, status) {
      if (![301, 302, 303, 307, 308].includes(status)) {
        throw new RangeError(
          "The redirection status must be one of 301, 302, 303, 307 and 308.",
        );
      }
      return new Response(null, {
        status,
        statusText: "",
        headers: [["Location", typeof url === "string" ? url : url.toString()]],
      });
    }
  }

  function sendFetchReq(url, method, headers, body, clientRid) {
    let headerArray = [];
    if (headers) {
      headerArray = Array.from(headers.entries());
    }

    const args = {
      method,
      url,
      headers: headerArray,
      clientRid,
    };

    return opFetch(args, body);
  }

  async function fetch(input, init) {
    let url;
    let method = null;
    let headers = null;
    let body;
    let clientRid = null;
    let redirected = false;
    let remRedirectCount = 20; // TODO: use a better way to handle

    if (typeof input === "string" || input instanceof URL) {
      url = typeof input === "string" ? input : input.href;
      if (init != null) {
        method = init.method || null;
        if (init.headers) {
          headers = init.headers instanceof Headers
            ? init.headers
            : new Headers(init.headers);
        } else {
          headers = null;
        }

        // ref: https://fetch.spec.whatwg.org/#body-mixin
        // Body should have been a mixin
        // but we are treating it as a separate class
        if (init.body) {
          if (!headers) {
            headers = new Headers();
          }
          let contentType = "";
          if (typeof init.body === "string") {
            body = new TextEncoder().encode(init.body);
            contentType = "text/plain;charset=UTF-8";
          } else if (isTypedArray(init.body)) {
            body = init.body;
          } else if (init.body instanceof ArrayBuffer) {
            body = new Uint8Array(init.body);
          } else if (init.body instanceof URLSearchParams) {
            body = new TextEncoder().encode(init.body.toString());
            contentType = "application/x-www-form-urlencoded;charset=UTF-8";
          } else if (init.body instanceof Blob) {
            body = init.body[bytesSymbol];
            contentType = init.body.type;
          } else if (init.body instanceof FormData) {
            let boundary;
            if (headers.has("content-type")) {
              const params = getHeaderValueParams("content-type");
              boundary = params.get("boundary");
            }
            const multipartBuilder = new MultipartBuilder(
              init.body,
              boundary,
            );
            body = multipartBuilder.getBody();
            contentType = multipartBuilder.getContentType();
          } else {
            // TODO(lucacasonato): do this in a streaming fashion once we support it
            const buf = new Buffer();
            for await (const chunk of init.body) {
              buf.write(chunk);
            }
            body = buf.bytes();
          }
          if (contentType && !headers.has("content-type")) {
            headers.set("content-type", contentType);
          }
        }

        if (init.client instanceof HttpClient) {
          clientRid = init.client.rid;
        }
      }
    } else {
      url = input.url;
      method = input.method;
      headers = input.headers;

      if (input._bodySource) {
        body = new DataView(await input.arrayBuffer());
      }
    }

    let responseBody;
    let responseInit = {};
    while (remRedirectCount) {
      const fetchResponse = await sendFetchReq(
        url,
        method,
        headers,
        body,
        clientRid,
      );
      const rid = fetchResponse.bodyRid;

      if (
        NULL_BODY_STATUS.includes(fetchResponse.status) ||
        REDIRECT_STATUS.includes(fetchResponse.status)
      ) {
        // We won't use body of received response, so close it now
        // otherwise it will be kept in resource table.
        core.close(fetchResponse.bodyRid);
        responseBody = null;
      } else {
        responseBody = new ReadableStream({
          type: "bytes",
          async pull(controller) {
            try {
              const result = await core.jsonOpAsync("op_fetch_read", { rid });
              if (!result || !result.chunk) {
                controller.close();
                core.close(rid);
              } else {
                // TODO(ry) This is terribly inefficient. Make this zero-copy.
                const chunk = new Uint8Array(result.chunk);
                controller.enqueue(chunk);
              }
            } catch (e) {
              controller.error(e);
              controller.close();
              core.close(rid);
            }
          },
          cancel() {
            // When reader.cancel() is called
            core.close(rid);
          },
        });
      }

      responseInit = {
        status: 200,
        statusText: fetchResponse.statusText,
        headers: fetchResponse.headers,
      };

      responseData.set(responseInit, {
        redirected,
        rid: fetchResponse.bodyRid,
        status: fetchResponse.status,
        url,
      });

      const response = new Response(responseBody, responseInit);

      if (REDIRECT_STATUS.includes(fetchResponse.status)) {
        // We're in a redirect status
        switch ((init && init.redirect) || "follow") {
          case "error":
            responseInit = {};
            responseData.set(responseInit, {
              type: "error",
              redirected: false,
              url: "",
            });
            return new Response(null, responseInit);
          case "manual":
            // On the web this would return a `opaqueredirect` response, but
            // those don't make sense server side. See denoland/deno#8351.
            return response;
          case "follow":
          // fallthrough
          default: {
            let redirectUrl = response.headers.get("Location");
            if (redirectUrl == null) {
              return response; // Unspecified
            }
            if (
              !redirectUrl.startsWith("http://") &&
              !redirectUrl.startsWith("https://")
            ) {
              redirectUrl = new URL(redirectUrl, url).href;
            }
            url = redirectUrl;
            redirected = true;
            remRedirectCount--;
          }
        }
      } else {
        return response;
      }
    }

    responseData.set(responseInit, {
      type: "error",
      redirected: false,
      url: "",
    });

    return new Response(null, responseInit);
  }

  window.__bootstrap.fetch = {
    Blob,
    DomFile,
    FormData,
    fetch,
    Request,
    Response,
    HttpClient,
    createHttpClient,
  };
})(this);