import { createServer } from "node:http";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import WebSocket from "ws";
import { captureEnv } from "../test-utils/env.js";
import {
  ensureChromeExtensionRelayServer,
  getChromeExtensionRelayAuthHeaders,
  stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { getFreePort } from "./test-port.js";

const RELAY_MESSAGE_TIMEOUT_MS = 1_200;
const RELAY_LIST_MATCH_TIMEOUT_MS = 1_000;
const RELAY_TEST_TIMEOUT_MS = 10_000;

function waitForOpen(ws: WebSocket) {
  return new Promise<void>((resolve, reject) => {
    ws.once("open", () => resolve());
    ws.once("error", reject);
  });
}

function waitForError(ws: WebSocket) {
  return new Promise<Error>((resolve, reject) => {
    ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err))));
    ws.once("open", () => reject(new Error("expected websocket error")));
  });
}

function waitForClose(ws: WebSocket, timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) {
  return new Promise<void>((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error("timeout"));
    }, timeoutMs);
    ws.once("close", () => {
      clearTimeout(timer);
      resolve();
    });
    ws.once("error", (err) => {
      clearTimeout(timer);
      reject(err instanceof Error ? err : new Error(String(err)));
    });
  });
}

function relayAuthHeaders(url: string) {
  return getChromeExtensionRelayAuthHeaders(url);
}

function createMessageQueue(ws: WebSocket) {
  const queue: string[] = [];
  let waiter: ((value: string) => void) | null = null;
  let waiterReject: ((err: Error) => void) | null = null;
  let waiterTimer: NodeJS.Timeout | null = null;

  const flushWaiter = (value: string) => {
    if (!waiter) {
      return false;
    }
    const resolve = waiter;
    waiter = null;
    const reject = waiterReject;
    waiterReject = null;
    if (waiterTimer) {
      clearTimeout(waiterTimer);
    }
    waiterTimer = null;
    if (reject) {
      // no-op (kept for symmetry)
    }
    resolve(value);
    return true;
  };

  ws.on("message", (data) => {
    const text =
      typeof data === "string"
        ? data
        : Buffer.isBuffer(data)
          ? data.toString("utf8")
          : Array.isArray(data)
            ? Buffer.concat(data).toString("utf8")
            : Buffer.from(data).toString("utf8");
    if (flushWaiter(text)) {
      return;
    }
    queue.push(text);
  });

  ws.on("error", (err) => {
    if (!waiterReject) {
      return;
    }
    const reject = waiterReject;
    waiterReject = null;
    waiter = null;
    if (waiterTimer) {
      clearTimeout(waiterTimer);
    }
    waiterTimer = null;
    reject(err instanceof Error ? err : new Error(String(err)));
  });

  const next = (timeoutMs = RELAY_MESSAGE_TIMEOUT_MS) =>
    new Promise<string>((resolve, reject) => {
      const existing = queue.shift();
      if (existing !== undefined) {
        return resolve(existing);
      }
      waiter = resolve;
      waiterReject = reject;
      waiterTimer = setTimeout(() => {
        waiter = null;
        waiterReject = null;
        waiterTimer = null;
        reject(new Error("timeout"));
      }, timeoutMs);
    });

  return { next };
}

async function waitForListMatch<T>(
  fetchList: () => Promise<T>,
  predicate: (value: T) => boolean,
  timeoutMs = RELAY_LIST_MATCH_TIMEOUT_MS,
  intervalMs = 20,
): Promise<T> {
  const deadline = Date.now() + timeoutMs;
  let latest: T | null = null;
  while (Date.now() <= deadline) {
    latest = await fetchList();
    if (predicate(latest)) {
      return latest;
    }
    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }
  throw new Error("timeout waiting for list match");
}

