import * as http from "http";
import type { AddressInfo } from "net";
import assert from "node:assert/strict";
import { Readable } from "node:stream";
import { describe, test } from "node:test";

import { readBodyWeb, readBody } from "../dist/index.js";

test("@arcjet/body", async function (t) {
  await t.test("should expose the public api", async function () {
    assert.deepEqual(Object.keys(await import("../dist/index.js")).sort(), [
      "readBody",
      "readBodyWeb",
    ]);
  });
});

describe("reads the body from the readable stream", () => {
  test("should fail if `limit` is not a number", async function () {
    const stream = new Readable({ read() {} });
    await assert.rejects(
      readBody(stream, {
        // @ts-expect-error: test runtime behavior.
        limit: "1kb",
      }),
      /Unexpected value `1kb` for `options\.limit`, expected positive number/,
    );
  });

  test("should fail if `limit` is literally not a number", async function () {
    const stream = new Readable({ read() {} });
    await assert.rejects(
      readBody(stream, { limit: NaN }),
      /Unexpected value `NaN` for `options\.limit`, expected positive number/,
    );
  });

  test("should fail if `limit` is a negative number", async function (t) {
    const stream = new Readable({ read() {} });
    await assert.rejects(
      readBody(stream, { limit: -1 }),
      /Unexpected value `-1` for `options\.limit`, expected positive number/,
    );
  });

  test("should fail if `expectedLength` is not a number", async function (t) {
    const stream = new Readable({ read() {} });
    await assert.rejects(
      readBody(stream, {
        // @ts-expect-error: test runtime behavior.
        expectedLength: "1kb",
      }),
      /Unexpected value `1kb` for `options\.expectedLength`, expected positive number/,
    );
  });

  test("should fail if `expectedLength` is literally not a number", async function (t) {
    const stream = new Readable({ read() {} });
    await assert.rejects(
      readBody(stream, { expectedLength: NaN }),
      /Unexpected value `NaN` for `options\.expectedLength`, expected positive number/,
    );
  });

  test("should fail if `expectedLength` is a negative number", async function (t) {
    const stream = new Readable({ read() {} });
    await assert.rejects(
      readBody(stream, { expectedLength: -1 }),
      /Unexpected value `-1` for `options\.expectedLength`, expected positive number/,
    );
  });

  test("should limit to 1mb by default", async function (t) {
    const fine = "a".repeat(1048576);

    assert.equal(
      await readBody(
        new Readable({
          read() {
            this.push(fine);
            this.push(null);
          },
        }),
      ),
      fine,
    );

    await assert.rejects(
      readBody(
        new Readable({
          read() {
            this.push(fine + "a");
            this.push(null);
          },
        }),
      ),
      /Cannot read stream that exceeds limit/,
    );
  });

  test("should read normal body streams", (t, done) => {
    const server = http.createServer(async (req, res) => {
      try {
        const body = await readBody(req, { limit: 1024 });
        res.end(body);
      } catch (err) {
        req.resume();
        res.statusCode = 500;
        if (err instanceof Error) {
          return res.end(err.message);
        } else {
          return res.end("unknown error");
        }
      }
    });

    server.listen(() => {
      const addr = server.address() as AddressInfo;
      const client = http.request({ method: "POST", port: addr.port });

      client.end("hello, world!");

      client.on("response", async (res) => {
        try {
          const body = await readBody(res, { limit: 1024 });
          assert.equal(body, "hello, world!");
        } catch (err) {
          assert.equal(err, undefined);
        } finally {
          server.close(done);
        }
      });
    });
  });

  test("should error if the body exceeds the length limit", (t, done) => {
    const server = http.createServer(async (req, res) => {
      try {
        await readBody(req, { limit: 4 });
        assert.fail("this should not return successfully");
      } catch (err) {
        assert.match(String(err), /Cannot read stream that exceeds limit/);
        req.resume();
        res.statusCode = 500;
        if (err instanceof Error) {
          return res.end(err.message);
        } else {
          return res.end("unknown error");
        }
      }
    });

    server.listen(() => {
      const addr = server.address() as AddressInfo;
      const client = http.request({ method: "POST", port: addr.port });

      client.end("i am a string");

      client.on("response", async (res) => {
        try {
          const body = await readBody(res, { limit: 1024 });
          assert.match(String(body), /Cannot read stream that exceeds limit/);
        } finally {
          server.close(done);
        }
      });
    });
  });

  test("should error if it isn't the exact length specified", (t, done) => {
    const server = http.createServer(async (req, res) => {
      try {
        await readBody(req, { limit: 1024, expectedLength: 4 });
        assert.fail("this should not return successfully");
      } catch (err) {
        assert.match(String(err), /Cannot read stream whose length does not match expected length/);
        req.resume();
        res.statusCode = 500;
        if (err instanceof Error) {
          return res.end(err.message);
        } else {
          return res.end("unknown error");
        }
      }
    });

    server.listen(() => {
      const addr = server.address() as AddressInfo;
      const client = http.request({ method: "POST", port: addr.port });

      client.end("hello, world!");

      client.on("response", async (res) => {
        try {
          const body = await readBody(res, { limit: 1024 });
          assert.match(body, /Cannot read stream whose length does not match expected length/);
        } finally {
          server.close(done);
        }
      });
    });
  });

  test("should error if the `on` function isn't present on the object", (t, done) => {
    const server = http.createServer(async (req, res) => {
      try {
        const reqNoOn = {
          removeListener: req.removeListener,
          readable: req.readable,
        };
        await readBody(reqNoOn, { limit: 1024 });
        assert.fail("this should not return successfully");
      } catch (err) {
        assert.match(
          String(err),
          /Unexpected value `undefined` for `stream\.on`, expected function/,
        );
        req.resume();
        res.statusCode = 500;
        if (err instanceof Error) {
          return res.end(err.message);
        } else {
          return res.end("unknown error");
        }
      }
    });

    server.listen(() => {
      const addr = server.address() as AddressInfo;
      const client = http.request({ method: "POST", port: addr.port });

      client.end("hello, world!");

      client.on("response", async (res) => {
        try {
          const body = await readBody(res, { limit: 1024 });
          assert.match(body, /Unexpected value `undefined` for `stream.on`, expected function/);
        } finally {
          server.close(done);
        }
      });
    });
  });

  test("should error if the `removeListener` function isn't present on the object", (t, done) => {
    const server = http.createServer(async (req, res) => {
      try {
        const reqNoOn = {
          on: req.on,
          readable: req.readable,
        };
        await readBody(reqNoOn, { limit: 1024 });
        assert.fail("this should not return successfully");
      } catch (err) {
        assert.match(
          String(err),
          /Unexpected value `undefined` for `stream\.removeListener`, expected function/,
        );
        req.resume();
        res.statusCode = 500;
        if (err instanceof Error) {
          return res.end(err.message);
        } else {
          return res.end("unknown error");
        }
      }
    });

    server.listen(() => {
      const addr = server.address() as AddressInfo;
      const client = http.request({ method: "POST", port: addr.port });

      client.end("hello, world!");

      client.on("response", async (res) => {
        try {
          const body = await readBody(res, { limit: 1024 });
          assert.match(
            body,
            /Unexpected value `undefined` for `stream\.removeListener`, expected function/,
          );
        } finally {
          server.close(done);
        }
      });
    });
  });

  test("should error if the stream is not readable", (t, done) => {
    const server = http.createServer(async (req, res) => {
      try {
        const reqNoOn = {
          on: req.on,
          removeListener: req.removeListener,
          readable: false,
        };
        await readBody(reqNoOn, { limit: 1024 });
        assert.fail("this should not return successfully");
      } catch (err) {
        assert.match(String(err), /Cannot read unreadable stream/);
        req.resume();
        res.statusCode = 500;
        if (err instanceof Error) {
          return res.end(err.message);
        } else {
          return res.end("unknown error");
        }
      }
    });

    server.listen(() => {
      const addr = server.address() as AddressInfo;
      const client = http.request({ method: "POST", port: addr.port });

      client.end("hello, world!");

      client.on("response", async (res) => {
        try {
          const body = await readBody(res, { limit: 1024 });
          assert.match(body, /Cannot read unreadable stream/);
        } finally {
          server.close(done);
        }
      });
    });
  });

  test("should work if `limit` is missing", (t, done) => {
    const server = http.createServer(async (req, res) => {
      try {
        const body = await readBody(req);
        assert.equal(body, "hello, world!");
        req.resume();
        res.statusCode = 200;
        res.end("ok");
      } catch (err) {
        req.resume();
        res.statusCode = 500;
        res.end("nok");
      }
    });

    server.listen(() => {
      const addr = server.address() as AddressInfo;
      const client = http.request({ method: "POST", port: addr.port });

      client.end("hello, world!");

      client.on("response", async (res) => {
        try {
          const body = await readBody(res);
          assert.equal(body, "ok");
        } finally {
          server.close(done);
        }
      });
    });
  });

  test(
    "should timeout if no chunks are sent",
    // setting test timeout to 200 because the promise should throw after 100ms
    { timeout: 200 },
    async () => {
      const stream = {
        on: () => undefined,
        removeListener: () => undefined,
        readable: true,
      };

      try {
        await readBody(stream, { limit: 100 });
        assert.fail("this should not return successfully");
      } catch (err) {
        assert.match(String(err), /Cannot read stream, did not receive data in time limit/);
      }
    },
  );

  type NoOpFunc = (...args: any[]) => void;
  test("should error if the stream is aborted", async () => {
    const stream = {
      on: (event: string, fn: NoOpFunc) => {
        if (event === "aborted") {
          fn();
        }
      },
      removeListener: () => undefined,
      readable: true,
    };

    try {
      await readBody(stream, { limit: 100 });
      assert.fail("this should not return successfully");
    } catch (err) {
      assert.match(String(err), /Cannot read aborted stream/);
    }
  });

  test("should propogate an error from `onEnd`", async () => {
    const stream = {
      on: (event: string, fn: NoOpFunc) => {
        if (event === "end") {
          fn(new Error("test error"));
        }
      },
      removeListener: () => undefined,
      readable: true,
    };

    try {
      await readBody(stream, { limit: 100 });
      assert.fail("this should not return successfully");
    } catch (err) {
      assert.equal(String(err), "Error: test error");
    }
  });

  test("exits normally if end is called twice", async () => {
    const stream = {
      on: (event: string, fn: NoOpFunc) => {
        if (event === "end") {
          fn();
          fn();
        }
      },
      removeListener: () => undefined,
      readable: true,
    };

    const body = await readBody(stream, { limit: 100 });
    assert.equal(body, "");
  });

  test("should not read the body if `expectedLength` exceeds `limit`", async function () {
    let read = false;

    const stream = new Readable({
      read() {
        read = true;
      },
    });

    await assert.rejects(
      readBody(stream, { expectedLength: 1025, limit: 1024 }),
      /Cannot read stream whose expected length exceeds limit/,
    );

    assert.equal(read, false);
  });
});

