// Discord tests cover provider.proxy plugin behavior.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

function createGatewayInfoBody(overrides?: {
  url?: string;
  shards?: number;
  maxConcurrency?: number;
}): string {
  return JSON.stringify({
    url: overrides?.url ?? "wss://gateway.discord.gg",
    shards: overrides?.shards ?? 1,
    session_start_limit: {
      total: 1000,
      remaining: 999,
      reset_after: 120_000,
      max_concurrency: overrides?.maxConcurrency ?? 1,
    },
  });
}

function resolveGatewayInfoFetch(resolve: ((value: Response) => void) | undefined): void {
  if (!resolve) {
    throw new Error("expected pending gateway info fetch resolver");
  }
  resolve({
    ok: true,
    status: 200,
    text: async () => createGatewayInfoBody(),
  } as Response);
}

const {
  GatewayIntents,
  baseRegisterClientSpy,
  captureHttpExchangeSpy,
  captureWsEventSpy,
  GatewayPlugin,
  globalFetchMock,
  HttpsAgent,
  HttpsProxyAgent,
  fetchWithSsrFGuardMock,
  getLastAgent,
  getLastProxyAgent,
  resolveDebugProxySettingsMock,
  resetLastAgent,
  webSocketSpy,
  httpsAgentSpy,
  wsProxyAgentSpy,
} = vi.hoisted(() => {
  const wsProxyAgentSpyLocal = vi.fn();
  const httpsAgentSpyLocal = vi.fn();
  const globalFetchMockLocal = vi.fn();
  const baseRegisterClientSpyLocal = vi.fn();
  const webSocketSpyLocal = vi.fn();
  const captureHttpExchangeSpyLocal = vi.fn();
  const captureWsEventSpyLocal = vi.fn();
  const resolveDebugProxySettingsMockLocal = vi.fn(() => ({ enabled: false }));
  const fetchWithSsrFGuardMockLocal = vi.fn(async (params: { url: string; init?: RequestInit }) => {
    const source = (await globalFetchMockLocal(params.url, params.init)) as Response;
    const body = await source.text();
    return {
      response: new Response(body, {
        status: source.status,
        statusText: source.statusText,
        headers: source.headers,
      }),
      release: vi.fn(),
    };
  });

  const GatewayIntentsLocal = {
    Guilds: 1 << 0,
    GuildMessages: 1 << 1,
    MessageContent: 1 << 2,
    DirectMessages: 1 << 3,
    GuildMessageReactions: 1 << 4,
    DirectMessageReactions: 1 << 5,
    GuildPresences: 1 << 6,
    GuildMembers: 1 << 7,
    GuildVoiceStates: 1 << 8,
  } as const;

  class GatewayPluginLocal {
    options: unknown;
    gatewayInfo: unknown;
    client: unknown;
    ws: unknown;
    isConnecting: boolean;
    constructor(options?: unknown, gatewayInfo?: unknown) {
      this.options = options;
      this.gatewayInfo = gatewayInfo;
      this.client = undefined;
      this.ws = undefined;
      this.isConnecting = false;
    }
    async registerClient(client: unknown) {
      baseRegisterClientSpyLocal(client);
    }
  }

  class HttpsAgentLocal {
    static lastCreated: HttpsAgentLocal | undefined;
    options: unknown;
    constructor(options?: unknown) {
      this.options = options;
      HttpsAgentLocal.lastCreated = this;
      httpsAgentSpyLocal(options);
    }
  }

  class HttpsProxyAgentLocal {
    static lastCreated: HttpsProxyAgentLocal | undefined;
    proxyUrl: string;
    constructor(proxyUrl: string) {
      if (proxyUrl === "bad-proxy") {
        throw new Error("bad proxy");
      }
      this.proxyUrl = proxyUrl;
      HttpsProxyAgentLocal.lastCreated = this;
      wsProxyAgentSpyLocal(proxyUrl);
    }
  }

  return {
    baseRegisterClientSpy: baseRegisterClientSpyLocal,
    GatewayIntents: GatewayIntentsLocal,
    GatewayPlugin: GatewayPluginLocal,
    globalFetchMock: globalFetchMockLocal,
    HttpsAgent: HttpsAgentLocal,
    HttpsProxyAgent: HttpsProxyAgentLocal,
    fetchWithSsrFGuardMock: fetchWithSsrFGuardMockLocal,
    getLastAgent: () => HttpsAgentLocal.lastCreated,
    getLastProxyAgent: () => HttpsProxyAgentLocal.lastCreated,
    captureHttpExchangeSpy: captureHttpExchangeSpyLocal,
    captureWsEventSpy: captureWsEventSpyLocal,
    httpsAgentSpy: httpsAgentSpyLocal,
    resolveDebugProxySettingsMock: resolveDebugProxySettingsMockLocal,
    resetLastAgent: () => {
      HttpsAgentLocal.lastCreated = undefined;
      HttpsProxyAgentLocal.lastCreated = undefined;
    },
    webSocketSpy: webSocketSpyLocal,
    wsProxyAgentSpy: wsProxyAgentSpyLocal,
  };
});

