import { test, expect } from "bun:test";
import {
  defineRoom,
  isRoomDefinition,
  createRealtimeRegistry,
  type RawSocket,
} from "../src/index.ts";

interface MockSocket extends RawSocket {
  sent: Record<string, unknown>[];
}
function mockSocket(): MockSocket {
  const sent: Record<string, unknown>[] = [];
  return {
    sent,
    send(data: string) {
      sent.push(JSON.parse(data) as Record<string, unknown>);
    },
    close() {},
  };
}

test("defineRoom marks a room definition", () => {
  expect(isRoomDefinition(defineRoom({}))).toBe(true);
  expect(isRoomDefinition({})).toBe(false);
  expect(isRoomDefinition(null)).toBe(false);
});

test("lifecycle hooks fire; broadcast reaches the whole room", async () => {
  const events: string[] = [];
  const def = defineRoom({
    onConnect(c) {
      events.push("connect");
      c.broadcast({ type: "join" }); // others only
    },
    onMessage(c, m) {
      events.push("message");
      c.room.broadcast({ type: "echo", text: m.text }); // everyone incl. sender
    },
    onLeave(c) {
      events.push("leave");
      c.broadcast({ type: "left" });
    },
  });
  const reg = createRealtimeRegistry();
  const a = mockSocket();
  const b = mockSocket();
  await reg.open(a, { room: "/r/x", def });
  await reg.open(b, { room: "/r/x", def });
  expect(a.sent.some((m) => m.type === "join")).toBe(true); // A saw B join
  expect(b.sent.some((m) => m.type === "join")).toBe(false); // B didn't see its own join

  await reg.message(a, JSON.stringify({ text: "hi" }));
  expect(a.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true); // sender sees own
  expect(b.sent.some((m) => m.type === "echo" && m.text === "hi")).toBe(true);

  await reg.close(b);
  expect(a.sent.some((m) => m.type === "left")).toBe(true);
  expect(reg.size()).toBe(1);
  expect(events).toEqual(["connect", "connect", "message", "leave"]);
});

test("to(connectionId) and toUser(user|users) target precisely", async () => {
  const ids: Record<string, string> = {};
  const def = defineRoom({
    onConnect(c) {
      c.user = c.query.as; // identify by ?as=
      ids[c.query.as!] = c.id;
    },
    onMessage(c, m) {
      if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
      if (m.toId) c.to(m.toId).send({ type: "direct", text: m.text });
    },
  });
  const reg = createRealtimeRegistry();
  const alice = mockSocket();
  const bob = mockSocket();
  const carol = mockSocket();
  await reg.open(alice, { room: "/r", def, query: { as: "alice" } });
  await reg.open(bob, { room: "/r", def, query: { as: "bob" } });
  await reg.open(carol, { room: "/r", def, query: { as: "carol" } });

  // single user
  await reg.message(alice, JSON.stringify({ toUser: "bob", text: "hey bob" }));
  expect(bob.sent.some((m) => m.type === "dm" && m.text === "hey bob")).toBe(true);
  expect(carol.sent.some((m) => m.type === "dm")).toBe(false);

  // selected users
  await reg.message(alice, JSON.stringify({ toUser: ["bob", "carol"], text: "both" }));
  expect(bob.sent.filter((m) => m.type === "dm").length).toBe(2);
  expect(carol.sent.some((m) => m.text === "both")).toBe(true);

  // by connection id
  await reg.message(alice, JSON.stringify({ toId: ids.carol, text: "by-id" }));
  expect(carol.sent.some((m) => m.type === "direct" && m.text === "by-id")).toBe(true);
});

test("rooms are isolated from each other", async () => {
  const def = defineRoom({
    onMessage(c, m) {
      c.room.broadcast({ type: "x", text: m.text });
    },
  });
  const reg = createRealtimeRegistry();
  const a = mockSocket();
  const b = mockSocket();
  await reg.open(a, { room: "/room/1", def }); // dynamic room instances, one handler
  await reg.open(b, { room: "/room/2", def });
  await reg.message(a, JSON.stringify({ text: "one" }));
  expect(a.sent.some((m) => m.text === "one")).toBe(true);
  expect(b.sent.length).toBe(0); // different room, untouched
});

