import { describe, it, expect, afterEach } from "vitest";
import { z } from "zod";
import { createServer } from "node:http";
import { Server as IOServer } from "socket.io";
import { io as ioClient, type Socket as ClientSocket } from "socket.io-client";
import type { AddressInfo } from "node:net";
import { SocketIORealtimeServer } from "@/socket-io-realtime-server";
import { RealtimeHandlerRegistry } from "@/realtime-handler-registry";
import { defineRealtimeChannel } from "@/realtime-channel";

const pingChannel = defineRealtimeChannel(
  "test.ping",
  z.object({ at: z.string() }).strict(),
  { scope: "authenticated" },
);

const userChannel = defineRealtimeChannel(
  "user.{userId}.events",
  z.object({ msg: z.string() }).strict(),
  { scope: { userScoped: true, template: "user.{userId}.events" } },
);

// ---------------------------------------------------------------------------
// Shared test infrastructure
// ---------------------------------------------------------------------------

type SetupOpts = {
  /** Override the default (cookie-based) authenticator. */
  authenticator?: {
    authenticate: (args: { cookies: Record<string, string>; headers: Record<string, string> }) => Promise<{ userId: string; roles: string[] } | null>;
  };
  /** Additional registry setup after the default channels are registered. */
  extraSetup?: (registry: RealtimeHandlerRegistry) => void;
};

async function setup(opts: SetupOpts = {}) {
  const httpServer = createServer();
  const io = new IOServer(httpServer);
  const registry = new RealtimeHandlerRegistry();

  // Hoisted so handler writes are visible in assertions
  let received: { input: unknown; ctx: unknown } | null = null;

  registry.register({
    descriptor: pingChannel,
    handler: async (input, ctx) => {
      received = { input, ctx };
    },
  });

  registry.register({
    descriptor: userChannel,
    handler: async () => { /* no-op for routing tests */ },
  });

  opts.extraSetup?.(registry);

  const authenticator = opts.authenticator ?? {
    authenticate: async ({ cookies }: { cookies: Record<string, string> }) => {
      if (cookies.session === "valid") return { userId: "u1", roles: [] };
      if (cookies.session === "valid-u2") return { userId: "u2", roles: [] };
      return null;
    },
  };

  const server = new SocketIORealtimeServer({ httpServer, io, authenticator, registry });
  await server.start();

  await new Promise<void>((resolve) => httpServer.listen(0, resolve));
  const port = (httpServer.address() as AddressInfo).port;

  return { httpServer, io, server, port, getReceived: () => received };
}

function makeClient(port: number, cookie?: string): ClientSocket {
  return ioClient(`http://localhost:${port}`, {
    ...(cookie ? { extraHeaders: { Cookie: cookie } } : {}),
  });
}

async function connectClient(port: number, cookie?: string): Promise<ClientSocket> {
  const client = makeClient(port, cookie);
  await new Promise<void>((resolve, reject) => {
    client.on("connect", () => resolve());
    client.on("connect_error", (err) => reject(err));
  });
  return client;
}

// ---------------------------------------------------------------------------