describe("chrome extension relay server", () => {
  const TEST_GATEWAY_TOKEN = "test-gateway-token";
  let cdpUrl = "";
  let sharedCdpUrl = "";
  let envSnapshot: ReturnType<typeof captureEnv>;

  beforeEach(() => {
    envSnapshot = captureEnv([
      "OPENCLAW_GATEWAY_TOKEN",
      "OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS",
      "OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS",
    ]);
    process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN;
    delete process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS;
    delete process.env.OPENCLAW_EXTENSION_RELAY_COMMAND_RECONNECT_WAIT_MS;
  });

  afterEach(async () => {
    if (cdpUrl) {
      await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {});
      cdpUrl = "";
    }
    envSnapshot.restore();
  });

  afterAll(async () => {
    if (!sharedCdpUrl) {
      return;
    }
    await stopChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl }).catch(() => {});
    sharedCdpUrl = "";
  });

  async function ensureSharedRelayServer() {
    if (sharedCdpUrl) {
      return sharedCdpUrl;
    }
    const port = await getFreePort();
    sharedCdpUrl = `http://127.0.0.1:${port}`;
    await ensureChromeExtensionRelayServer({ cdpUrl: sharedCdpUrl });
    return sharedCdpUrl;
  }

  async function startRelayWithExtension() {
    const port = await getFreePort();
    cdpUrl = `http://127.0.0.1:${port}`;
    await ensureChromeExtensionRelayServer({ cdpUrl });
    const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
    });
    await waitForOpen(ext);
    return { port, ext };
  }

  it("advertises CDP WS only when extension is connected", async () => {
    const port = await getFreePort();
    cdpUrl = `http://127.0.0.1:${port}`;
    await ensureChromeExtensionRelayServer({ cdpUrl });

    const v1 = (await fetch(`${cdpUrl}/json/version`, {
      headers: relayAuthHeaders(cdpUrl),
    }).then((r) => r.json())) as {
      webSocketDebuggerUrl?: string;
    };
    expect(v1.webSocketDebuggerUrl).toBeUndefined();

    const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
    });
    await waitForOpen(ext);

    const v2 = (await fetch(`${cdpUrl}/json/version`, {
      headers: relayAuthHeaders(cdpUrl),
    }).then((r) => r.json())) as {
      webSocketDebuggerUrl?: string;
    };
    expect(String(v2.webSocketDebuggerUrl ?? "")).toContain(`/cdp`);

    ext.close();
  });

  it("uses relay-scoped token only for known relay ports", async () => {
    const port = await getFreePort();
    const unknown = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`);
    expect(unknown).toEqual({});

    const sharedUrl = await ensureSharedRelayServer();

    const headers = getChromeExtensionRelayAuthHeaders(sharedUrl);
    expect(Object.keys(headers)).toContain("x-openclaw-relay-token");
    expect(headers["x-openclaw-relay-token"]).not.toBe(TEST_GATEWAY_TOKEN);
  });

  it("rejects CDP access without relay auth token", async () => {
    const sharedUrl = await ensureSharedRelayServer();
    const sharedPort = new URL(sharedUrl).port;

    const res = await fetch(`${sharedUrl}/json/version`);
    expect(res.status).toBe(401);

    const cdp = new WebSocket(`ws://127.0.0.1:${sharedPort}/cdp`);
    const err = await waitForError(cdp);
    expect(err.message).toContain("401");
  });

  it("returns 400 for malformed percent-encoding in target action routes", async () => {
    const sharedUrl = await ensureSharedRelayServer();

    const res = await fetch(`${sharedUrl}/json/activate/%E0%A4%A`, {
      headers: relayAuthHeaders(sharedUrl),
    });
    expect(res.status).toBe(400);
    expect(await res.text()).toContain("invalid targetId encoding");
  });

  it("deduplicates concurrent relay starts for the same requested port", async () => {
    const sharedUrl = await ensureSharedRelayServer();
    const port = Number(new URL(sharedUrl).port);
    const [first, second] = await Promise.all([
      ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }),
      ensureChromeExtensionRelayServer({ cdpUrl: sharedUrl }),
    ]);
    expect(first).toBe(second);
    expect(first.port).toBe(port);
  });

  it("allows CORS preflight from chrome-extension origins", async () => {
    const sharedUrl = await ensureSharedRelayServer();

    const origin = "chrome-extension://abcdefghijklmnop";
    const res = await fetch(`${sharedUrl}/json/version`, {
      method: "OPTIONS",
      headers: {
        Origin: origin,
        "Access-Control-Request-Method": "GET",
        "Access-Control-Request-Headers": "x-openclaw-relay-token",
      },
    });

    expect(res.status).toBe(204);
    expect(res.headers.get("access-control-allow-origin")).toBe(origin);
    expect(res.headers.get("access-control-allow-headers") ?? "").toContain(
      "x-openclaw-relay-token",
    );
  });

  it("rejects CORS preflight from non-extension origins", async () => {
    const sharedUrl = await ensureSharedRelayServer();

    const res = await fetch(`${sharedUrl}/json/version`, {
      method: "OPTIONS",
      headers: {
        Origin: "https://example.com",
        "Access-Control-Request-Method": "GET",
      },
    });

    expect(res.status).toBe(403);
  });

  it("returns CORS headers on JSON responses for extension origins", async () => {
    const sharedUrl = await ensureSharedRelayServer();

    const origin = "chrome-extension://abcdefghijklmnop";
    const res = await fetch(`${sharedUrl}/json/version`, {
      headers: {
        Origin: origin,
        ...relayAuthHeaders(sharedUrl),
      },
    });

    expect(res.status).toBe(200);
    expect(res.headers.get("access-control-allow-origin")).toBe(origin);
  });

  it("rejects extension websocket access without relay auth token", async () => {
    const sharedUrl = await ensureSharedRelayServer();
    const sharedPort = new URL(sharedUrl).port;

    const ext = new WebSocket(`ws://127.0.0.1:${sharedPort}/extension`);
    const err = await waitForError(ext);
    expect(err.message).toContain("401");
  });

  it("rejects a second live extension connection with 409", async () => {
    const port = await getFreePort();
    cdpUrl = `http://127.0.0.1:${port}`;
    await ensureChromeExtensionRelayServer({ cdpUrl });

    const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
    });
    await waitForOpen(ext1);

    const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
    });
    const err = await waitForError(ext2);
    expect(err.message).toContain("409");

    ext1.close();
  });

  it("allows immediate reconnect when prior extension socket is closing", async () => {
    const port = await getFreePort();
    cdpUrl = `http://127.0.0.1:${port}`;
    await ensureChromeExtensionRelayServer({ cdpUrl });

    const ext1 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
    });
    await waitForOpen(ext1);
    const ext1Closed = new Promise<void>((resolve) => ext1.once("close", () => resolve()));

    ext1.close();
    const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
    });
    await waitForOpen(ext2);
    await ext1Closed;

    const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as {
      connected?: boolean;
    };
    expect(status.connected).toBe(true);

    ext2.close();
  });

  it("keeps CDP clients alive across a brief extension reconnect", async () => {
    const { port, ext: ext1 } = await startRelayWithExtension();
    const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
    });
    await waitForOpen(cdp);

    let cdpClosed = false;
    cdp.once("close", () => {
      cdpClosed = true;
    });

    const ext1Closed = waitForClose(ext1, 2_000);
    ext1.close();
    await ext1Closed;
    const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
    });
    await waitForOpen(ext2);
    expect(cdpClosed).toBe(false);

    cdp.close();
    ext2.close();
  });

  it("keeps /json/version websocket endpoint during short extension disconnects", async () => {
    const { port, ext } = await startRelayWithExtension();
    ext.send(
      JSON.stringify({
        method: "forwardCDPEvent",
        params: {
          method: "Target.attachedToTarget",
          params: {
            sessionId: "cb-tab-disconnect",
            targetInfo: {
              targetId: "t-disconnect",
              type: "page",
              title: "Disconnect test",
              url: "https://example.com",
            },
            waitingForDebugger: false,
          },
        },
      }),
    );

    await waitForListMatch(
      async () =>
        (await fetch(`${cdpUrl}/json/list`, {
          headers: relayAuthHeaders(cdpUrl),
        }).then((r) => r.json())) as Array<{ id?: string }>,
      (list) => list.some((entry) => entry.id === "t-disconnect"),
    );

    const extClosed = waitForClose(ext, 2_000);
    ext.close();
    await extClosed;

    const version = (await fetch(`${cdpUrl}/json/version`, {
      headers: relayAuthHeaders(cdpUrl),
    }).then((r) => r.json())) as {
      webSocketDebuggerUrl?: string;
    };
    expect(String(version.webSocketDebuggerUrl ?? "")).toContain("/cdp");

    const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
    });
    await waitForOpen(cdp);
    cdp.close();
  });

  it("accepts re-announce attach events with minimal targetInfo", async () => {
    const { ext } = await startRelayWithExtension();
    ext.send(
      JSON.stringify({
        method: "forwardCDPEvent",
        params: {
          method: "Target.attachedToTarget",
          params: {
            sessionId: "cb-tab-minimal",
            targetInfo: {
              targetId: "t-minimal",
            },
            waitingForDebugger: false,
          },
        },
      }),
    );

    await waitForListMatch(
      async () =>
        (await fetch(`${cdpUrl}/json/list`, {
          headers: relayAuthHeaders(cdpUrl),
        }).then((r) => r.json())) as Array<{ id?: string }>,
      (entries) => entries.some((entry) => entry.id === "t-minimal"),
    );
  });

  it("waits briefly for extension reconnect before failing CDP commands", async () => {
    const { port, ext: ext1 } = await startRelayWithExtension();
    const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
    });
    await waitForOpen(cdp);
    const cdpQueue = createMessageQueue(cdp);

    const ext1Closed = waitForClose(ext1, 2_000);
    ext1.close();
    await ext1Closed;

    cdp.send(JSON.stringify({ id: 41, method: "Runtime.enable" }));
    await new Promise((r) => setTimeout(r, 30));

    const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
    });
    const ext2Queue = createMessageQueue(ext2);
    await waitForOpen(ext2);

    while (true) {
      const msg = JSON.parse(await ext2Queue.next(4_000)) as {
        id?: number;
        method?: string;
      };
      if (msg.method === "ping") {
        ext2.send(JSON.stringify({ method: "pong" }));
        continue;
      }
      if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") {
        ext2.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
        break;
      }
    }

    const response = JSON.parse(await cdpQueue.next(6_000)) as {
      id?: number;
      result?: { ok?: boolean };
      error?: { message?: string };
    };
    expect(response.id).toBe(41);
    expect(response.error).toBeUndefined();
    expect(response.result?.ok).toBe(true);

    cdp.close();
    ext2.close();
  });

  it("closes CDP clients after reconnect grace when extension stays disconnected", async () => {
    process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "150";

    const { port, ext } = await startRelayWithExtension();
    const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
    });
    await waitForOpen(cdp);

    ext.close();
    await waitForClose(cdp, 2_000);
  });

  it("stops advertising websocket endpoint after reconnect grace expires", async () => {
    process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "120";

    const { ext } = await startRelayWithExtension();
    ext.send(
      JSON.stringify({
        method: "forwardCDPEvent",
        params: {
          method: "Target.attachedToTarget",
          params: {
            sessionId: "cb-tab-grace-expire",
            targetInfo: {
              targetId: "t-grace-expire",
              type: "page",
              title: "Grace expire",
              url: "https://example.com",
            },
            waitingForDebugger: false,
          },
        },
      }),
    );

    await waitForListMatch(
      async () =>
        (await fetch(`${cdpUrl}/json/list`, {
          headers: relayAuthHeaders(cdpUrl),
        }).then((r) => r.json())) as Array<{ id?: string }>,
      (list) => list.some((entry) => entry.id === "t-grace-expire"),
    );

    ext.close();
    await expect
      .poll(
        async () => {
          const version = (await fetch(`${cdpUrl}/json/version`, {
            headers: relayAuthHeaders(cdpUrl),
          }).then((r) => r.json())) as { webSocketDebuggerUrl?: string };
          return version.webSocketDebuggerUrl === undefined;
        },
        { timeout: 800, interval: 20 },
      )
      .toBe(true);
  });

  it("accepts extension websocket access with relay token query param", async () => {
    const sharedUrl = await ensureSharedRelayServer();
    const sharedPort = new URL(sharedUrl).port;

    const token = relayAuthHeaders(`ws://127.0.0.1:${sharedPort}/extension`)[
      "x-openclaw-relay-token"
    ];
    expect(token).toBeTruthy();
    const ext = new WebSocket(
      `ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(String(token))}`,
    );
    await waitForOpen(ext);
    ext.close();
  });

  it("accepts /json endpoints with relay token query param", async () => {
    const sharedUrl = await ensureSharedRelayServer();

    const token = relayAuthHeaders(sharedUrl)["x-openclaw-relay-token"];
    expect(token).toBeTruthy();
    const versionRes = await fetch(
      `${sharedUrl}/json/version?token=${encodeURIComponent(String(token))}`,
    );
    expect(versionRes.status).toBe(200);
  });

  it("accepts raw gateway token for relay auth compatibility", async () => {
    const sharedUrl = await ensureSharedRelayServer();
    const sharedPort = new URL(sharedUrl).port;

    const versionRes = await fetch(`${sharedUrl}/json/version`, {
      headers: { "x-openclaw-relay-token": TEST_GATEWAY_TOKEN },
    });
    expect(versionRes.status).toBe(200);

    const ext = new WebSocket(
      `ws://127.0.0.1:${sharedPort}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`,
    );
    await waitForOpen(ext);
    ext.close();
  });

  it(
    "tracks attached page targets and exposes them via CDP + /json/list",
    async () => {
      const { port, ext } = await startRelayWithExtension();

      // Simulate a tab attach coming from the extension.
      ext.send(
        JSON.stringify({
          method: "forwardCDPEvent",
          params: {
            method: "Target.attachedToTarget",
            params: {
              sessionId: "cb-tab-1",
              targetInfo: {
                targetId: "t1",
                type: "page",
                title: "Example",
                url: "https://example.com",
              },
              waitingForDebugger: false,
            },
          },
        }),
      );

      const list = (await fetch(`${cdpUrl}/json/list`, {
        headers: relayAuthHeaders(cdpUrl),
      }).then((r) => r.json())) as Array<{
        id?: string;
        url?: string;
        title?: string;
      }>;
      expect(list.some((t) => t.id === "t1" && t.url === "https://example.com")).toBe(true);

      // Simulate navigation updating tab metadata.
      ext.send(
        JSON.stringify({
          method: "forwardCDPEvent",
          params: {
            method: "Target.targetInfoChanged",
            params: {
              targetInfo: {
                targetId: "t1",
                type: "page",
                title: "DER STANDARD",
                url: "https://www.derstandard.at/",
              },
            },
          },
        }),
      );

      await waitForListMatch(
        async () =>
          (await fetch(`${cdpUrl}/json/list`, {
            headers: relayAuthHeaders(cdpUrl),
          }).then((r) => r.json())) as Array<{
            id?: string;
            url?: string;
            title?: string;
          }>,
        (list) =>
          list.some(
            (t) =>
              t.id === "t1" &&
              t.url === "https://www.derstandard.at/" &&
              t.title === "DER STANDARD",
          ),
      );

      const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
        headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
      });
      await waitForOpen(cdp);
      const q = createMessageQueue(cdp);

      cdp.send(JSON.stringify({ id: 1, method: "Target.getTargets" }));
      const res1 = JSON.parse(await q.next()) as { id: number; result?: unknown };
      expect(res1.id).toBe(1);
      const targetInfos = (
        res1.result as { targetInfos?: Array<{ targetId?: string }> } | undefined
      )?.targetInfos;
      expect((targetInfos ?? []).some((target) => target.targetId === "t1")).toBe(true);

      cdp.send(
        JSON.stringify({
          id: 2,
          method: "Target.attachToTarget",
          params: { targetId: "t1" },
        }),
      );
      const received: Array<{
        id?: number;
        method?: string;
        result?: unknown;
        params?: unknown;
      }> = [];
      received.push(JSON.parse(await q.next()) as never);
      received.push(JSON.parse(await q.next()) as never);

      const res2 = received.find((m) => m.id === 2);
      expect(res2?.id).toBe(2);
      expect((res2?.result as { sessionId?: string } | undefined)?.sessionId).toBe("cb-tab-1");

      const evt = received.find((m) => m.method === "Target.attachedToTarget");
      expect(evt?.method).toBe("Target.attachedToTarget");
      expect(
        (evt?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId,
      ).toBe("t1");

      cdp.close();
      ext.close();
    },
    RELAY_TEST_TIMEOUT_MS,
  );

  it("removes cached targets from /json/list when targetDestroyed arrives", async () => {
    const { ext } = await startRelayWithExtension();

    ext.send(
      JSON.stringify({
        method: "forwardCDPEvent",
        params: {
          method: "Target.attachedToTarget",
          params: {
            sessionId: "cb-tab-1",
            targetInfo: {
              targetId: "t1",
              type: "page",
              title: "Example",
              url: "https://example.com",
            },
            waitingForDebugger: false,
          },
        },
      }),
    );

    await waitForListMatch(
      async () =>
        (await fetch(`${cdpUrl}/json/list`, {
          headers: relayAuthHeaders(cdpUrl),
        }).then((r) => r.json())) as Array<{ id?: string }>,
      (list) => list.some((target) => target.id === "t1"),
    );

    ext.send(
      JSON.stringify({
        method: "forwardCDPEvent",
        params: {
          method: "Target.targetDestroyed",
          params: { targetId: "t1" },
        },
      }),
    );

    await waitForListMatch(
      async () =>
        (await fetch(`${cdpUrl}/json/list`, {
          headers: relayAuthHeaders(cdpUrl),
        }).then((r) => r.json())) as Array<{ id?: string }>,
      (list) => list.every((target) => target.id !== "t1"),
    );
    ext.close();
  });

  it("prunes stale cached targets after target-not-found command errors", async () => {
    const { port, ext } = await startRelayWithExtension();
    const extQueue = createMessageQueue(ext);

    ext.send(
      JSON.stringify({
        method: "forwardCDPEvent",
        params: {
          method: "Target.attachedToTarget",
          params: {
            sessionId: "cb-tab-1",
            targetInfo: {
              targetId: "t1",
              type: "page",
              title: "Example",
              url: "https://example.com",
            },
            waitingForDebugger: false,
          },
        },
      }),
    );

    await waitForListMatch(
      async () =>
        (await fetch(`${cdpUrl}/json/list`, {
          headers: relayAuthHeaders(cdpUrl),
        }).then((r) => r.json())) as Array<{ id?: string }>,
      (list) => list.some((target) => target.id === "t1"),
    );

    const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
    });
    await waitForOpen(cdp);
    const cdpQueue = createMessageQueue(cdp);

    cdp.send(
      JSON.stringify({
        id: 77,
        method: "Runtime.evaluate",
        sessionId: "cb-tab-1",
        params: { expression: "1+1" },
      }),
    );

    let forwardedId: number | null = null;
    for (let attempt = 0; attempt < 6; attempt++) {
      const msg = JSON.parse(await extQueue.next()) as { method?: string; id?: number };
      if (msg.method === "forwardCDPCommand" && typeof msg.id === "number") {
        forwardedId = msg.id;
        break;
      }
    }
    expect(forwardedId).not.toBeNull();

    ext.send(
      JSON.stringify({
        id: forwardedId,
        error: "No target with given id",
      }),
    );

    let response: { id?: number; error?: { message?: string } } | null = null;
    for (let attempt = 0; attempt < 6; attempt++) {
      const msg = JSON.parse(await cdpQueue.next()) as {
        id?: number;
        error?: { message?: string };
      };
      if (msg.id === 77) {
        response = msg;
        break;
      }
    }
    expect(response?.id).toBe(77);
    expect(response?.error?.message ?? "").toContain("No target with given id");

    await waitForListMatch(
      async () =>
        (await fetch(`${cdpUrl}/json/list`, {
          headers: relayAuthHeaders(cdpUrl),
        }).then((r) => r.json())) as Array<{ id?: string }>,
      (list) => list.every((target) => target.id !== "t1"),
    );

    cdp.close();
    ext.close();
  });

  it("rebroadcasts attach when a session id is reused for a new target", async () => {
    const { port, ext } = await startRelayWithExtension();

    const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, {
      headers: relayAuthHeaders(`ws://127.0.0.1:${port}/cdp`),
    });
    await waitForOpen(cdp);
    const q = createMessageQueue(cdp);

    ext.send(
      JSON.stringify({
        method: "forwardCDPEvent",
        params: {
          method: "Target.attachedToTarget",
          params: {
            sessionId: "shared-session",
            targetInfo: {
              targetId: "t1",
              type: "page",
              title: "First",
              url: "https://example.com",
            },
            waitingForDebugger: false,
          },
        },
      }),
    );

    const first = JSON.parse(await q.next()) as { method?: string; params?: unknown };
    expect(first.method).toBe("Target.attachedToTarget");
    expect(
      (first.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo?.targetId,
    ).toBe("t1");

    ext.send(
      JSON.stringify({
        method: "forwardCDPEvent",
        params: {
          method: "Target.attachedToTarget",
          params: {
            sessionId: "shared-session",
            targetInfo: {
              targetId: "t2",
              type: "page",
              title: "Second",
              url: "https://example.org",
            },
            waitingForDebugger: false,
          },
        },
      }),
    );

    const received: Array<{ method?: string; params?: unknown }> = [];
    received.push(JSON.parse(await q.next()) as never);
    received.push(JSON.parse(await q.next()) as never);

    const detached = received.find((m) => m.method === "Target.detachedFromTarget");
    const attached = received.find((m) => m.method === "Target.attachedToTarget");
    expect((detached?.params as { targetId?: string } | undefined)?.targetId).toBe("t1");
    expect(
      (attached?.params as { targetInfo?: { targetId?: string } } | undefined)?.targetInfo
        ?.targetId,
    ).toBe("t2");

    cdp.close();
    ext.close();
  });

  it("reuses an already-bound relay port when another process owns it", async () => {
    const port = await getFreePort();
    let probeToken: string | undefined;
    const fakeRelay = createServer((req, res) => {
      if (req.url?.startsWith("/json/version")) {
        const header = req.headers["x-openclaw-relay-token"];
        probeToken = Array.isArray(header) ? header[0] : header;
        if (!probeToken) {
          res.writeHead(401);
          res.end("Unauthorized");
          return;
        }
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ Browser: "OpenClaw/extension-relay" }));
        return;
      }
      if (req.url?.startsWith("/extension/status")) {
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ connected: false }));
        return;
      }
      res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
      res.end("OK");
    });
    await new Promise<void>((resolve, reject) => {
      fakeRelay.listen(port, "127.0.0.1", () => resolve());
      fakeRelay.once("error", reject);
    });

    try {
      cdpUrl = `http://127.0.0.1:${port}`;
      const relay = await ensureChromeExtensionRelayServer({ cdpUrl });
      expect(relay.port).toBe(port);
      const status = (await fetch(`${cdpUrl}/extension/status`).then((r) => r.json())) as {
        connected?: boolean;
      };
      expect(status.connected).toBe(false);
      expect(probeToken).toBeTruthy();
      expect(probeToken).not.toBe("test-gateway-token");
    } finally {
      await new Promise<void>((resolve) => fakeRelay.close(() => resolve()));
    }
  });

  it(
    "restores tabs after extension reconnects and re-announces",
    async () => {
      process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "200";

      const { port, ext: ext1 } = await startRelayWithExtension();

      ext1.send(
        JSON.stringify({
          method: "forwardCDPEvent",
          params: {
            method: "Target.attachedToTarget",
            params: {
              sessionId: "cb-tab-10",
              targetInfo: {
                targetId: "t10",
                type: "page",
                title: "My Page",
                url: "https://example.com",
              },
              waitingForDebugger: false,
            },
          },
        }),
      );

      await waitForListMatch(
        async () =>
          (await fetch(`${cdpUrl}/json/list`, {
            headers: relayAuthHeaders(cdpUrl),
          }).then((r) => r.json())) as Array<{ id?: string }>,
        (list) => list.some((t) => t.id === "t10"),
      );

      // Disconnect extension and wait for grace period cleanup.
      const ext1Closed = waitForClose(ext1, 2_000);
      ext1.close();
      await ext1Closed;
      await waitForListMatch(
        async () =>
          (await fetch(`${cdpUrl}/json/list`, {
            headers: relayAuthHeaders(cdpUrl),
          }).then((r) => r.json())) as Array<{ id?: string }>,
        (list) => list.length === 0,
      );

      // Reconnect and re-announce the same tab (simulates reannounceAttachedTabs).
      const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
        headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
      });
      await waitForOpen(ext2);

      ext2.send(
        JSON.stringify({
          method: "forwardCDPEvent",
          params: {
            method: "Target.attachedToTarget",
            params: {
              sessionId: "cb-tab-10",
              targetInfo: {
                targetId: "t10",
                type: "page",
                title: "My Page",
                url: "https://example.com",
              },
              waitingForDebugger: false,
            },
          },
        }),
      );

      const list2 = await waitForListMatch(
        async () =>
          (await fetch(`${cdpUrl}/json/list`, {
            headers: relayAuthHeaders(cdpUrl),
          }).then((r) => r.json())) as Array<{ id?: string; title?: string }>,
        (list) => list.some((t) => t.id === "t10"),
      );
      expect(list2.some((t) => t.id === "t10" && t.title === "My Page")).toBe(true);

      ext2.close();
    },
    RELAY_TEST_TIMEOUT_MS,
  );

  it(
    "preserves tab across a fast extension reconnect within grace period",
    async () => {
      process.env.OPENCLAW_EXTENSION_RELAY_RECONNECT_GRACE_MS = "2000";

      const { port, ext: ext1 } = await startRelayWithExtension();

      ext1.send(
        JSON.stringify({
          method: "forwardCDPEvent",
          params: {
            method: "Target.attachedToTarget",
            params: {
              sessionId: "cb-tab-20",
              targetInfo: {
                targetId: "t20",
                type: "page",
                title: "Persistent",
                url: "https://example.org",
              },
              waitingForDebugger: false,
            },
          },
        }),
      );

      await waitForListMatch(
        async () =>
          (await fetch(`${cdpUrl}/json/list`, {
            headers: relayAuthHeaders(cdpUrl),
          }).then((r) => r.json())) as Array<{ id?: string }>,
        (list) => list.some((t) => t.id === "t20"),
      );

      // Disconnect briefly (within grace period).
      const ext1Closed = waitForClose(ext1, 2_000);
      ext1.close();
      await ext1Closed;

      // Tab should still be listed during grace period.
      const listDuringGrace = (await fetch(`${cdpUrl}/json/list`, {
        headers: relayAuthHeaders(cdpUrl),
      }).then((r) => r.json())) as Array<{ id?: string }>;
      expect(listDuringGrace.some((t) => t.id === "t20")).toBe(true);

      // Reconnect within grace and re-announce with updated info.
      const ext2 = new WebSocket(`ws://127.0.0.1:${port}/extension`, {
        headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`),
      });
      await waitForOpen(ext2);

      ext2.send(
        JSON.stringify({
          method: "forwardCDPEvent",
          params: {
            method: "Target.attachedToTarget",
            params: {
              sessionId: "cb-tab-20",
              targetInfo: {
                targetId: "t20",
                type: "page",
                title: "Persistent Updated",
                url: "https://example.org/new",
              },
              waitingForDebugger: false,
            },
          },
        }),
      );

      const list2 = await waitForListMatch(
        async () =>
          (await fetch(`${cdpUrl}/json/list`, {
            headers: relayAuthHeaders(cdpUrl),
          }).then((r) => r.json())) as Array<{ id?: string; title?: string; url?: string }>,
        (list) => list.some((t) => t.id === "t20" && t.title === "Persistent Updated"),
      );
      expect(list2.some((t) => t.id === "t20" && t.url === "https://example.org/new")).toBe(true);

      ext2.close();
    },
    RELAY_TEST_TIMEOUT_MS,
  );

  it("does not swallow EADDRINUSE when occupied port is not an openclaw relay", async () => {
    const port = await getFreePort();
    const blocker = createServer((_, res) => {
      res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
      res.end("not-relay");
    });
    await new Promise<void>((resolve, reject) => {
      blocker.listen(port, "127.0.0.1", () => resolve());
      blocker.once("error", reject);
    });
    const blockedUrl = `http://127.0.0.1:${port}`;
    await expect(ensureChromeExtensionRelayServer({ cdpUrl: blockedUrl })).rejects.toThrow(
      /EADDRINUSE/i,
    );
    await new Promise<void>((resolve) => blocker.close(() => resolve()));
  });

  it(
    "respects bindHost override to bind on a non-loopback address",
    async () => {
      const port = await getFreePort();
      cdpUrl = `http://127.0.0.1:${port}`;
      const relay = await ensureChromeExtensionRelayServer({
        cdpUrl,
        bindHost: "0.0.0.0",
      });
      expect(relay.port).toBe(port);
      // Verify the server actually bound to 0.0.0.0, not the cdpUrl host.
      expect(relay.bindHost).toBe("0.0.0.0");

      const res = await fetch(`http://127.0.0.1:${port}/`);
      expect(res.status).toBe(200);
    },
    RELAY_TEST_TIMEOUT_MS,
  );

  it(
    "defaults bindHost to cdpUrl host when not specified",
    async () => {
      const port = await getFreePort();
      cdpUrl = `http://127.0.0.1:${port}`;
      const relay = await ensureChromeExtensionRelayServer({ cdpUrl });
      expect(relay.host).toBe("127.0.0.1");
      expect(relay.bindHost).toBe("127.0.0.1");

      const res = await fetch(`http://127.0.0.1:${port}/`);
      expect(res.status).toBe(200);
    },
    RELAY_TEST_TIMEOUT_MS,
  );

  it(
    "restarts the relay when bindHost changes for the same port",
    async () => {
      const port = await getFreePort();
      cdpUrl = `http://127.0.0.1:${port}`;

      const initial = await ensureChromeExtensionRelayServer({ cdpUrl });
      expect(initial.bindHost).toBe("127.0.0.1");

      const rebound = await ensureChromeExtensionRelayServer({
        cdpUrl,
        bindHost: "0.0.0.0",
      });
      expect(rebound.bindHost).toBe("0.0.0.0");
      expect(rebound.port).toBe(port);
    },
    RELAY_TEST_TIMEOUT_MS,
  );
});