test("bridge relays broadcasts + toUser across registries (horizontal scaling)", async () => {
  const regA = createRealtimeRegistry();
  const regB = createRealtimeRegistry();
  // A shared bus: each instance delivers the other's published envelopes.
  regA.setBridge({ publish: (env) => regB.deliver(env) });
  regB.setBridge({ publish: (env) => regA.deliver(env) });

  const def = defineRoom({
    onConnect(c) {
      c.user = c.query.as;
    },
    onMessage(c, m) {
      if (m.toUser) c.toUser(m.toUser).send({ type: "dm", text: m.text });
      else c.room.broadcast({ type: "x", text: m.text });
    },
  });
  const a = mockSocket();
  const b = mockSocket();
  await regA.open(a, { room: "/r", def, query: { as: "alice" } });
  await regB.open(b, { room: "/r", def, query: { as: "bob" } }); // b is on the OTHER instance

  // broadcast from A reaches B through the bridge
  await regA.message(a, JSON.stringify({ text: "cross-instance" }));
  expect(a.sent.some((m) => m.text === "cross-instance")).toBe(true);
  expect(b.sent.some((m) => m.text === "cross-instance")).toBe(true);

  // toUser bob (on instance B) from A reaches him via the bridge; alice doesn't
  await regA.message(a, JSON.stringify({ toUser: "bob", text: "hi bob" }));
  expect(b.sent.some((m) => m.type === "dm" && m.text === "hi bob")).toBe(true);
  const aliceDms = a.sent.filter((m) => m.type === "dm").length;
  expect(aliceDms).toBe(0); // not looped back / not delivered to the wrong user
});

test("room.state and count() track the live room", async () => {
  const def = defineRoom({
    onConnect(c) {
      c.room.state.hits = ((c.room.state.hits as number) ?? 0) + 1;
      c.send({ type: "welcome", online: c.room.count(), hits: c.room.state.hits });
    },
  });
  const reg = createRealtimeRegistry();
  const a = mockSocket();
  const b = mockSocket();
  await reg.open(a, { room: "/r", def });
  await reg.open(b, { room: "/r", def });
  expect(a.sent[0]).toMatchObject({ online: 1, hits: 1 });
  expect(b.sent[0]).toMatchObject({ online: 2, hits: 2 });
});

test("realtime security enforces authentication, message size, rate, and safe shapes", async () => {
  const violations: string[] = [];
  const def = defineRoom({
    security: {
      requireUser: true,
      maxMessageBytes: 64,
      maxMessagesPerSecond: 1,
      onViolation(reason) {
        violations.push(reason);
      },
    },
  });
  const now = { value: 0 };
  const reg = createRealtimeRegistry({ now: () => now.value });
  const anonymousClosures: Array<[number | undefined, string | undefined]> = [];
  const anonymous: RawSocket = {
    send() {},
    close(code, reason) {
      anonymousClosures.push([code, reason]);
    },
  };
  await reg.open(anonymous, { room: "/secure", def });
  expect(anonymousClosures[0]).toEqual([1008, "Authentication required"]);

  const closures: Array<[number | undefined, string | undefined]> = [];
  const socket: RawSocket = {
    send() {},
    close(code, reason) {
      closures.push([code, reason]);
    },
  };
  await reg.open(socket, { room: "/secure", def, user: "u1" });
  await reg.message(socket, JSON.stringify({ ok: true }));
  await reg.message(socket, JSON.stringify({ ok: true }));
  expect(closures.at(-1)).toEqual([1008, "Message rate exceeded"]);
  expect(violations).toContain("message-rate-limit");

  now.value = 2_000;
  const unsafe = '{"constructor":{"prototype":{"admin":true}}}';
  await reg.message(socket, unsafe);
  expect(violations).toContain("invalid-message-shape");
});
