import expect = require("expect.js");
import { io, Manager, ManagerOptions } from "..";
import hasCORS from "has-cors";
import { install } from "@sinonjs/fake-timers";
import textBlobBuilder from "text-blob-builder";
import { BASE_URL, wrap } from "./support/util";
import { nextTick } from "engine.io-client";

describe("connection", () => {
  it("should connect to localhost", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true });
      socket.emit("hi");
      socket.on("hi", (data) => {
        socket.disconnect();
        done();
      });
    });
  });

  it("should not connect when autoConnect option set to false", () => {
    const socket = io(BASE_URL, { forceNew: true, autoConnect: false });
    expect(socket.io.engine).to.not.be.ok();
    socket.disconnect();
  });

  it("should start two connections with same path", () => {
    const s1 = io(BASE_URL + "/");
    const s2 = io(BASE_URL + "/");

    expect(s1.io).to.not.be(s2.io);
    s1.disconnect();
    s2.disconnect();
  });

  it("should start two connections with same path and different querystrings", () => {
    const s1 = io(BASE_URL + "/?woot");
    const s2 = io(BASE_URL + "/");

    expect(s1.io).to.not.be(s2.io);
    s1.disconnect();
    s2.disconnect();
  });

  it("should start two connections with different paths", () => {
    const s1 = io(BASE_URL + "/", { path: "/foo" });
    const s2 = io(BASE_URL + "/", { path: "/bar" });

    expect(s1.io).to.not.be(s2.io);
    s1.disconnect();
    s2.disconnect();
  });

  it("should start a single connection with different namespaces", () => {
    const opts = {};
    const s1 = io(BASE_URL + "/foo", opts);
    const s2 = io(BASE_URL + "/bar", opts);

    expect(s1.io).to.be(s2.io);
    s1.disconnect();
    s2.disconnect();
  });

  it("should work with acks", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true });
      socket.emit("ack");
      socket.on("ack", (fn) => {
        fn(5, { test: true });
      });
      socket.on("got it", () => {
        socket.disconnect();
        done();
      });
    });
  });

  it("should receive date with ack", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true });
      socket.emit("getAckDate", { test: true }, (data) => {
        expect(data).to.be.a("string");
        socket.disconnect();
        done();
      });
    });
  });

  it("should work with false", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true });
      socket.emit("false");
      socket.on("false", (f) => {
        expect(f).to.be(false);
        socket.disconnect();
        done();
      });
    });
  });

  it("should receive utf8 multibyte characters", () => {
    return wrap((done) => {
      const correct = [
        "てすと",
        "Я Б Г Д Ж Й",
        "Ä ä Ü ü ß",
        "utf8 — string",
        "utf8 — string",
      ];

      const socket = io(BASE_URL, { forceNew: true });
      let i = 0;
      socket.on("takeUtf8", (data) => {
        expect(data).to.be(correct[i]);
        i++;
        if (i === correct.length) {
          socket.disconnect();
          done();
        }
      });
      socket.emit("getUtf8");
    });
  });

  it("should connect to a namespace after connection established", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL);
      const socket = manager.socket("/");
      socket.on("connect", () => {
        const foo = manager.socket("/foo");
        foo.on("connect", () => {
          foo.close();
          socket.close();
          manager._close();
          done();
        });
      });
    });
  });

  it("should open a new namespace after connection gets closed", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL);
      const socket = manager.socket("/");
      socket
        .on("connect", () => {
          socket.disconnect();
        })
        .on("disconnect", () => {
          const foo = manager.socket("/foo");
          foo.on("connect", () => {
            foo.disconnect();
            manager._close();
            done();
          });
        });
    });
  });

  it("should reconnect by default", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true, reconnectionDelay: 10 });
      socket.io.on("reconnect", () => {
        socket.disconnect();
        done();
      });

      setTimeout(() => {
        socket.io.engine.close();
      }, 100);
    });
  });

  it("should reconnect manually", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true });
      socket
        .once("connect", () => {
          socket.disconnect();
        })
        .once("disconnect", () => {
          socket.once("connect", () => {
            socket.disconnect();
            done();
          });
          socket.connect();
        });
    });
  });

  it("should reconnect automatically after reconnecting manually", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true, reconnectionDelay: 10 });
      socket
        .once("connect", () => {
          socket.disconnect();
        })
        .once("disconnect", () => {
          socket.io.on("reconnect", () => {
            socket.disconnect();
            done();
          });
          socket.connect();
          setTimeout(() => {
            socket.io.engine.close();
          }, 100);
        });
    });
  });

  it("should attempt reconnects after a failed reconnect", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        reconnection: true,
        timeout: 0,
        reconnectionAttempts: 2,
        reconnectionDelay: 10,
      });
      const socket = manager.socket("/timeout");
      manager.once("reconnect_failed", () => {
        let reconnects = 0;
        const reconnectCb = () => {
          reconnects++;
        };

        manager.on("reconnect_attempt", reconnectCb);
        manager.on("reconnect_failed", () => {
          expect(reconnects).to.be(2);
          socket.close();
          manager._close();
          done();
        });
        socket.connect();
      });
    });
  });

  it("reconnect delay should increase every time", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        reconnection: true,
        timeout: 0,
        reconnectionAttempts: 3,
        reconnectionDelay: 100,
        randomizationFactor: 0.2,
      });
      const socket = manager.socket("/timeout");
      let reconnects = 0;
      let increasingDelay = true;
      let startTime;
      let prevDelay = 0;

      manager.on("error", () => {
        startTime = new Date().getTime();
      });
      manager.on("reconnect_attempt", () => {
        reconnects++;
        const currentTime = new Date().getTime();
        const delay = currentTime - startTime;
        if (delay <= prevDelay) {
          increasingDelay = false;
        }
        prevDelay = delay;
      });

      manager.on("reconnect_failed", () => {
        expect(reconnects).to.be(3);
        expect(increasingDelay).to.be.ok();
        socket.close();
        manager._close();
        done();
      });
    });
  });

  it("should not reconnect when force closed", () => {
    return wrap((done) => {
      const socket = io(BASE_URL + "/invalid", {
        forceNew: true,
        timeout: 0,
        reconnectionDelay: 10,
      });
      socket.io.once("error", () => {
        socket.io.on("reconnect_attempt", () => {
          done(new Error("should not happen"));
        });
        socket.disconnect();
        // set a timeout to let reconnection possibly fire
        setTimeout(() => {
          done();
        }, 100);
      });
    });
  });

  it("should stop reconnecting when force closed", () => {
    return wrap((done) => {
      const socket = io(BASE_URL + "/invalid", {
        forceNew: true,
        timeout: 0,
        reconnectionDelay: 10,
      });
      socket.io.once("reconnect_attempt", () => {
        socket.io.on("reconnect_attempt", () => {
          done(new Error("should not happen"));
        });
        socket.disconnect();
        // set a timeout to let reconnection possibly fire
        setTimeout(() => {
          done();
        }, 100);
      });
    });
  });

  it("should reconnect after stopping reconnection", () => {
    return wrap((done) => {
      const socket = io(BASE_URL + "/invalid", {
        forceNew: true,
        timeout: 0,
        reconnectionDelay: 10,
      });
      socket.io.once("reconnect_attempt", () => {
        socket.io.on("reconnect_attempt", () => {
          socket.disconnect();
          done();
        });
        socket.disconnect();
        socket.connect();
      });
    });
  });

  it("should stop reconnecting on a socket and keep to reconnect on another", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        reconnectionDelay: 10,
      });
      const socket1 = manager.socket("/");
      const socket2 = manager.socket("/asd");

      manager.on("reconnect_attempt", () => {
        socket1.on("connect", () => {
          done(new Error("should not happen"));
        });
        socket2.on("connect", () => {
          setTimeout(() => {
            socket2.disconnect();
            manager._close();
            done();
          }, 50);
        });
        socket1.disconnect();
      });

      setTimeout(() => {
        manager.engine.close();
      }, 100);
    });
  });

  it("should try to reconnect twice and fail when requested two attempts with immediate timeout and reconnect enabled", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        reconnection: true,
        timeout: 0,
        reconnectionAttempts: 2,
        reconnectionDelay: 10,
      });
      let socket;

      let reconnects = 0;
      const reconnectCb = () => {
        reconnects++;
      };

      manager.on("reconnect_attempt", reconnectCb);
      manager.on("reconnect_failed", () => {
        expect(reconnects).to.be(2);
        socket.close();
        manager._close();
        done();
      });

      socket = manager.socket("/timeout");
    });
  });

  it("should fire reconnect_* events on manager", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        reconnection: true,
        timeout: 0,
        reconnectionAttempts: 2,
        reconnectionDelay: 10,
      });
      const socket = manager.socket("/timeout_socket");

      let reconnects = 0;
      const reconnectCb = (attempts) => {
        reconnects++;
        expect(attempts).to.be(reconnects);
      };

      manager.on("reconnect_attempt", reconnectCb);
      manager.on("reconnect_failed", () => {
        expect(reconnects).to.be(2);
        socket.close();
        manager._close();
        done();
      });
    });
  });

  it("should fire reconnecting (on manager) with attempts number when reconnecting twice", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        reconnection: true,
        timeout: 0,
        reconnectionAttempts: 2,
        reconnectionDelay: 10,
      });
      const socket = manager.socket("/timeout_socket");

      let reconnects = 0;
      const reconnectCb = (attempts) => {
        reconnects++;
        expect(attempts).to.be(reconnects);
      };

      manager.on("reconnect_attempt", reconnectCb);
      manager.on("reconnect_failed", () => {
        expect(reconnects).to.be(2);
        socket.close();
        manager._close();
        done();
      });
    });
  });

  it("should not try to reconnect and should form a connection when connecting to correct port with default timeout", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        reconnection: true,
        reconnectionDelay: 10,
      });

      manager.on("reconnect_attempt", () => {
        done(new Error("should not happen"));
      });

      const socket = manager.socket("/valid");
      socket.on("connect", () => {
        // set a timeout to let reconnection possibly fire
        setTimeout(() => {
          socket.close();
          manager._close();
          done();
        }, 100);
      });
    });
  });

  it("should connect while disconnecting another socket", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL);
      const socket1 = manager.socket("/foo");
      socket1.on("connect", () => {
        const socket2 = manager.socket("/asd");
        socket2.on("connect", done);
        socket1.disconnect();
      });
    });
  });

  it("should emit a connect_error event when reaching a Socket.IO server in v2.x", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, {
        autoConnect: false,
      });

      socket.on("connect_error", () => {
        done();
      });

      // @ts-ignore
      socket.onpacket({
        nsp: "/",
        type: 0,
      });
    });
  });

  it("should not close the connection when disconnecting a single socket", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        autoConnect: false,
      });
      const socket1 = manager.socket("/foo");
      const socket2 = manager.socket("/asd");

      socket1.connect();
      socket1.on("connect", () => {
        socket2.connect();
      });

      socket2.on("connect", () => {
        socket2.on("disconnect", () => {
          done(new Error("should not happen for now"));
        });
        socket1.disconnect();
        setTimeout(() => {
          socket2.off("disconnect");
          manager.on("close", () => {
            done();
          });
          socket2.disconnect();
        }, 200);
      });
    });
  });

  it("should stop trying to reconnect", () => {
    return wrap((done) => {
      const manager = new Manager("http://localhost:9823", {
        reconnectionDelay: 10,
      });

      manager.on("reconnect_error", () => {
        // disable current reconnection loop
        manager.reconnection(false);

        manager.on("reconnect_attempt", () => {
          done(new Error("should not happen"));
        });

        setTimeout(() => {
          manager._close();
          done();
        }, 100);
      });
    });
  });

  // Ignore incorrect connection test for old IE due to no support for
  // `script.onerror` (see: http://requirejs.org/docs/api.html#ieloadfail)
  if (!global.document || hasCORS) {
    it("should try to reconnect twice and fail when requested two attempts with incorrect address and reconnect enabled", () => {
      return wrap((done) => {
        const manager = new Manager("http://localhost:3940", {
          reconnection: true,
          reconnectionAttempts: 2,
          reconnectionDelay: 10,
        });
        const socket = manager.socket("/asd");
        let reconnects = 0;
        const cb = () => {
          reconnects++;
        };

        manager.on("reconnect_attempt", cb);

        manager.on("reconnect_failed", () => {
          expect(reconnects).to.be(2);
          socket.disconnect();
          manager._close();
          done();
        });
      });
    });

    it("should not try to reconnect with incorrect port when reconnection disabled", () => {
      return wrap((done) => {
        const manager = new Manager("http://localhost:9823", {
          reconnection: false,
          reconnectionDelay: 10,
        });

        manager.on("reconnect_attempt", () => {
          done(new Error("should not happen"));
        });

        manager.on("error", () => {
          // set a timeout to let reconnection possibly fire
          setTimeout(() => {
            socket.disconnect();
            manager._close();
            done();
          }, 100);
        });

        var socket = manager.socket("/invalid");
      });
    });

    it("should still try to reconnect twice after opening another socket asynchronously", () => {
      return wrap((done) => {
        const manager = new Manager("http://localhost:9823", {
          reconnection: true,
          reconnectionAttempts: 2,
          reconnectionDelay: 10,
        });
        let delay = Math.floor(
          manager.reconnectionDelay() * manager.randomizationFactor() * 0.5,
        );
        delay = Math.max(delay, 10);

        let reconnects = 0;
        const cb = () => {
          reconnects++;
        };

        manager.on("reconnect_attempt", cb);

        manager.on("reconnect_failed", () => {
          expect(reconnects).to.be(2);
          socket.disconnect();
          manager._close();
          done();
        });

        var socket = manager.socket("/room1");

        setTimeout(() => {
          manager.socket("/room2");
        }, delay);
      });
    });
  }

  describe("reconnect timeout", () => {
    const setupManagerCallbacks = (manager: Manager) => {
      const socket = manager.socket("/foo");
      manager.on("reconnect_attempt", () => {
        reconnected = true;
        socket.disconnect();
      });
    };
    let options!: Partial<ManagerOptions>;
    let reconnected!: boolean;
    const realTimeout = setTimeout;
    const wait = (delay: number) =>
      new Promise((resolve) => {
        realTimeout(resolve, delay);
      });

    beforeEach(() => {
      options = {
        timeout: 500,
        reconnectionDelayMax: 100,
        randomizationFactor: 0,
      };
      reconnected = false;
    });

    it("should use overridden setTimeout by default", async () => {
      const clock = install();
      const manager = new Manager(options);
      setupManagerCallbacks(manager);

      // Wait real clock time for the socket to connect before disconnecting it.
      await clock.tickAsync(options.timeout);
      await wait(options.timeout);
      manager.engine.close();

      // Let the clock approach the reconnect delay.
      await clock.tickAsync(options.reconnectionDelayMax - 1);
      expect(reconnected).to.be(false);

      await clock.tickAsync(1); // reconnect.
      expect(reconnected).to.be(true);

      clock.uninstall();
    });

    it("should use native setTimeout with useNativeSetTimers", async () => {
      options.useNativeTimers = true;
      const clock = install();
      const manager = new Manager(options);
      setupManagerCallbacks(manager);

      // Wait real clock time for the socket to connect before disconnecting it.
      await clock.tickAsync(options.timeout);
      await wait(options.timeout);
      manager.engine.close();

      // Since the client will not use the overridden timeout function,
      // advancing the fake clock should not reconnect.
      await clock.tickAsync(options.reconnectionDelayMax);
      expect(reconnected).to.be(false);
      clock.uninstall();

      // Wait reconnectionDelayMax to trigger reconnect.
      await wait(options.reconnectionDelayMax);
      expect(reconnected).to.be(true);
    });
  });

  it("should emit date as string", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true });
      socket.on("takeDate", (data) => {
        socket.close();
        expect(data).to.be.a("string");
        done();
      });
      socket.emit("getDate");
    });
  });

  it("should emit date in object", () => {
    return wrap((done) => {
      const socket = io(BASE_URL, { forceNew: true });
      socket.on("takeDateObj", (data) => {
        socket.close();
        expect(data).to.be.an("object");
        expect(data.date).to.be.a("string");
        done();
      });
      socket.emit("getDateObj");
    });
  });

  if (!global.Blob && !global.ArrayBuffer) {
    it("should get base64 data as a last resort", () => {
      return wrap((done) => {
        const socket = io(BASE_URL, { forceNew: true });
        socket.on("takebin", (a) => {
          socket.disconnect();
          expect(a.base64).to.be(true);
          expect(a.data).to.eql("YXNkZmFzZGY=");
          done();
        });
        socket.emit("getbin");
      });
    });
  }

  if (global.ArrayBuffer) {
    const base64 = require("base64-arraybuffer");

    it("should get binary data (as an ArrayBuffer)", () => {
      return wrap((done) => {
        const socket = io(BASE_URL, { forceNew: true });
        if (typeof window === "undefined") {
          socket.io.engine.binaryType = "arraybuffer";
        }
        socket.emit("doge");
        socket.on("doge", (buffer) => {
          expect(buffer instanceof ArrayBuffer).to.be(true);
          socket.disconnect();
          done();
        });
      });
    });

    it("should send binary data (as an ArrayBuffer)", () => {
      return wrap((done) => {
        const socket = io(BASE_URL, { forceNew: true });
        socket.on("buffack", () => {
          socket.disconnect();
          done();
        });
        const buf = base64.decode("asdfasdf");
        socket.emit("buffa", buf);
      });
    });

    it("should send binary data (as an ArrayBuffer) mixed with json", () => {
      return wrap((done) => {
        const socket = io(BASE_URL, { forceNew: true });
        socket.on("jsonbuff-ack", () => {
          socket.disconnect();
          done();
        });
        const buf = base64.decode("howdy");
        socket.emit("jsonbuff", {
          hello: "lol",
          message: buf,
          goodbye: "gotcha",
        });
      });
    });

    it("should send events with ArrayBuffers in the correct order", () => {
      return wrap((done) => {
        const socket = io(BASE_URL, { forceNew: true });
        socket.on("abuff2-ack", () => {
          socket.disconnect();
          done();
        });
        const buf = base64.decode("abuff1");
        socket.emit("abuff1", buf);
        socket.emit("abuff2", "please arrive second");
      });
    });
  }

  // Blob is available in Node.js since v18, but not yet supported by the `engine.io-parser` package
  const isBrowser = typeof window !== "undefined";

  if (isBrowser && global.Blob && textBlobBuilder("xxx") !== null) {
    it("should send binary data (as a Blob)", () => {
      return wrap((done) => {
        const socket = io(BASE_URL, { forceNew: true });
        socket.on("back", () => {
          socket.disconnect();
          done();
        });
        const blob = textBlobBuilder("hello world");
        socket.emit("blob", blob);
      });
    });

    it("should send binary data (as a Blob) mixed with json", () => {
      return wrap((done) => {
        const socket = io(BASE_URL, { forceNew: true });
        socket.on("jsonblob-ack", () => {
          socket.disconnect();
          done();
        });
        const blob = textBlobBuilder("EEEEEEEEE");
        socket.emit("jsonblob", {
          hello: "lol",
          message: blob,
          goodbye: "gotcha",
        });
      });
    });

    it("should send events with Blobs in the correct order", () => {
      return wrap((done) => {
        const socket = io(BASE_URL, { forceNew: true });
        socket.on("blob3-ack", () => {
          socket.disconnect();
          done();
        });
        const blob = textBlobBuilder("BLOBBLOB");
        socket.emit("blob1", blob);
        socket.emit("blob2", "second");
        socket.emit("blob3", blob);
      });
    });
  }

  it("should reopen a cached socket", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        autoConnect: true,
      });
      const socket = manager.socket("/");
      socket.on("connect", () => {
        socket.disconnect();
      });

      socket.on("disconnect", () => {
        const socket2 = manager.socket("/");

        expect(socket2 === socket).to.be(true);
        expect(socket2.active).to.be(true);

        socket2.on("connect", () => {
          socket2.disconnect();
          done();
        });
      });
    });
  });

  it("should not reopen a cached but active socket", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        autoConnect: true,
      });

      let i = 0;
      const expected = ["0", "1"];

      manager.engine.on("packetCreate", ({ data }) => {
        expect(data).to.eql(expected[i++]);
      });

      manager.once("open", () => {
        const socket = manager.socket("/");
        const socket2 = manager.socket("/");

        expect(socket2 === socket).to.be(true);

        socket.on("connect", () => {
          socket.disconnect();
          done();
        });
      });
    });
  });

  it("should not reopen an already active socket", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        autoConnect: true,
      });

      let i = 0;
      const expected = ["0", "0/foo,", "1", "1/foo,"];

      manager.engine.on("packetCreate", ({ data }) => {
        expect(data).to.eql(expected[i++]);
      });

      manager.once("open", () => {
        const socket = manager.socket("/");
        const socketFoo = manager.socket("/foo");

        socket.on("connect", () => {
          socket.disconnect();
          socketFoo.disconnect();
          done();
        });
      });
    });
  });

  it("should close the engine upon decoding exception", () => {
    return wrap((done) => {
      const manager = new Manager(BASE_URL, {
        autoConnect: true,
        reconnectionDelay: 50,
      });

      let engine = manager.engine;

      manager.on("open", () => {
        nextTick(() => {
          // @ts-expect-error emit() is private
          manager.engine.emit("data", "bad");
        });
      });

      manager.on("reconnect", () => {
        expect(manager.engine === engine).to.be(false);
        expect(engine.readyState).to.eql("closed");

        manager._close();
        done();
      });
    });
  });
});
