import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";

import { afterAll, beforeAll, describe, expect, it } from "vitest";

import { approveNodePairing, listNodePairing } from "../node-pairing.js";
import { startNodeBridgeServer } from "./server.js";

function createLineReader(socket: net.Socket) {
  let buffer = "";
  const pending: Array<(line: string) => void> = [];

  const flush = () => {
    while (pending.length > 0) {
      const idx = buffer.indexOf("\n");
      if (idx === -1) return;
      const line = buffer.slice(0, idx);
      buffer = buffer.slice(idx + 1);
      const resolve = pending.shift();
      resolve?.(line);
    }
  };

  socket.on("data", (chunk) => {
    buffer += chunk.toString("utf8");
    flush();
  });

  const readLine = async () => {
    flush();
    const idx = buffer.indexOf("\n");
    if (idx !== -1) {
      const line = buffer.slice(0, idx);
      buffer = buffer.slice(idx + 1);
      return line;
    }
    return await new Promise<string>((resolve) => pending.push(resolve));
  };

  return readLine;
}

function sendLine(socket: net.Socket, obj: unknown) {
  socket.write(`${JSON.stringify(obj)}\n`);
}

describe("node bridge server", () => {
  let baseDir = "";

  beforeAll(async () => {
    process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS = "1";
    baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-bridge-test-"));
  });

  afterAll(async () => {
    await fs.rm(baseDir, { recursive: true, force: true });
    delete process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS;
  });

  it("rejects hello when not paired", async () => {
    const server = await startNodeBridgeServer({
      host: "127.0.0.1",
      port: 0,
      pairingBaseDir: baseDir,
    });

    const socket = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine = createLineReader(socket);
    sendLine(socket, { type: "hello", nodeId: "n1" });
    const line = await readLine();
    const msg = JSON.parse(line) as { type: string; code?: string };
    expect(msg.type).toBe("error");
    expect(msg.code).toBe("NOT_PAIRED");
    socket.destroy();
    await server.close();
  });

  it("pairs after approval and then accepts hello", async () => {
    const server = await startNodeBridgeServer({
      host: "127.0.0.1",
      port: 0,
      pairingBaseDir: baseDir,
    });

    const socket = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine = createLineReader(socket);
    sendLine(socket, { type: "pair-request", nodeId: "n2", platform: "ios" });

    // Approve the pending request from the gateway side.
    let reqId: string | undefined;
    for (let i = 0; i < 40; i += 1) {
      const list = await listNodePairing(baseDir);
      const req = list.pending.find((p) => p.nodeId === "n2");
      if (req) {
        reqId = req.requestId;
        break;
      }
      await new Promise((r) => setTimeout(r, 25));
    }
    expect(reqId).toBeTruthy();
    if (!reqId) throw new Error("expected a pending requestId");
    await approveNodePairing(reqId, baseDir);

    const line1 = JSON.parse(await readLine()) as {
      type: string;
      token?: string;
    };
    expect(line1.type).toBe("pair-ok");
    expect(typeof line1.token).toBe("string");
    if (!line1.token) throw new Error("expected pair-ok token");
    const token = line1.token;

    const line2 = JSON.parse(await readLine()) as { type: string };
    expect(line2.type).toBe("hello-ok");

    socket.destroy();

    const socket2 = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine2 = createLineReader(socket2);
    sendLine(socket2, { type: "hello", nodeId: "n2", token });
    const line3 = JSON.parse(await readLine2()) as { type: string };
    expect(line3.type).toBe("hello-ok");
    socket2.destroy();

    await server.close();
  });

  it("calls onPairRequested for newly created pending requests", async () => {
    let requested: { nodeId?: string; requestId?: string } | null = null;
    const server = await startNodeBridgeServer({
      host: "127.0.0.1",
      port: 0,
      pairingBaseDir: baseDir,
      onPairRequested: async (req) => {
        requested = req;
      },
    });

    const socket = net.connect({ host: "127.0.0.1", port: server.port });
    sendLine(socket, { type: "pair-request", nodeId: "n3", platform: "ios" });

    for (let i = 0; i < 40; i += 1) {
      if (requested) break;
      await new Promise((r) => setTimeout(r, 25));
    }

    expect(requested?.nodeId).toBe("n3");
    expect(typeof requested?.requestId).toBe("string");

    socket.destroy();
    await server.close();
  });

  it("handles req/res RPC after authentication", async () => {
    let lastRequest: { nodeId?: string; id?: string; method?: string } | null =
      null;

    const server = await startNodeBridgeServer({
      host: "127.0.0.1",
      port: 0,
      pairingBaseDir: baseDir,
      onRequest: async (nodeId, req) => {
        lastRequest = { nodeId, id: req.id, method: req.method };
        return { ok: true, payloadJSON: JSON.stringify({ ok: true }) };
      },
    });

    const socket = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine = createLineReader(socket);
    sendLine(socket, {
      type: "pair-request",
      nodeId: "n3-rpc",
      platform: "ios",
    });

    // Approve the pending request from the gateway side.
    let reqId: string | undefined;
    for (let i = 0; i < 40; i += 1) {
      const list = await listNodePairing(baseDir);
      const req = list.pending.find((p) => p.nodeId === "n3-rpc");
      if (req) {
        reqId = req.requestId;
        break;
      }
      await new Promise((r) => setTimeout(r, 25));
    }
    expect(reqId).toBeTruthy();
    if (!reqId) throw new Error("expected a pending requestId");
    await approveNodePairing(reqId, baseDir);

    const line1 = JSON.parse(await readLine()) as { type: string };
    expect(line1.type).toBe("pair-ok");
    const line2 = JSON.parse(await readLine()) as { type: string };
    expect(line2.type).toBe("hello-ok");

    sendLine(socket, { type: "req", id: "r1", method: "health" });
    const res = JSON.parse(await readLine()) as {
      type: string;
      id?: string;
      ok?: boolean;
      payloadJSON?: string | null;
      error?: unknown;
    };
    expect(res.type).toBe("res");
    expect(res.id).toBe("r1");
    expect(res.ok).toBe(true);
    expect(res.payloadJSON).toBe(JSON.stringify({ ok: true }));
    expect(res.error).toBeUndefined();

    expect(lastRequest).toEqual({
      nodeId: "n3-rpc",
      id: "r1",
      method: "health",
    });

    socket.destroy();
    await server.close();
  });

  it("passes node metadata to onAuthenticated and onDisconnected", async () => {
    let lastAuthed: {
      nodeId?: string;
      displayName?: string;
      platform?: string;
      version?: string;
      deviceFamily?: string;
      modelIdentifier?: string;
      remoteIp?: string;
      permissions?: Record<string, boolean>;
    } | null = null;

    let disconnected: {
      nodeId?: string;
      displayName?: string;
      platform?: string;
      version?: string;
      deviceFamily?: string;
      modelIdentifier?: string;
      remoteIp?: string;
      permissions?: Record<string, boolean>;
    } | null = null;

    let resolveDisconnected: (() => void) | null = null;
    const disconnectedP = new Promise<void>((resolve) => {
      resolveDisconnected = resolve;
    });

    const server = await startNodeBridgeServer({
      host: "127.0.0.1",
      port: 0,
      pairingBaseDir: baseDir,
      onAuthenticated: async (node) => {
        lastAuthed = node;
      },
      onDisconnected: async (node) => {
        disconnected = node;
        resolveDisconnected?.();
      },
    });

    const socket = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine = createLineReader(socket);
    sendLine(socket, {
      type: "pair-request",
      nodeId: "n4",
      displayName: "Node",
      platform: "ios",
      version: "1.0",
      deviceFamily: "iPad",
      modelIdentifier: "iPad16,6",
      permissions: { screenRecording: true, notifications: false },
    });

    // Approve the pending request from the gateway side.
    let reqId: string | undefined;
    for (let i = 0; i < 40; i += 1) {
      const list = await listNodePairing(baseDir);
      const req = list.pending.find((p) => p.nodeId === "n4");
      if (req) {
        reqId = req.requestId;
        break;
      }
      await new Promise((r) => setTimeout(r, 25));
    }
    expect(reqId).toBeTruthy();
    if (!reqId) throw new Error("expected a pending requestId");
    const approved = await approveNodePairing(reqId, baseDir);
    const token = approved?.node?.token ?? "";
    expect(token.length).toBeGreaterThan(0);

    const line1 = JSON.parse(await readLine()) as { type: string };
    expect(line1.type).toBe("pair-ok");
    const line2 = JSON.parse(await readLine()) as { type: string };
    expect(line2.type).toBe("hello-ok");
    socket.destroy();

    const socket2 = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine2 = createLineReader(socket2);
    sendLine(socket2, {
      type: "hello",
      nodeId: "n4",
      token,
      displayName: "Different name",
      platform: "ios",
      version: "2.0",
      deviceFamily: "iPad",
      modelIdentifier: "iPad99,1",
      permissions: { screenRecording: false },
    });
    const line3 = JSON.parse(await readLine2()) as { type: string };
    expect(line3.type).toBe("hello-ok");

    for (let i = 0; i < 40; i += 1) {
      if (lastAuthed?.nodeId === "n4") break;
      await new Promise((r) => setTimeout(r, 25));
    }

    expect(lastAuthed?.nodeId).toBe("n4");
    // Prefer paired metadata over hello payload (token verifies the stored node record).
    expect(lastAuthed?.displayName).toBe("Node");
    expect(lastAuthed?.platform).toBe("ios");
    expect(lastAuthed?.version).toBe("1.0");
    expect(lastAuthed?.deviceFamily).toBe("iPad");
    expect(lastAuthed?.modelIdentifier).toBe("iPad16,6");
    expect(lastAuthed?.permissions).toEqual({
      screenRecording: false,
      notifications: false,
    });
    expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true);

    socket2.destroy();
    await disconnectedP;
    expect(disconnected?.nodeId).toBe("n4");
    expect(disconnected?.remoteIp?.includes("127.0.0.1")).toBe(true);

    await server.close();
  });

  it("supports invoke roundtrip to a connected node", async () => {
    const server = await startNodeBridgeServer({
      host: "127.0.0.1",
      port: 0,
      pairingBaseDir: baseDir,
    });

    const socket = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine = createLineReader(socket);
    sendLine(socket, { type: "pair-request", nodeId: "n5", platform: "ios" });

    // Approve the pending request from the gateway side.
    let reqId: string | undefined;
    for (let i = 0; i < 40; i += 1) {
      const list = await listNodePairing(baseDir);
      const req = list.pending.find((p) => p.nodeId === "n5");
      if (req) {
        reqId = req.requestId;
        break;
      }
      await new Promise((r) => setTimeout(r, 25));
    }
    expect(reqId).toBeTruthy();
    if (!reqId) throw new Error("expected a pending requestId");
    await approveNodePairing(reqId, baseDir);

    const pairOk = JSON.parse(await readLine()) as {
      type: string;
      token?: string;
    };
    expect(pairOk.type).toBe("pair-ok");
    expect(typeof pairOk.token).toBe("string");
    if (!pairOk.token) throw new Error("expected pair-ok token");
    const token = pairOk.token;

    const helloOk = JSON.parse(await readLine()) as { type: string };
    expect(helloOk.type).toBe("hello-ok");

    const responder = (async () => {
      while (true) {
        const frame = JSON.parse(await readLine()) as {
          type: string;
          id?: string;
          command?: string;
        };
        if (frame.type !== "invoke") continue;
        sendLine(socket, {
          type: "invoke-res",
          id: frame.id,
          ok: true,
          payloadJSON: JSON.stringify({ echo: frame.command }),
        });
        break;
      }
    })();

    const res = await server.invoke({
      nodeId: "n5",
      command: "canvas.eval",
      paramsJSON: JSON.stringify({ javaScript: "1+1" }),
      timeoutMs: 3000,
    });

    expect(res.ok).toBe(true);
    const payload = JSON.parse(String(res.payloadJSON ?? "null")) as {
      echo?: string;
    };
    expect(payload.echo).toBe("canvas.eval");

    await responder;
    socket.destroy();

    // Ensure invoke works only for connected nodes (hello with token on a new socket).
    const socket2 = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine2 = createLineReader(socket2);
    sendLine(socket2, { type: "hello", nodeId: "n5", token });
    const hello2 = JSON.parse(await readLine2()) as { type: string };
    expect(hello2.type).toBe("hello-ok");
    socket2.destroy();

    await server.close();
  });

  it("tracks connected node caps and hardware identifiers", async () => {
    const server = await startNodeBridgeServer({
      host: "127.0.0.1",
      port: 0,
      pairingBaseDir: baseDir,
    });

    const socket = net.connect({ host: "127.0.0.1", port: server.port });
    const readLine = createLineReader(socket);
    sendLine(socket, {
      type: "pair-request",
      nodeId: "n-caps",
      displayName: "Node",
      platform: "ios",
      version: "1.0",
      deviceFamily: "iPad",
      modelIdentifier: "iPad14,5",
      caps: ["canvas", "camera"],
      commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
      permissions: { accessibility: true },
    });

    // Approve the pending request from the gateway side.
    let reqId: string | undefined;
    for (let i = 0; i < 40; i += 1) {
      const list = await listNodePairing(baseDir);
      const req = list.pending.find((p) => p.nodeId === "n-caps");
      if (req) {
        reqId = req.requestId;
        break;
      }
      await new Promise((r) => setTimeout(r, 25));
    }
    expect(reqId).toBeTruthy();
    if (!reqId) throw new Error("expected a pending requestId");
    await approveNodePairing(reqId, baseDir);

    const pairOk = JSON.parse(await readLine()) as { type: string };
    expect(pairOk.type).toBe("pair-ok");
    const helloOk = JSON.parse(await readLine()) as { type: string };
    expect(helloOk.type).toBe("hello-ok");

    const connected = server.listConnected();
    const node = connected.find((n) => n.nodeId === "n-caps");
    expect(node?.deviceFamily).toBe("iPad");
    expect(node?.modelIdentifier).toBe("iPad14,5");
    expect(node?.caps).toEqual(["canvas", "camera"]);
    expect(node?.commands).toEqual([
      "canvas.eval",
      "canvas.snapshot",
      "camera.snap",
    ]);
    expect(node?.permissions).toEqual({ accessibility: true });

    const after = await listNodePairing(baseDir);
    const paired = after.paired.find((p) => p.nodeId === "n-caps");
    expect(paired?.caps).toEqual(["canvas", "camera"]);
    expect(paired?.commands).toEqual([
      "canvas.eval",
      "canvas.snapshot",
      "camera.snap",
    ]);
    expect(paired?.permissions).toEqual({ accessibility: true });

    socket.destroy();
    await server.close();
  });
});