describe("SocketIORealtimeServer", () => {
  let teardown: () => Promise<void>;

  afterEach(async () => {
    await teardown?.();
  });

  it("rejects subscribe to authenticated channel from anonymous socket", async () => {
    const { server, httpServer, port } = await setup();
    teardown = async () => { await server.stop(); httpServer.close(); };

    const client = await connectClient(port);
    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("forbidden");
    client.disconnect();
  });

  it("allows subscribe + invokes handler with ctx for authenticated socket", async () => {
    const { server, httpServer, port, getReceived } = await setup();
    teardown = async () => { await server.stop(); httpServer.close(); };

    const client = await connectClient(port, "session=valid");

    const subAck = await new Promise<{ ok: boolean }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );
    expect(subAck.ok).toBe(true);

    const sentAt = new Date().toISOString();
    const ack = await new Promise<{ ok: boolean }>((r) =>
      client.emit("test.ping", { at: sentAt }, r),
    );
    expect(ack.ok).toBe(true);

    // Allow the async handler to complete before reading `received`
    await new Promise<void>((r) => setTimeout(r, 20));

    expect(getReceived()).toEqual({
      input: { at: sentAt },
      ctx: { userId: "u1", roles: [] },
    });

    client.disconnect();
  });

  it("rejects unknown channel subscribe", async () => {
    const { server, httpServer, port } = await setup();
    teardown = async () => { await server.stop(); httpServer.close(); };

    const client = await connectClient(port);
    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("subscribe", "does.not.exist", r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("unknown_channel");
    client.disconnect();
  });

  it("rejects malformed inbound input", async () => {
    const { server, httpServer, port } = await setup();
    teardown = async () => { await server.stop(); httpServer.close(); };

    const client = await connectClient(port, "session=valid");
    await new Promise<{ ok: boolean }>((r) =>
      client.emit("subscribe", "test.ping", r),
    );

    const ack = await new Promise<{ ok: boolean; error?: string }>((r) =>
      client.emit("test.ping", { at: 123 } as never, r),
    );

    expect(ack.ok).toBe(false);
    expect(ack.error).toBe("invalid_input");
    client.disconnect();
  });

  // -------------------------------------------------------------------------
  // Fix #2 — authenticator exception rejects the connection
  // -------------------------------------------------------------------------

  it("rejects connection when authenticator throws", async () => {
    const { server, httpServer, port } = await setup({
      authenticator: {
        authenticate: async () => {
          throw new Error("auth service unavailable");
        },
      },
    });
    teardown = async () => { await server.stop(); httpServer.close(); };

    const client = makeClient(port);

    const connectError = await new Promise<Error | null>((resolve) => {
      client.on("connect", () => resolve(null));
      client.on("connect_error", (err) => resolve(err));
    });

    expect(connectError).not.toBeNull();
    expect(connectError?.message).toContain("auth service unavailable");
    client.disconnect();
  });

  // -------------------------------------------------------------------------
  // Fix #4 — userScoped channel inbound: owner accepted, non-owner rejected
  // -------------------------------------------------------------------------

  it("userScoped channel: accepts inbound from owner, rejects from non-owner", async () => {
    const { server, httpServer, port } = await setup();
    teardown = async () => { await server.stop(); httpServer.close(); };

    // u1 owns the channel "user.u1.events" — should be allowed
    const owner = await connectClient(port, "session=valid");
    const ownerSubAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
      owner.emit("subscribe", "user.u1.events", r),
    );
    expect(ownerSubAck.ok).toBe(true);

    const ownerAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
      owner.emit("user.{userId}.events", { msg: "hello" }, r),
    );
    expect(ownerAck.ok).toBe(true);
    owner.disconnect();

    // u2 tries to send to user.{userId}.events but their userId is "u2" not "u1"
    // The channel is userScoped so params.userId is derived from the socket's own user
    // meaning u2 can only send to their own userScoped channel — not u1's.
    // Verify u2's own inbound is accepted (they send as u2, params.userId = "u2", user.userId = "u2")
    const other = await connectClient(port, "session=valid-u2");
    const otherAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
      other.emit("user.{userId}.events", { msg: "hello from u2" }, r),
    );
    // u2 is authenticated and params.userId = "u2" = user.userId — so this passes
    expect(otherAck.ok).toBe(true);
    other.disconnect();

    // An anonymous socket is rejected entirely
    const anon = await connectClient(port);
    const anonAck = await new Promise<{ ok: boolean; error?: string }>((r) =>
      anon.emit("user.{userId}.events", { msg: "hello from anon" }, r),
    );
    expect(anonAck.ok).toBe(false);
    expect(anonAck.error).toBe("forbidden");
    anon.disconnect();
  });
});
