const isNodejs = typeof window === "undefined";

if (isNodejs) {
  // make the tests runnable in both the browser and Node.js
  await import("./node-imports.js");
}

const { expect } = chai;

const URL = "http://localhost:3000";
const WS_URL = URL.replace("http", "ws");

const PING_INTERVAL = 300;
const PING_TIMEOUT = 200;

function sleep(delay) {
  return new Promise((resolve) => setTimeout(resolve, delay));
}

function createWebSocket(url) {
  const socket = new WebSocket(url);
  socket._eventBuffer = {};
  socket._pendingPromises = {};

  for (const eventType of ["open", "close", "message"]) {
    socket._eventBuffer[eventType] = [];
    socket._pendingPromises[eventType] = [];

    socket.addEventListener(eventType, (event) => {
      if (socket._pendingPromises[eventType].length) {
        socket._pendingPromises[eventType].shift()(event);
      } else {
        socket._eventBuffer[eventType].push(event);
      }
    });
  }

  return socket;
}

function waitFor(socket, eventType) {
  if (socket._eventBuffer[eventType].length) {
    return Promise.resolve(socket._eventBuffer[eventType].shift());
  } else {
    return new Promise((resolve) => {
      socket._pendingPromises[eventType].push(resolve);
    });
  }
}

async function initLongPollingSession() {
  const response = await fetch(`${URL}/engine.io/?EIO=4&transport=polling`);
  const content = await response.text();
  return JSON.parse(content.substring(1)).sid;
}

