import { randomUUID } from "node:crypto";
import { type AddressInfo, createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { emitAgentEvent } from "../infra/agent-events.js";
import { startGatewayServer } from "./server.js";

vi.mock("../commands/health.js", () => ({
  getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
}));
vi.mock("../commands/status.js", () => ({
  getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
}));
vi.mock("../webchat/server.js", () => ({
  ensureWebChatServerFromConfig: vi.fn().mockResolvedValue(null),
}));
vi.mock("../web/outbound.js", () => ({
  sendMessageWhatsApp: vi
    .fn()
    .mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
vi.mock("../commands/agent.js", () => ({
  agentCommand: vi.fn().mockResolvedValue(undefined),
}));

process.env.CLAWDIS_SKIP_PROVIDERS = "1";

const originalLockPath = process.env.CLAWDIS_GATEWAY_LOCK_PATH;

beforeEach(() => {
  process.env.CLAWDIS_GATEWAY_LOCK_PATH = path.join(
    os.tmpdir(),
    `clawdis-gateway-${randomUUID()}.lock`,
  );
});

afterEach(() => {
  process.env.CLAWDIS_GATEWAY_LOCK_PATH = originalLockPath;
});

async function getFreePort(): Promise<number> {
  return await new Promise((resolve, reject) => {
    const server = createServer();
    server.listen(0, "127.0.0.1", () => {
      const port = (server.address() as AddressInfo).port;
      server.close((err) => (err ? reject(err) : resolve(port)));
    });
  });
}

function onceMessage<T = unknown>(
  ws: WebSocket,
  filter: (obj: unknown) => boolean,
  timeoutMs = 3000,
): Promise<T> {
  return new Promise<T>((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
    const closeHandler = (code: number, reason: Buffer) => {
      clearTimeout(timer);
      ws.off("message", handler);
      reject(new Error(`closed ${code}: ${reason.toString()}`));
    };
    const handler = (data: WebSocket.RawData) => {
      const obj = JSON.parse(String(data));
      if (filter(obj)) {
        clearTimeout(timer);
        ws.off("message", handler);
        ws.off("close", closeHandler);
        resolve(obj as T);
      }
    };
    ws.on("message", handler);
    ws.once("close", closeHandler);
  });
}

async function startServerWithClient(token?: string) {
  const port = await getFreePort();
  const prev = process.env.CLAWDIS_GATEWAY_TOKEN;
  if (token === undefined) {
    delete process.env.CLAWDIS_GATEWAY_TOKEN;
  } else {
    process.env.CLAWDIS_GATEWAY_TOKEN = token;
  }
  const server = await startGatewayServer(port);
  const ws = new WebSocket(`ws://127.0.0.1:${port}`);
  await new Promise<void>((resolve) => ws.once("open", resolve));
  return { server, ws, port, prevToken: prev };
}

describe("gateway server", () => {
  test("rejects protocol mismatch", async () => {
    const { server, ws } = await startServerWithClient();
    ws.send(
      JSON.stringify({
        type: "hello",
        minProtocol: 2,
        maxProtocol: 3,
        client: { name: "test", version: "1", platform: "test", mode: "test" },
        caps: [],
      }),
    );
    try {
      const res = await onceMessage(ws, () => true, 2000);
      expect(res.type).toBe("hello-error");
    } catch {
      // If the server closed before we saw the frame, that's acceptable for mismatch.
    }
    ws.close();
    await server.close();
  });

  test("rejects invalid token", async () => {
    const { server, ws, prevToken } = await startServerWithClient("secret");
    ws.send(
      JSON.stringify({
        type: "hello",
        minProtocol: 1,
        maxProtocol: 1,
        client: { name: "test", version: "1", platform: "test", mode: "test" },
        caps: [],
        auth: { token: "wrong" },
      }),
    );
    const res = await onceMessage(ws, () => true);
    expect(res.type).toBe("hello-error");
    expect(res.reason).toContain("unauthorized");
    ws.close();
    await server.close();
    process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
  });

  test(
    "closes silent handshakes after timeout",
    { timeout: 15_000 },
    async () => {
      const { server, ws } = await startServerWithClient();
      const closed = await new Promise<boolean>((resolve) => {
        const timer = setTimeout(() => resolve(false), 12_000);
        ws.once("close", () => {
          clearTimeout(timer);
          resolve(true);
        });
      });
      expect(closed).toBe(true);
      await server.close();
    },
  );

  test(
    "hello + health + presence + status succeed",
    { timeout: 8000 },
    async () => {
      const { server, ws } = await startServerWithClient();
      ws.send(
        JSON.stringify({
          type: "hello",
          minProtocol: 1,
          maxProtocol: 1,
          client: {
            name: "test",
            version: "1.0.0",
            platform: "test",
            mode: "test",
          },
          caps: [],
        }),
      );
      await onceMessage(ws, (o) => o.type === "hello-ok");

      const healthP = onceMessage(
        ws,
        (o) => o.type === "res" && o.id === "health1",
      );
      const statusP = onceMessage(
        ws,
        (o) => o.type === "res" && o.id === "status1",
      );
      const presenceP = onceMessage(
        ws,
        (o) => o.type === "res" && o.id === "presence1",
      );

      const sendReq = (id: string, method: string) =>
        ws.send(JSON.stringify({ type: "req", id, method }));
      sendReq("health1", "health");
      sendReq("status1", "status");
      sendReq("presence1", "system-presence");

      const health = await healthP;
      const status = await statusP;
      const presence = await presenceP;
      expect(health.ok).toBe(true);
      expect(status.ok).toBe(true);
      expect(presence.ok).toBe(true);
      expect(Array.isArray(presence.payload)).toBe(true);

      ws.close();
      await server.close();
    },
  );

  test(
    "presence events carry seq + stateVersion",
    { timeout: 8000 },
    async () => {
      const { server, ws } = await startServerWithClient();
      ws.send(
        JSON.stringify({
          type: "hello",
          minProtocol: 1,
          maxProtocol: 1,
          client: {
            name: "test",
            version: "1.0.0",
            platform: "test",
            mode: "test",
          },
          caps: [],
        }),
      );
      await onceMessage(ws, (o) => o.type === "hello-ok");

      const presenceEventP = onceMessage(
        ws,
        (o) => o.type === "event" && o.event === "presence",
      );
      ws.send(
        JSON.stringify({
          type: "req",
          id: "evt-1",
          method: "system-event",
          params: { text: "note from test" },
        }),
      );

      const evt = await presenceEventP;
      expect(typeof evt.seq).toBe("number");
      expect(evt.stateVersion?.presence).toBeGreaterThan(0);
      expect(Array.isArray(evt.payload?.presence)).toBe(true);

      ws.close();
      await server.close();
    },
  );

  test("agent events stream with seq", { timeout: 8000 }, async () => {
    const { server, ws } = await startServerWithClient();
    ws.send(
      JSON.stringify({
        type: "hello",
        minProtocol: 1,
        maxProtocol: 1,
        client: {
          name: "test",
          version: "1.0.0",
          platform: "test",
          mode: "test",
        },
        caps: [],
      }),
    );
    await onceMessage(ws, (o) => o.type === "hello-ok");

    // Emit a fake agent event directly through the shared emitter.
    const evtPromise = onceMessage(
      ws,
      (o) => o.type === "event" && o.event === "agent",
    );
    emitAgentEvent({ runId: "run-1", stream: "job", data: { msg: "hi" } });
    const evt = await evtPromise;
    expect(evt.payload.runId).toBe("run-1");
    expect(typeof evt.seq).toBe("number");
    expect(evt.payload.data.msg).toBe("hi");

    ws.close();
    await server.close();
  });

  test("agent ack event then final response", { timeout: 8000 }, async () => {
    const { server, ws } = await startServerWithClient();
    ws.send(
      JSON.stringify({
        type: "hello",
        minProtocol: 1,
        maxProtocol: 1,
        client: {
          name: "test",
          version: "1.0.0",
          platform: "test",
          mode: "test",
        },
        caps: [],
      }),
    );
    await onceMessage(ws, (o) => o.type === "hello-ok");

    const ackP = onceMessage(
      ws,
      (o) =>
        o.type === "event" &&
        o.event === "agent" &&
        o.payload?.status === "accepted",
    );
    const finalP = onceMessage(ws, (o) => o.type === "res" && o.id === "ag1");
    ws.send(
      JSON.stringify({
        type: "req",
        id: "ag1",
        method: "agent",
        params: { message: "hi", idempotencyKey: "idem-ag" },
      }),
    );

    const ack = await ackP;
    const final = await finalP;
    expect(ack.payload.runId).toBeDefined();
    expect(final.payload.runId).toBe(ack.payload.runId);
    expect(final.payload.status).toBe("ok");

    ws.close();
    await server.close();
  });

  test(
    "agent dedupes by idempotencyKey after completion",
    { timeout: 8000 },
    async () => {
      const { server, ws } = await startServerWithClient();
      ws.send(
        JSON.stringify({
          type: "hello",
          minProtocol: 1,
          maxProtocol: 1,
          client: {
            name: "test",
            version: "1.0.0",
            platform: "test",
            mode: "test",
          },
          caps: [],
        }),
      );
      await onceMessage(ws, (o) => o.type === "hello-ok");

      const firstFinalP = onceMessage(
        ws,
        (o) =>
          o.type === "res" &&
          o.id === "ag1" &&
          o.payload?.status !== "accepted",
      );
      ws.send(
        JSON.stringify({
          type: "req",
          id: "ag1",
          method: "agent",
          params: { message: "hi", idempotencyKey: "same-agent" },
        }),
      );
      const firstFinal = await firstFinalP;

      const secondP = onceMessage(
        ws,
        (o) => o.type === "res" && o.id === "ag2",
      );
      ws.send(
        JSON.stringify({
          type: "req",
          id: "ag2",
          method: "agent",
          params: { message: "hi again", idempotencyKey: "same-agent" },
        }),
      );
      const second = await secondP;
      expect(second.payload).toEqual(firstFinal.payload);

      ws.close();
      await server.close();
    },
  );

  test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
    const { server, ws } = await startServerWithClient();
    ws.send(
      JSON.stringify({
        type: "hello",
        minProtocol: 1,
        maxProtocol: 1,
        client: {
          name: "test",
          version: "1.0.0",
          platform: "test",
          mode: "test",
        },
        caps: [],
      }),
    );
    await onceMessage(ws, (o) => o.type === "hello-ok");

    const shutdownP = onceMessage(
      ws,
      (o) => o.type === "event" && o.event === "shutdown",
      5000,
    );
    await server.close();
    const evt = await shutdownP;
    expect(evt.payload?.reason).toBeDefined();
  });

  test(
    "presence broadcast reaches multiple clients",
    { timeout: 8000 },
    async () => {
      const port = await getFreePort();
      const server = await startGatewayServer(port);
      const mkClient = async () => {
        const c = new WebSocket(`ws://127.0.0.1:${port}`);
        await new Promise<void>((resolve) => c.once("open", resolve));
        c.send(
          JSON.stringify({
            type: "hello",
            minProtocol: 1,
            maxProtocol: 1,
            client: {
              name: "test",
              version: "1.0.0",
              platform: "test",
              mode: "test",
            },
            caps: [],
          }),
        );
        await onceMessage(c, (o) => o.type === "hello-ok");
        return c;
      };

      const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
      const waits = clients.map((c) =>
        onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
      );
      clients[0].send(
        JSON.stringify({
          type: "req",
          id: "broadcast",
          method: "system-event",
          params: { text: "fanout" },
        }),
      );
      const events = await Promise.all(waits);
      for (const evt of events) {
        expect(evt.payload?.presence?.length).toBeGreaterThan(0);
        expect(typeof evt.seq).toBe("number");
      }
      for (const c of clients) c.close();
      await server.close();
    },
  );

  test("send dedupes by idempotencyKey", { timeout: 8000 }, async () => {
    const { server, ws } = await startServerWithClient();
    ws.send(
      JSON.stringify({
        type: "hello",
        minProtocol: 1,
        maxProtocol: 1,
        client: {
          name: "test",
          version: "1.0.0",
          platform: "test",
          mode: "test",
        },
        caps: [],
      }),
    );
    await onceMessage(ws, (o) => o.type === "hello-ok");

    const idem = "same-key";
    const res1P = onceMessage(ws, (o) => o.type === "res" && o.id === "a1");
    const res2P = onceMessage(ws, (o) => o.type === "res" && o.id === "a2");
    const sendReq = (id: string) =>
      ws.send(
        JSON.stringify({
          type: "req",
          id,
          method: "send",
          params: { to: "+15550000000", message: "hi", idempotencyKey: idem },
        }),
      );
    sendReq("a1");
    sendReq("a2");

    const res1 = await res1P;
    const res2 = await res2P;
    expect(res1.ok).toBe(true);
    expect(res2.ok).toBe(true);
    expect(res1.payload).toEqual(res2.payload);
    ws.close();
    await server.close();
  });

  test("agent dedupe survives reconnect", { timeout: 15000 }, async () => {
    const port = await getFreePort();
    const server = await startGatewayServer(port);

    const dial = async () => {
      const ws = new WebSocket(`ws://127.0.0.1:${port}`);
      await new Promise<void>((resolve) => ws.once("open", resolve));
      ws.send(
        JSON.stringify({
          type: "hello",
          minProtocol: 1,
          maxProtocol: 1,
          client: {
            name: "test",
            version: "1.0.0",
            platform: "test",
            mode: "test",
          },
          caps: [],
        }),
      );
      await onceMessage(ws, (o) => o.type === "hello-ok");
      return ws;
    };

    const idem = "reconnect-agent";
    const ws1 = await dial();
    const final1P = onceMessage(
      ws1,
      (o) => o.type === "res" && o.id === "ag1",
      6000,
    );
    ws1.send(
      JSON.stringify({
        type: "req",
        id: "ag1",
        method: "agent",
        params: { message: "hi", idempotencyKey: idem },
      }),
    );
    const final1 = await final1P;
    ws1.close();

    const ws2 = await dial();
    const final2P = onceMessage(
      ws2,
      (o) => o.type === "res" && o.id === "ag2",
      6000,
    );
    ws2.send(
      JSON.stringify({
        type: "req",
        id: "ag2",
        method: "agent",
        params: { message: "hi again", idempotencyKey: idem },
      }),
    );
    const res = await final2P;
    expect(res.payload).toEqual(final1.payload);
    ws2.close();
    await server.close();
  });

  test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
    const { server, ws } = await startServerWithClient();
    ws.send(
      JSON.stringify({
        type: "hello",
        minProtocol: 1,
        maxProtocol: 1,
        client: { name: "test", version: "1", platform: "test", mode: "test" },
        caps: [],
      }),
    );
    await onceMessage(ws, (o) => o.type === "hello-ok");

    const reqId = "chat-img";
    ws.send(
      JSON.stringify({
        type: "req",
        id: reqId,
        method: "chat.send",
        params: {
          sessionKey: "main",
          message: "see image",
          idempotencyKey: "idem-img",
          attachments: [
            {
              type: "image",
              mimeType: "image/png",
              fileName: "dot.png",
              content:
                "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
            },
          ],
        },
      }),
    );

    const res = await onceMessage(
      ws,
      (o) => o.type === "res" && o.id === reqId,
      8000,
    );
    expect(res.ok).toBe(true);
    expect(res.payload?.runId).toBeDefined();

    ws.close();
    await server.close();
  });

  test("presence includes client fingerprint", async () => {
    const { server, ws } = await startServerWithClient();
    ws.send(
      JSON.stringify({
        type: "hello",
        minProtocol: 1,
        maxProtocol: 1,
        client: {
          name: "fingerprint",
          version: "9.9.9",
          platform: "test",
          mode: "ui",
          instanceId: "abc",
        },
        caps: [],
      }),
    );
    await onceMessage(ws, (o) => o.type === "hello-ok");

    const presenceP = onceMessage(
      ws,
      (o) => o.type === "res" && o.id === "fingerprint",
      4000,
    );
    ws.send(
      JSON.stringify({
        type: "req",
        id: "fingerprint",
        method: "system-presence",
      }),
    );

    const presenceRes = await presenceP;
    const entries = presenceRes.payload as Array<Record<string, unknown>>;
    const clientEntry = entries.find((e) => e.instanceId === "abc");
    expect(clientEntry?.host).toBe("fingerprint");
    expect(clientEntry?.version).toBe("9.9.9");
    expect(clientEntry?.mode).toBe("ui");

    ws.close();
    await server.close();
  });
});