// Unit test: don't import the real gateway just to check the prototype chain.
vi.mock("../internal/gateway.js", () => ({
  DISCORD_GATEWAY_WS_CLIENT_OPTIONS: { maxPayload: 16 * 1024 * 1024 },
  GatewayIntents,
  GatewayPlugin,
}));

vi.mock("node:https", () => ({
  Agent: HttpsAgent,
}));

vi.mock("ws", () => ({
  default: function MockWebSocket(
    url: string,
    options?: { agent?: unknown; handshakeTimeout?: number; maxPayload?: number },
  ) {
    webSocketSpy(url, options);
  },
}));

vi.mock("openclaw/plugin-sdk/proxy-capture", () => ({
  captureHttpExchange: captureHttpExchangeSpy,
  captureWsEvent: captureWsEventSpy,
  resolveEffectiveDebugProxyUrl: (configuredProxyUrl?: string) =>
    configuredProxyUrl?.trim() || process.env.OPENCLAW_DEBUG_PROXY_URL,
  resolveDebugProxySettings: resolveDebugProxySettingsMock,
}));

vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
  fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));

describe("createDiscordGatewayPlugin", () => {
  const proxyEnvKeys = [
    "OPENCLAW_PROXY_URL",
    "ALL_PROXY",
    "HTTPS_PROXY",
    "HTTP_PROXY",
    "NO_PROXY",
    "all_proxy",
    "https_proxy",
    "http_proxy",
    "no_proxy",
  ] as const;
  let createDiscordGatewayPlugin: typeof import("./gateway-plugin.js").createDiscordGatewayPlugin;
  let waitForDiscordGatewayPluginRegistration: typeof import("./gateway-plugin.js").waitForDiscordGatewayPluginRegistration;

  beforeAll(async () => {
    ({ createDiscordGatewayPlugin, waitForDiscordGatewayPluginRegistration } =
      await import("./gateway-plugin.js"));
  });

  function createRuntime() {
    return {
      log: vi.fn(),
      error: vi.fn(),
      exit: vi.fn(() => {
        throw new Error("exit");
      }),
    };
  }

  type MockWithCalls = { mock: { calls: unknown[][] } };

  function firstMockCall(mock: MockWithCalls, label: string): unknown[] {
    const call = mock.mock.calls.at(0);
    if (!call) {
      throw new Error(`expected ${label} call`);
    }
    return call;
  }

  function firstMockArg(mock: MockWithCalls, label: string, index = 0) {
    return firstMockCall(mock, label)[index];
  }

  function firstGuardedFetchCall() {
    return firstMockArg(fetchWithSsrFGuardMock, "fetchWithSsrFGuardMock") as {
      url: string;
      init?: RequestInit & { signal?: unknown };
      signal?: unknown;
      timeoutMs?: number;
      mode?: string;
      dispatcherPolicy?: unknown;
      policy?: unknown;
    };
  }

  function createProxyTestingOverrides(): NonNullable<
    Parameters<typeof createDiscordGatewayPlugin>[0]["testing"]
  > {
    return {
      createProxyAgent: (proxyUrl: string) =>
        new HttpsProxyAgent(proxyUrl) as unknown as import("node:http").Agent,
      webSocketCtor: function WebSocketCtor(
        url: string,
        options?: { agent?: unknown; handshakeTimeout?: number },
      ) {
        webSocketSpy(url, options);
      } as unknown as NonNullable<
        Parameters<typeof createDiscordGatewayPlugin>[0]["testing"]
      >["webSocketCtor"],
      registerClient: async (_plugin: unknown, client: unknown) => {
        baseRegisterClientSpy(client);
      },
    };
  }

  async function registerGatewayClient(plugin: unknown) {
    await (
      plugin as {
        registerClient: (client: {
          options: { token: string };
          registerListener: typeof baseRegisterClientSpy;
          unregisterListener: ReturnType<typeof vi.fn>;
        }) => Promise<void>;
      }
    ).registerClient({
      options: { token: "token-123" },
      registerListener: baseRegisterClientSpy,
      unregisterListener: vi.fn(),
    });
  }

  function startIgnoredGatewayRegistration(plugin: unknown) {
    void (
      plugin as {
        registerClient: (client: {
          options: { token: string };
          registerListener: typeof baseRegisterClientSpy;
          unregisterListener: ReturnType<typeof vi.fn>;
        }) => Promise<void>;
      }
    ).registerClient({
      options: { token: "token-123" },
      registerListener: baseRegisterClientSpy,
      unregisterListener: vi.fn(),
    });
  }

  async function expectGatewayRegisterFetchFailure(response: Response) {
    const runtime = createRuntime();
    globalFetchMock.mockResolvedValue(response);
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    await expect(registerGatewayClient(plugin)).rejects.toThrow(
      "Failed to get gateway information from Discord",
    );
    expect(baseRegisterClientSpy).not.toHaveBeenCalled();
  }

  async function expectGatewayRegisterFallback(response: Response) {
    const runtime = createRuntime();
    globalFetchMock.mockResolvedValue(response);
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    await registerGatewayClient(plugin);

    expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
    expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
      "wss://gateway.discord.gg/",
    );
    expect(runtime.log).toHaveBeenCalledTimes(1);
    expect(String(firstMockArg(runtime.log, "runtime.log"))).toContain(
      "discord: gateway metadata lookup failed transiently",
    );
  }

  async function registerGatewayClientWithMetadata(params: {
    plugin: unknown;
    fetchMock: typeof globalFetchMock;
  }) {
    params.fetchMock.mockResolvedValue({
      ok: true,
      status: 200,
      text: async () => createGatewayInfoBody(),
    } as Response);
    await registerGatewayClient(params.plugin);
  }

  beforeEach(() => {
    vi.unstubAllEnvs();
    for (const key of proxyEnvKeys) {
      vi.stubEnv(key, undefined);
    }
    vi.stubEnv("OPENCLAW_DEBUG_PROXY_ENABLED", "");
    vi.stubEnv("OPENCLAW_DEBUG_PROXY_URL", "");
    vi.stubGlobal("fetch", globalFetchMock);
    vi.useRealTimers();
    baseRegisterClientSpy.mockClear();
    globalFetchMock.mockClear();
    fetchWithSsrFGuardMock.mockClear();
    httpsAgentSpy.mockClear();
    wsProxyAgentSpy.mockClear();
    webSocketSpy.mockClear();
    captureHttpExchangeSpy.mockClear();
    captureWsEventSpy.mockClear();
    resolveDebugProxySettingsMock.mockReset().mockReturnValue({ enabled: false });
    resetLastAgent();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.unstubAllEnvs();
  });

  it("uses safe gateway metadata lookup without proxy", async () => {
    const runtime = createRuntime();
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock });

    expect(globalFetchMock).toHaveBeenCalledTimes(1);
    const fetchInit = firstMockArg(globalFetchMock, "globalFetchMock", 1) as
      | { headers?: Record<string, string>; signal?: unknown }
      | undefined;
    expect(firstMockArg(globalFetchMock, "globalFetchMock")).toBe(
      "https://discord.com/api/v10/gateway/bot",
    );
    expect(fetchInit?.headers).toEqual({ Authorization: "Bot token-123" });
    expect(fetchInit?.signal).toBeInstanceOf(AbortSignal);
    expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
  });

  it("uses ws for gateway sockets even without proxy", () => {
    const runtime = createRuntime();
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg");

    expect(httpsAgentSpy).toHaveBeenCalledTimes(1);
    const httpsAgentOptions = firstMockArg(httpsAgentSpy, "httpsAgentSpy") as
      | { lookup?: unknown }
      | undefined;
    expect(Object.keys(httpsAgentOptions ?? {})).toEqual(["lookup"]);
    expect(typeof httpsAgentOptions?.lookup).toBe("function");
    expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
      agent: getLastAgent(),
      handshakeTimeout: 30_000,
      maxPayload: 16 * 1024 * 1024,
    });
    expect(wsProxyAgentSpy).not.toHaveBeenCalled();
  });

  it("allocates a fresh websocket flow id for each gateway socket", () => {
    const runtime = createRuntime();
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg/?attempt=1");
    createWebSocket("wss://gateway.discord.gg/?attempt=2");

    const openCalls = captureWsEventSpy.mock.calls.filter(([event]) => event?.kind === "ws-open");
    expect(openCalls).toHaveLength(2);
    expect(openCalls[0]?.[0]?.flowId).not.toBe(openCalls[1]?.[0]?.flowId);
  });

  it("maps plain-text Discord 503 responses to fetch failed", async () => {
    await expectGatewayRegisterFallback({
      ok: false,
      status: 503,
      text: async () =>
        "upstream connect error or disconnect/reset before headers. reset reason: overflow",
    } as Response);
  });

  it("keeps fatal Discord metadata failures fatal", async () => {
    await expectGatewayRegisterFetchFailure({
      ok: false,
      status: 401,
      text: async () => "401: Unauthorized",
    } as Response);
  });

  it("keeps ignored fatal metadata failures handled for supervised startup", async () => {
    const runtime = createRuntime();
    const unhandledReasons: unknown[] = [];
    const onUnhandledRejection = (reason: unknown) => {
      unhandledReasons.push(reason);
    };
    globalFetchMock.mockResolvedValue({
      ok: false,
      status: 401,
      text: async () => "401: Unauthorized",
    } as Response);
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    process.on("unhandledRejection", onUnhandledRejection);
    try {
      startIgnoredGatewayRegistration(plugin);
      await new Promise((resolve) => {
        setImmediate(resolve);
      });

      expect(unhandledReasons).toHaveLength(0);
      const registration = waitForDiscordGatewayPluginRegistration(plugin);
      if (!registration) {
        throw new Error("expected Discord gateway registration promise");
      }
      await expect(registration).rejects.toThrow("Failed to get gateway information from Discord");
      expect(baseRegisterClientSpy).not.toHaveBeenCalled();
    } finally {
      process.off("unhandledRejection", onUnhandledRejection);
    }
  });

  it("exposes ignored successful registrations for startup await", async () => {
    const runtime = createRuntime();
    globalFetchMock.mockResolvedValue({
      ok: true,
      status: 200,
      text: async () => createGatewayInfoBody(),
    } as Response);
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    startIgnoredGatewayRegistration(plugin);
    const registration = waitForDiscordGatewayPluginRegistration(plugin);
    if (!registration) {
      throw new Error("expected Discord gateway registration promise");
    }
    await registration;

    expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
    expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
      "wss://gateway.discord.gg",
    );
  });

  it("uses proxy agent for gateway WebSocket when configured", () => {
    const runtime = createRuntime();

    const plugin = createDiscordGatewayPlugin({
      discordConfig: { proxy: "http://127.0.0.1:8080" },
      runtime,
      testing: createProxyTestingOverrides(),
    });

    expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg");

    expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://127.0.0.1:8080");
    expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
      agent: getLastProxyAgent(),
      handshakeTimeout: 30_000,
      maxPayload: 16 * 1024 * 1024,
    });
    expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
    expect(runtime.error).not.toHaveBeenCalled();
  });

  it("accepts configured DNS proxy hosts for gateway WebSocket", () => {
    const runtime = createRuntime();

    const plugin = createDiscordGatewayPlugin({
      discordConfig: { proxy: "http://mitm-proxy:8080" },
      runtime,
      testing: createProxyTestingOverrides(),
    });

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg");

    expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://mitm-proxy:8080");
    expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
      agent: getLastProxyAgent(),
      handshakeTimeout: 30_000,
      maxPayload: 16 * 1024 * 1024,
    });
    expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
    expect(runtime.error).not.toHaveBeenCalled();
  });

  it("uses the configured gateway proxy when proxy is arbitrary DNS", () => {
    const runtime = createRuntime();

    const plugin = createDiscordGatewayPlugin({
      discordConfig: { proxy: "http://proxy.test:8080" },
      runtime,
      testing: createProxyTestingOverrides(),
    });

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg");

    expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080");
    expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
      agent: getLastProxyAgent(),
      handshakeTimeout: 30_000,
      maxPayload: 16 * 1024 * 1024,
    });
    expect(runtime.error).not.toHaveBeenCalled();
    expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
  });

  it("keeps gateway WebSocket direct when only ambient proxy env is configured", () => {
    vi.stubEnv("https_proxy", "env-proxy.test:8080");
    const runtime = createRuntime();
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg");

    expect(httpsAgentSpy).toHaveBeenCalledTimes(1);
    expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
      agent: getLastAgent(),
      handshakeTimeout: 30_000,
      maxPayload: 16 * 1024 * 1024,
    });
    expect(runtime.log).not.toHaveBeenCalled();
  });

  it("uses explicit gateway proxy even when ambient proxy env is configured", () => {
    vi.stubEnv("https_proxy", "http://env-proxy.test:8080");
    const runtime = createRuntime();
    const plugin = createDiscordGatewayPlugin({
      discordConfig: { proxy: "http://127.0.0.1:8080" },
      runtime,
      testing: createProxyTestingOverrides(),
    });

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg");

    expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
      agent: getLastProxyAgent(),
      handshakeTimeout: 30_000,
      maxPayload: 16 * 1024 * 1024,
    });
    expect(runtime.log).toHaveBeenCalledTimes(1);
    expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
  });

  it("falls back to the default gateway plugin when proxy is invalid", () => {
    const runtime = createRuntime();

    const plugin = createDiscordGatewayPlugin({
      discordConfig: { proxy: "bad-proxy" },
      runtime,
    });

    expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype);
    expect(runtime.error).toHaveBeenCalled();
    expect(runtime.log).not.toHaveBeenCalled();
    expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
  });

  it("routes gateway metadata lookup through the guarded proxy dispatcher", async () => {
    const runtime = createRuntime();
    const plugin = createDiscordGatewayPlugin({
      discordConfig: { proxy: "http://127.0.0.1:8080" },
      runtime,
      testing: createProxyTestingOverrides(),
    });

    await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock });

    expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
    const guardedFetch = firstGuardedFetchCall();
    expect(guardedFetch.url).toBe("https://discord.com/api/v10/gateway/bot");
    expect(guardedFetch.mode).toBe("trusted_explicit_proxy");
    expect(guardedFetch.dispatcherPolicy).toEqual({
      mode: "explicit-proxy",
      proxyUrl: "http://127.0.0.1:8080",
      allowPrivateProxy: true,
    });
    expect(guardedFetch.policy).toEqual({ allowedHostnames: ["discord.com"] });
    expect(guardedFetch.init?.headers).toEqual({ Authorization: "Bot token-123" });
    expect(guardedFetch.init?.signal).toBeInstanceOf(AbortSignal);
    expect(guardedFetch.signal).toBe(guardedFetch.init?.signal);
    expect(guardedFetch.timeoutMs).toBeUndefined();
    expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
  });

  it("does not double-capture gateway metadata fetches when global fetch patching is enabled", async () => {
    resolveDebugProxySettingsMock.mockReturnValue({ enabled: true });
    const runtime = createRuntime();
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock });

    expect(captureHttpExchangeSpy).not.toHaveBeenCalled();
  });

  it("accepts IPv6 loopback proxy URLs for websocket setup", async () => {
    const runtime = createRuntime();
    const plugin = createDiscordGatewayPlugin({
      discordConfig: { proxy: "http://[::1]:8080" },
      runtime,
      testing: createProxyTestingOverrides(),
    });

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg");
    await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock });

    expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://[::1]:8080");
    expect(runtime.error).not.toHaveBeenCalled();
  });

  it("uses the configured gateway proxy when proxy is a non-loopback IP", () => {
    const runtime = createRuntime();

    const plugin = createDiscordGatewayPlugin({
      discordConfig: { proxy: "http://10.0.0.10:8080" },
      runtime,
      testing: createProxyTestingOverrides(),
    });

    const createWebSocket = (plugin as unknown as { createWebSocket: (url: string) => unknown })
      .createWebSocket;
    createWebSocket("wss://gateway.discord.gg");

    expect(wsProxyAgentSpy).toHaveBeenCalledWith("http://10.0.0.10:8080");
    expect(webSocketSpy).toHaveBeenCalledWith("wss://gateway.discord.gg", {
      agent: getLastProxyAgent(),
      handshakeTimeout: 30_000,
      maxPayload: 16 * 1024 * 1024,
    });
    expect(runtime.error).not.toHaveBeenCalled();
    expect(runtime.log).toHaveBeenCalledWith("discord: gateway proxy enabled");
  });

  it("maps body read failures to fetch failed", async () => {
    await expectGatewayRegisterFallback({
      ok: true,
      status: 200,
      text: async () => {
        throw new Error("body stream closed");
      },
    } as unknown as Response);
  });

  it("falls back to the default gateway url when metadata lookup times out", async () => {
    vi.useFakeTimers();
    const runtime = createRuntime();
    globalFetchMock.mockImplementation(() => new Promise(() => {}));
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    const registerPromise = registerGatewayClient(plugin);
    await vi.advanceTimersByTimeAsync(30_000);
    await registerPromise;

    expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
    expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
      "wss://gateway.discord.gg/",
    );
    expect(runtime.log).toHaveBeenCalledTimes(1);
    expect(String(firstMockArg(runtime.log, "runtime.log"))).toContain(
      "discord: gateway metadata lookup failed transiently",
    );
  });

  it("uses env gateway metadata timeout when config is unset", async () => {
    vi.useFakeTimers();
    vi.stubEnv("OPENCLAW_DISCORD_GATEWAY_INFO_TIMEOUT_MS", "6000");
    const runtime = createRuntime();
    globalFetchMock.mockImplementation(() => new Promise(() => {}));
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    const registerPromise = registerGatewayClient(plugin);
    await vi.advanceTimersByTimeAsync(5_999);
    expect(baseRegisterClientSpy).not.toHaveBeenCalled();
    await vi.advanceTimersByTimeAsync(1);
    await registerPromise;

    expect((plugin as unknown as { gatewayInfo?: { url?: string } }).gatewayInfo?.url).toBe(
      "wss://gateway.discord.gg/",
    );
  });

  it("rate-limits repeated gateway metadata fallback logs", async () => {
    vi.useFakeTimers();
    const runtime = createRuntime();
    globalFetchMock.mockResolvedValue({
      ok: false,
      status: 503,
      text: async () => "upstream connect error",
    } as Response);
    const firstPlugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });
    const secondPlugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    await registerGatewayClient(firstPlugin);
    await registerGatewayClient(secondPlugin);
    expect(runtime.log).toHaveBeenCalledTimes(1);

    await vi.advanceTimersByTimeAsync(60_000);
    await registerGatewayClient(
      createDiscordGatewayPlugin({
        discordConfig: {},
        runtime,
      }),
    );

    expect(runtime.log).toHaveBeenCalledTimes(2);
  });

  it("sets client reference before the async gateway-info fetch resolves (regression for #52372)", async () => {
    vi.useFakeTimers();
    const runtime = createRuntime();
    let fetchResolve: ((v: Response) => void) | undefined;
    globalFetchMock.mockImplementation(
      () =>
        new Promise<Response>((resolve) => {
          fetchResolve = resolve;
        }),
    );
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    const clientArg = {
      options: { token: "token-race" },
      registerListener: baseRegisterClientSpy,
      unregisterListener: vi.fn(),
    };
    const registerPromise = (
      plugin as unknown as {
        registerClient: (c: typeof clientArg) => Promise<void>;
      }
    ).registerClient(clientArg);

    // Before the metadata fetch resolves, this.client should already be set so
    // that a concurrent identify() cannot observe an undefined client.
    expect((plugin as unknown as { client: unknown }).client).toBe(clientArg);

    resolveGatewayInfoFetch(fetchResolve);
    await registerPromise;

    expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1);
  });

  it("skips super.registerClient when an external connect starts during the metadata fetch (regression for #52372)", async () => {
    const runCase = async (markStarted: (plugin: unknown) => void) => {
      vi.useFakeTimers();
      const runtime = createRuntime();
      let fetchResolve: ((v: Response) => void) | undefined;
      globalFetchMock.mockImplementation(
        () =>
          new Promise<Response>((resolve) => {
            fetchResolve = resolve;
          }),
      );
      const plugin = createDiscordGatewayPlugin({
        discordConfig: {},
        runtime,
      });

      const clientArg = {
        options: { token: "token-race" },
        registerListener: baseRegisterClientSpy,
        unregisterListener: vi.fn(),
      };
      const registerPromise = (
        plugin as unknown as {
          registerClient: (c: typeof clientArg) => Promise<void>;
        }
      ).registerClient(clientArg);

      markStarted(plugin);
      resolveGatewayInfoFetch(fetchResolve);
      await registerPromise;
    };

    await runCase((plugin) => {
      (plugin as { ws: unknown }).ws = { readyState: 1 };
    });
    expect(baseRegisterClientSpy).not.toHaveBeenCalled();

    baseRegisterClientSpy.mockClear();
    globalFetchMock.mockReset();
    vi.useRealTimers();

    await runCase((plugin) => {
      (plugin as { isConnecting: boolean }).isConnecting = true;
    });
    expect(baseRegisterClientSpy).not.toHaveBeenCalled();
  });

  it("refreshes fallback gateway metadata on the next register attempt", async () => {
    const runtime = createRuntime();
    globalFetchMock
      .mockResolvedValueOnce({
        ok: false,
        status: 503,
        text: async () =>
          "upstream connect error or disconnect/reset before headers. reset reason: overflow",
      } as Response)
      .mockResolvedValueOnce({
        ok: true,
        status: 200,
        text: async () =>
          createGatewayInfoBody({
            url: "wss://gateway.discord.gg/?v=10",
            shards: 8,
            maxConcurrency: 16,
          }),
      } as Response);
    const plugin = createDiscordGatewayPlugin({
      discordConfig: {},
      runtime,
    });

    await registerGatewayClient(plugin);
    await registerGatewayClient(plugin);

    expect(globalFetchMock).toHaveBeenCalledTimes(2);
    expect(baseRegisterClientSpy).toHaveBeenCalledTimes(2);
    expect((plugin as unknown as { gatewayInfo?: unknown }).gatewayInfo).toEqual({
      url: "wss://gateway.discord.gg/?v=10",
      shards: 8,
      session_start_limit: {
        total: 1000,
        remaining: 999,
        reset_after: 120_000,
        max_concurrency: 16,
      },
    });
  });
});