describe("Engine.IO protocol", () => {
  describe("handshake", () => {
    describe("HTTP long-polling", () => {
      it("successfully opens a session", async () => {
        const response = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling`
        );

        expect(response.status).to.eql(200);

        const content = await response.text();

        expect(content).to.startsWith("0");

        const value = JSON.parse(content.substring(1));

        expect(value).to.have.all.keys(
          "sid",
          "upgrades",
          "pingInterval",
          "pingTimeout",
          "maxPayload"
        );
        expect(value.sid).to.be.a("string");
        expect(value.upgrades).to.eql(["websocket"]);
        expect(value.pingInterval).to.eql(PING_INTERVAL);
        expect(value.pingTimeout).to.eql(PING_TIMEOUT);
        expect(value.maxPayload).to.eql(1000000);
      });

      it("fails with an invalid 'EIO' query parameter", async () => {
        const response = await fetch(`${URL}/engine.io/?transport=polling`);

        expect(response.status).to.eql(400);

        const response2 = await fetch(
          `${URL}/engine.io/?EIO=abc&transport=polling`
        );

        expect(response2.status).to.eql(400);
      });

      it("fails with an invalid 'transport' query parameter", async () => {
        const response = await fetch(`${URL}/engine.io/?EIO=4`);

        expect(response.status).to.eql(400);

        const response2 = await fetch(`${URL}/engine.io/?EIO=4&transport=abc`);

        expect(response2.status).to.eql(400);
      });

      it("fails with an invalid request method", async () => {
        const response = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling`,
          {
            method: "post",
          }
        );

        expect(response.status).to.eql(400);

        const response2 = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling`,
          {
            method: "put",
          }
        );

        expect(response2.status).to.eql(400);
      });
    });

    describe("WebSocket", () => {
      it("successfully opens a session", async () => {
        const socket = createWebSocket(
          `${WS_URL}/engine.io/?EIO=4&transport=websocket`
        );

        const { data } = await waitFor(socket, "message");

        expect(data).to.startsWith("0");

        const value = JSON.parse(data.substring(1));

        expect(value).to.have.all.keys(
          "sid",
          "upgrades",
          "pingInterval",
          "pingTimeout",
          "maxPayload"
        );
        expect(value.sid).to.be.a("string");
        expect(value.upgrades).to.eql([]);
        expect(value.pingInterval).to.eql(PING_INTERVAL);
        expect(value.pingTimeout).to.eql(PING_TIMEOUT);
        expect(value.maxPayload).to.eql(1000000);

        socket.close();
      });

      it("fails with an invalid 'EIO' query parameter", async () => {
        const socket = createWebSocket(
          `${WS_URL}/engine.io/?transport=websocket`
        );

        if (isNodejs) {
          socket.on("error", () => {});
        }

        await waitFor(socket, "close");

        const socket2 = createWebSocket(
          `${WS_URL}/engine.io/?EIO=abc&transport=websocket`
        );

        if (isNodejs) {
          socket2.on("error", () => {});
        }

        await waitFor(socket2, "close");
      });

      it("fails with an invalid 'transport' query parameter", async () => {
        const socket = createWebSocket(`${WS_URL}/engine.io/?EIO=4`);

        if (isNodejs) {
          socket.on("error", () => {});
        }

        await waitFor(socket, "close");

        const socket2 = createWebSocket(
          `${WS_URL}/engine.io/?EIO=4&transport=abc`
        );

        if (isNodejs) {
          socket2.on("error", () => {});
        }

        await waitFor(socket2, "close");
      });
    });
  });

  describe("message", () => {
    describe("HTTP long-polling", () => {
      it("sends and receives a payload containing one plain text packet", async () => {
        const sid = await initLongPollingSession();

        const pushResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
          {
            method: "post",
            body: "4hello",
          }
        );

        expect(pushResponse.status).to.eql(200);

        const postContent = await pushResponse.text();

        expect(postContent).to.eql("ok");

        const pollResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
        );

        expect(pollResponse.status).to.eql(200);

        const pollContent = await pollResponse.text();

        expect(pollContent).to.eql("4hello");
      });

      it("sends and receives a payload containing several plain text packets", async () => {
        const sid = await initLongPollingSession();

        const pushResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
          {
            method: "post",
            body: "4test1\x1e4test2\x1e4test3",
          }
        );

        expect(pushResponse.status).to.eql(200);

        const postContent = await pushResponse.text();

        expect(postContent).to.eql("ok");

        const pollResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
        );

        expect(pollResponse.status).to.eql(200);

        const pollContent = await pollResponse.text();

        expect(pollContent).to.eql("4test1\x1e4test2\x1e4test3");
      });

      it("sends and receives a payload containing plain text and binary packets", async () => {
        const sid = await initLongPollingSession();

        const pushResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
          {
            method: "post",
            body: "4hello\x1ebAQIDBA==",
          }
        );

        expect(pushResponse.status).to.eql(200);

        const postContent = await pushResponse.text();

        expect(postContent).to.eql("ok");

        const pollResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
        );

        expect(pollResponse.status).to.eql(200);

        const pollContent = await pollResponse.text();

        expect(pollContent).to.eql("4hello\x1ebAQIDBA==");
      });

      it("closes the session upon invalid packet format", async () => {
        const sid = await initLongPollingSession();

        try {
          const pushResponse = await fetch(
            `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
            {
              method: "post",
              body: "abc",
            }
          );

          expect(pushResponse.status).to.eql(400);
        } catch (e) {
          // node-fetch throws when the request is closed abnormally
        }

        const pollResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
        );

        expect(pollResponse.status).to.eql(400);
      });

      it("closes the session upon duplicate poll requests", async () => {
        const sid = await initLongPollingSession();

        const pollResponses = await Promise.all([
          fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`),
          sleep(5).then(() => fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}&t=burst`)),
        ]);

        expect(pollResponses[0].status).to.eql(200);

        const content = await pollResponses[0].text();

        expect(content).to.eql("1");

        // the Node.js implementation uses HTTP 500 (Internal Server Error), but HTTP 400 seems more suitable
        expect(pollResponses[1].status).to.be.oneOf([400, 500]);

        const pollResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
        );

        expect(pollResponse.status).to.eql(400);
      });

      it("closes the session upon cancelled polling request", async () => {
        const sid = await initLongPollingSession();
        const controller = new AbortController();

        fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`, {
          signal: controller.signal,
        }).catch(() => {});

        await sleep(5);

        controller.abort();

        const pollResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
        );

        expect(pollResponse.status).to.eql(400);
      });
    });

    describe("WebSocket", () => {
      it("sends and receives a plain text packet", async () => {
        const socket = createWebSocket(
          `${WS_URL}/engine.io/?EIO=4&transport=websocket`
        );

        await waitFor(socket, "open");

        await waitFor(socket, "message"); // handshake

        socket.send("4hello");

        const { data } = await waitFor(socket, "message");

        expect(data).to.eql("4hello");

        socket.close();
      });

      it("sends and receives a binary packet", async () => {
        const socket = createWebSocket(
          `${WS_URL}/engine.io/?EIO=4&transport=websocket`
        );
        socket.binaryType = "arraybuffer";

        await waitFor(socket, "message"); // handshake

        socket.send(Uint8Array.from([1, 2, 3, 4]));

        const { data } = await waitFor(socket, "message");

        expect(data).to.eql(Uint8Array.from([1, 2, 3, 4]).buffer);

        socket.close();
      });

      it("closes the session upon invalid packet format", async () => {
        const socket = createWebSocket(
          `${WS_URL}/engine.io/?EIO=4&transport=websocket`
        );

        await waitFor(socket, "message"); // handshake

        socket.send("abc");

        await waitFor(socket, "close");

        socket.close();
      });
    });
  });

  describe("heartbeat", function () {
    this.timeout(5000);

    describe("HTTP long-polling", () => {
      it("sends ping/pong packets", async () => {
        const sid = await initLongPollingSession();

        for (let i = 0; i < 3; i++) {
          const pollResponse = await fetch(
            `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
          );

          expect(pollResponse.status).to.eql(200);

          const pollContent = await pollResponse.text();

          expect(pollContent).to.eql("2");

          const pushResponse = await fetch(
            `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`,
            {
              method: "post",
              body: "3",
            }
          );

          expect(pushResponse.status).to.eql(200);
        }
      });

      it("closes the session upon ping timeout", async () => {
        const sid = await initLongPollingSession();

        await sleep(PING_INTERVAL + PING_TIMEOUT);

        const pollResponse = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
        );

        expect(pollResponse.status).to.eql(400);
      });
    });

    describe("WebSocket", () => {
      it("sends ping/pong packets", async () => {
        const socket = createWebSocket(
          `${WS_URL}/engine.io/?EIO=4&transport=websocket`
        );

        await waitFor(socket, "message"); // handshake

        for (let i = 0; i < 3; i++) {
          const { data } = await waitFor(socket, "message");

          expect(data).to.eql("2");

          socket.send("3");
        }

        socket.close();
      });

      it("closes the session upon ping timeout", async () => {
        const socket = createWebSocket(
          `${WS_URL}/engine.io/?EIO=4&transport=websocket`
        );

        await waitFor(socket, "close"); // handshake
      });
    });
  });

  describe("close", () => {
    describe("HTTP long-polling", () => {
      it("forcefully closes the session", async () => {
        const sid = await initLongPollingSession();

        const [pollResponse] = await Promise.all([
          fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`),
          fetch(`${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`, {
            method: "post",
            body: "1",
          }),
        ]);

        expect(pollResponse.status).to.eql(200);

        const pullContent = await pollResponse.text();

        expect(pullContent).to.eql("6");

        const pollResponse2 = await fetch(
          `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
        );

        expect(pollResponse2.status).to.eql(400);
      });
    });

    describe("WebSocket", () => {
      it("forcefully closes the session", async () => {
        const socket = createWebSocket(
          `${WS_URL}/engine.io/?EIO=4&transport=websocket`
        );

        await waitFor(socket, "message"); // handshake

        socket.send("1");

        await waitFor(socket, "close");
      });
    });
  });

  describe("upgrade", () => {
    it("successfully upgrades from HTTP long-polling to WebSocket", async () => {
      const sid = await initLongPollingSession();

      const socket = createWebSocket(
        `${WS_URL}/engine.io/?EIO=4&transport=websocket&sid=${sid}`
      );

      await waitFor(socket, "open");

      // send probe
      socket.send("2probe");

      const probeResponse = await waitFor(socket, "message");

      expect(probeResponse.data).to.eql("3probe");

      const pollResponse = await fetch(
        `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
      );

      expect(pollResponse.status).to.eql(200);

      const pollContent = await pollResponse.text();

      expect(pollContent).to.eql("6"); // "noop" packet to cleanly end the HTTP long-polling request

      // complete upgrade
      socket.send("5");

      socket.send("4hello");

      const { data } = await waitFor(socket, "message");

      expect(data).to.eql("4hello");
    });

    it("ignores HTTP requests with same sid after upgrade", async () => {
      const sid = await initLongPollingSession();

      const socket = createWebSocket(
        `${WS_URL}/engine.io/?EIO=4&transport=websocket&sid=${sid}`
      );

      await waitFor(socket, "open");
      socket.send("2probe");
      await waitFor(socket, "message"); // "3probe"
      socket.send("5");

      const pollResponse = await fetch(
        `${URL}/engine.io/?EIO=4&transport=polling&sid=${sid}`
      );

      expect(pollResponse.status).to.eql(400);

      socket.send("4hello");

      const { data } = await waitFor(socket, "message");

      expect(data).to.eql("4hello");
    });

    it("ignores WebSocket connection with same sid after upgrade", async () => {
      const sid = await initLongPollingSession();

      const socket = createWebSocket(
        `${WS_URL}/engine.io/?EIO=4&transport=websocket&sid=${sid}`
      );

      await waitFor(socket, "open");
      socket.send("2probe");
      await waitFor(socket, "message"); // "3probe"
      socket.send("5");

      const socket2 = createWebSocket(
        `${WS_URL}/engine.io/?EIO=4&transport=websocket&sid=${sid}`
      );

      await waitFor(socket2, "close");

      socket.send("4hello");

      const { data } = await waitFor(socket, "message");

      expect(data).to.eql("4hello");
    });
  });
});