test("web stream", async function (t) {
  await t.test("should fail if `limit` is not a number", async function () {
    const webStream = new ReadableStream();

    await assert.rejects(
      readBodyWeb(webStream, {
        // @ts-expect-error: test runtime behavior.
        limit: "1kb",
      }),
      /Unexpected value `1kb` for `options\.limit`, expected positive number/,
    );
  });

  await t.test("should fail if `limit` is literally not a number", async function () {
    const webStream = new ReadableStream();

    await assert.rejects(
      readBodyWeb(webStream, { limit: NaN }),
      /Unexpected value `NaN` for `options\.limit`, expected positive number/,
    );
  });

  await t.test("should fail if `limit` is a negative number", async function () {
    const webStream = new ReadableStream();

    await assert.rejects(
      readBodyWeb(webStream, { limit: -1 }),
      /Unexpected value `-1` for `options\.limit`, expected positive number/,
    );
  });

  await t.test("should fail if `expectedLength` is not a number", async function () {
    const webStream = new ReadableStream();

    await assert.rejects(
      readBodyWeb(webStream, {
        // @ts-expect-error: test runtime behavior.
        expectedLength: "1kb",
      }),
      /Unexpected value `1kb` for `options\.expectedLength`, expected positive number/,
    );
  });

  await t.test("should fail if `expectedLength` is literally not a number", async function () {
    const webStream = new ReadableStream();

    await assert.rejects(
      readBodyWeb(webStream, { expectedLength: NaN }),
      /Unexpected value `NaN` for `options\.expectedLength`, expected positive number/,
    );
  });

  await t.test("should fail if `expectedLength` is a negative number", async function () {
    const webStream = new ReadableStream();

    await assert.rejects(
      readBodyWeb(webStream, { expectedLength: -1 }),
      /Unexpected value `-1` for `options\.expectedLength`, expected positive number/,
    );
  });

  await t.test("should limit to 1mb by default", async function () {
    const fine = "a".repeat(1048576);
    const body = await readBodyWeb(
      new ReadableStream({
        start(controller) {
          controller.enqueue(new TextEncoder().encode(fine));
          controller.close();
        },
      }),
    );
    assert.equal(body, fine);

    await assert.rejects(
      readBodyWeb(
        new ReadableStream({
          start(controller) {
            controller.enqueue(new TextEncoder().encode(fine + "a"));
            controller.close();
          },
        }),
      ),
      /Cannot read stream that exceeds limit/,
    );
  });

  await t.test("should read normal body streams", async function () {
    const webStream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("hello, world!"));
        controller.close();
      },
    });

    const body = await readBodyWeb(webStream, { limit: 1024 });
    assert.equal(body, "hello, world!");
  });

  await t.test("should error if the body exceeds the length limit", async function () {
    const webStream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("i am a string"));
        controller.close();
      },
    });

    await assert.rejects(
      readBodyWeb(webStream, { limit: 4 }),
      /Cannot read stream that exceeds limit/,
    );
  });

  await t.test("should error if it isn't the exact length specified", async function () {
    const webStream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("hello, world!"));
        controller.close();
      },
    });

    await assert.rejects(
      readBodyWeb(webStream, { expectedLength: 4, limit: 1024 }),
      /Cannot read stream whose length does not match expected length/,
    );
  });

  await t.test("should work if `limit` is missing", async function () {
    const webStream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("hello, world!"));
        controller.close();
      },
    });

    const body = await readBodyWeb(webStream);
    assert.equal(body, "hello, world!");
  });

  await t.test("should timeout if no chunks are sent", async function () {
    const webStream = new ReadableStream();

    await assert.rejects(
      readBodyWeb(webStream, { limit: 1024 }),
      /Cannot read stream, did not receive data in time limit/,
    );
  });

  await t.test("should not timeout if a first chunk is sent in 100ms", async function () {
    const webStream = new ReadableStream({
      start(controller) {
        setTimeout(function () {
          controller.enqueue(new TextEncoder().encode("h"));
        }, 50);

        setTimeout(function () {
          controller.enqueue(new TextEncoder().encode("i"));
        }, 100);

        setTimeout(function () {
          controller.enqueue(new TextEncoder().encode("!"));
          controller.close();
        }, 150);
      },
    });

    const body = await readBodyWeb(webStream, { limit: 1024 });
    assert.equal(body, "hi!");
  });

  await t.test("should error if the stream errors", async function () {
    const webStream = new ReadableStream({
      start(controller) {
        controller.error(new Error("boom!"));
      },
    });

    await assert.rejects(readBodyWeb(webStream, { limit: 1024 }), /boom!/);
  });

  await t.test("should not read the body if `expectedLength` exceeds `limit`", async function () {
    const webStream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("hi!"));
        controller.close();
      },
    });

    await assert.rejects(
      readBodyWeb(webStream, { expectedLength: 1025, limit: 1024 }),
      /Cannot read stream whose expected length exceeds limit/,
    );

    // The fact that this works is proof that the stream was not read.
    assert.equal(await readBodyWeb(webStream, { limit: 1024 }), "hi!");
  });

  await t.test("should fail if a stream is no longer readable", async function () {
    const webStream = new ReadableStream({
      start(controller) {
        controller.enqueue(new TextEncoder().encode("hi!"));
        controller.close();
      },
    });

    assert.equal(await readBodyWeb(webStream, { limit: 1024 }), "hi!");

    await assert.rejects(
      readBodyWeb(webStream, { limit: 1024 }),
      /Cannot read stream, did not receive data/,
    );
  });

  await t.test("should support UTF8", async function () {
    const value = "🪐";
    const webStream = new ReadableStream({
      start(controller) {
        const bytes = new TextEncoder().encode(value);
        let index = 0;

        tick();

        function tick() {
          if (index === bytes.length) {
            controller.close();
          } else {
            controller.enqueue(bytes.slice(index, index + 1));
            index++;
            setTimeout(tick, 0);
          }
        }
      },
    });

    const body = await readBodyWeb(webStream, { limit: 1024 });
    assert.equal(body, value);
  });
});
