// Discord tests cover channel plugin behavior.
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { ChannelType } from "discord-api-types/v10";
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedDiscordAccount } from "./accounts.js";
import type { OpenClawConfig } from "./runtime-api.js";
import * as sendModule from "./send.js";
import { createDiscordSendReceipt } from "./send.receipt.js";
import { EMPTY_DISCORD_TEST_CONFIG } from "./test-support/config.js";
import { argAt, objectArgAt, recordField } from "./test-support/mock-calls.js";
let discordPlugin: typeof import("./channel.js").discordPlugin;
let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime;

const probeDiscordMock = vi.hoisted(() => vi.fn());
const monitorDiscordProviderMock = vi.hoisted(() => vi.fn());
const auditDiscordChannelPermissionsMock = vi.hoisted(() => vi.fn());
const collectDiscordAuditChannelIdsMock = vi.hoisted(() =>
  vi.fn(() => ({ channelIds: [], unresolvedChannels: 0 })),
);
const sleepWithAbortMock = vi.hoisted(() => vi.fn(async () => undefined));

function discordTestSendResult(messageId: string, channelId = "channel:thread-123") {
  return {
    messageId,
    channelId,
    receipt: createDiscordSendReceipt({ platformMessageIds: [messageId], channelId, kind: "text" }),
  };
}

vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
  const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
    "openclaw/plugin-sdk/runtime-env",
  );
  return {
    ...actual,
    sleepWithAbort: sleepWithAbortMock,
  };
});

vi.mock("./probe.js", () => {
  return {
    probeDiscord: probeDiscordMock,
  };
});

vi.mock("./monitor/provider.runtime.js", () => {
  return {
    monitorDiscordProvider: monitorDiscordProviderMock,
  };
});

vi.mock("./audit.js", () => {
  return {
    auditDiscordChannelPermissions: auditDiscordChannelPermissionsMock,
    collectDiscordAuditChannelIds: collectDiscordAuditChannelIdsMock,
  };
});

function createCfg(): OpenClawConfig {
  return {
    channels: {
      discord: {
        enabled: true,
        token: "discord-token",
      },
    },
  } as OpenClawConfig;
}

function resolveAccount(cfg: OpenClawConfig, accountId = "default"): ResolvedDiscordAccount {
  return discordPlugin.config.resolveAccount(cfg, accountId);
}

function startDiscordAccount(cfg: OpenClawConfig, accountId = "default") {
  return discordPlugin.gateway!.startAccount!(
    createStartAccountContext({
      account: resolveAccount(cfg, accountId),
      cfg,
    }),
  );
}

function prepareDiscordStartupMocks() {
  probeDiscordMock.mockResolvedValue({
    ok: true,
    bot: { username: "Jarvis" },
    application: {
      intents: {
        messageContent: "limited",
        guildMembers: "disabled",
        presence: "disabled",
      },
    },
    elapsedMs: 1,
  });
  monitorDiscordProviderMock.mockResolvedValue(undefined);
}

async function expectDiscordStartupDelay(
  cfg: OpenClawConfig,
  accountId: string,
  expectedMs: number,
) {
  const ctx = createStartAccountContext({ account: resolveAccount(cfg, accountId), cfg });
  sleepWithAbortMock.mockClear();
  await discordPlugin.gateway!.startAccount!(ctx);
  if (expectedMs === 0) {
    expect(sleepWithAbortMock).not.toHaveBeenCalled();
    return;
  }
  expect(sleepWithAbortMock).toHaveBeenCalledOnce();
  expect(sleepWithAbortMock).toHaveBeenCalledWith(expectedMs, ctx.abortSignal);
}

function installDiscordRuntime(
  discord: Record<string, unknown>,
  openKeyedStore: (options: Record<string, unknown>) => unknown = vi.fn(() => ({
    lookup: vi.fn(async () => undefined),
    register: vi.fn(async () => undefined),
  })),
) {
  setDiscordRuntime({
    channel: {
      discord,
    },
    logging: {
      shouldLogVerbose: () => false,
    },
    state: { openKeyedStore },
  } as unknown as PluginRuntime);
}

async function expectStaleProbeMetadataCleared(statusPatches: Array<Record<string, unknown>>) {
  await vi.waitFor(() =>
    expect(
      statusPatches
        .filter(
          (patch) =>
            "bot" in patch &&
            "application" in patch &&
            patch.bot === undefined &&
            patch.application === undefined,
        )
        .map((patch) => ({
          bot: patch.bot,
          application: patch.application,
        })),
    ).toEqual([{ bot: undefined, application: undefined }]),
  );
}

afterEach(() => {
  probeDiscordMock.mockReset();
  monitorDiscordProviderMock.mockReset();
  auditDiscordChannelPermissionsMock.mockReset();
  collectDiscordAuditChannelIdsMock.mockReset();
  collectDiscordAuditChannelIdsMock.mockReturnValue({
    channelIds: [],
    unresolvedChannels: 0,
  });
  sleepWithAbortMock.mockReset();
  sleepWithAbortMock.mockResolvedValue(undefined);
});

beforeEach(async () => {
  vi.useRealTimers();
  installDiscordRuntime({});
});

beforeAll(async () => {
  ({ discordPlugin } = await import("./channel.js"));
  ({ setDiscordRuntime } = await import("./runtime.js"));
});

describe("discordPlugin outbound", () => {
  it("builds tool context with separate native and routable DM targets", () => {
    const buildToolContext = discordPlugin.threading?.buildToolContext;
    if (!buildToolContext) {
      throw new Error("Expected discordPlugin.threading.buildToolContext to be defined");
    }
    const hasRepliedRef = { value: false };

    expect(
      buildToolContext({
        cfg: {} as OpenClawConfig,
        context: {
          To: "user:123456789",
          NativeChannelId: "987654321",
          ChatType: "direct",
          CurrentMessageId: "message-1",
        },
        hasRepliedRef,
      }),
    ).toEqual({
      currentChannelId: "987654321",
      currentChatType: "direct",
      currentMessagingTarget: "user:123456789",
      currentMessageId: "message-1",
      hasRepliedRef,
    });
  });

  it("avoids local require calls for bundled-only sibling modules", async () => {
    const source = await readFile(
      resolve(process.cwd(), "extensions/discord/src/channel.ts"),
      "utf8",
    );
    expect(source).not.toContain('require("./ui.js")');
    expect(source).not.toContain('require("./channel-actions.js")');
  });

  it("prefers final assistant text for text-only cron announce delivery", () => {
    expect(discordPlugin.outbound?.preferFinalAssistantVisibleText).toBe(true);
  });

  it("routes Discord message actions through the gateway", () => {
    expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "read" as never })).toBe(
      "gateway",
    );
    expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "search" as never })).toBe(
      "gateway",
    );
    expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "send" as never })).toBe(
      "local",
    );
    expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "upload-file" as never })).toBe(
      "local",
    );
    expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "thread-reply" as never })).toBe(
      "local",
    );
    expect(discordPlugin.actions?.resolveExecutionMode?.({ action: "channel-info" as never })).toBe(
      "gateway",
    );
  });

  it("requires trusted requester identity for registered privileged tool actions", () => {
    expect(
      discordPlugin.actions?.requiresTrustedRequesterSender?.({
        action: "channel-delete",
        toolContext: { currentChannelProvider: "discord" },
      }),
    ).toBe(true);
    expect(
      discordPlugin.actions?.requiresTrustedRequesterSender?.({
        action: "channel-delete",
      }),
    ).toBe(false);
    expect(
      discordPlugin.actions?.requiresTrustedRequesterSender?.({
        action: "read",
        toolContext: { currentChannelProvider: "discord" },
      }),
    ).toBe(false);
  });

  it("adds Discord mention formatting to agent prompt hints", () => {
    const hints = discordPlugin.agentPrompt?.messageToolHints?.({} as never) ?? [];

    expect(hints).toContain(
      "- Discord mentions: use canonical outbound syntax: users `<@USER_ID>`, channels `<#CHANNEL_ID>`, and roles `<@&ROLE_ID>`. Plain `@name` text only pings when a configured `mentionAliases` entry rewrites it; do not use the legacy `<@!USER_ID>` nickname form.",
    );
  });

  it("preserves normalized Discord targets for delivery routing", () => {
    const messaging = discordPlugin.messaging;
    if (!messaging?.normalizeTarget || !messaging.inferTargetChatType) {
      throw new Error("Expected discordPlugin.messaging target helpers to be defined");
    }

    expect(messaging.normalizeTarget("user:123")).toBe("user:123");
    expect(messaging.inferTargetChatType({ to: "user:123" })).toBe("direct");
    expect(messaging.normalizeTarget("<@!456>")).toBe("user:456");
    expect(messaging.inferTargetChatType({ to: "<@!456>" })).toBe("direct");
    expect(messaging.normalizeTarget("channel:789")).toBe("channel:789");
    expect(messaging.inferTargetChatType({ to: "channel:789" })).toBe("channel");
    expect(messaging.normalizeTarget("1470130713209602050")).toBe("channel:1470130713209602050");
    expect(messaging.inferTargetChatType({ to: "1470130713209602050" })).toBe("channel");
  });

  it("preserves the normalized channel kind for bare current-channel ids", async () => {
    const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
    if (!resolveTarget) {
      throw new Error(
        "Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
      );
    }

    await expect(
      resolveTarget({
        cfg: createCfg(),
        accountId: "default",
        input: "1470130713209602050",
        normalized: "channel:1470130713209602050",
      }),
    ).resolves.toEqual({
      to: "channel:1470130713209602050",
      kind: "channel",
      display: "1470130713209602050",
      source: "normalized",
    });
  });

  it("keeps allowlisted bare Discord ids routable as DMs", async () => {
    const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
    if (!resolveTarget) {
      throw new Error(
        "Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
      );
    }

    await expect(
      resolveTarget({
        cfg: {
          channels: {
            discord: {
              accounts: {
                default: {
                  token: "discord-token",
                  allowFrom: ["123456789"],
                },
              },
            },
          },
        },
        accountId: "default",
        input: "123456789",
        normalized: "channel:123456789",
      }),
    ).resolves.toEqual({
      to: "user:123456789",
      kind: "user",
      display: "123456789",
      source: "directory",
    });
  });

  it("honors per-account replyToMode overrides", () => {
    const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
    if (!resolveReplyToMode) {
      throw new Error("Expected discordPlugin.threading.resolveReplyToMode to be defined");
    }

    const cfg = {
      channels: {
        discord: {
          replyToMode: "all",
          token: "discord-token",
          accounts: {
            work: {
              token: "discord-token-work",
              replyToMode: "first",
            },
          },
        },
      },
    } as OpenClawConfig;

    expect(resolveReplyToMode({ cfg, accountId: "work" })).toBe("first");
    expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("all");
  });

  it("forwards full media send context to sendMessageDiscord", async () => {
    const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
    const mediaReadFile = vi.fn(async () => Buffer.from("media"));

    const result = await discordPlugin.outbound!.sendMedia!({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      to: "channel:123",
      text: "hi",
      mediaUrl: "/tmp/image.png",
      mediaLocalRoots: ["/tmp/agent-root"],
      mediaReadFile,
      accountId: "work",
      threadId: "thread-123",
      replyToId: "reply-123",
      deps: {
        discord: sendMessageDiscord,
      },
    });

    expect(argAt(sendMessageDiscord, 0, 0)).toBe("channel:thread-123");
    expect(argAt(sendMessageDiscord, 0, 1)).toBe("hi");
    const sendOptions = objectArgAt(sendMessageDiscord, 0, 2);
    expect(sendOptions.mediaUrl).toBe("/tmp/image.png");
    expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
    expect(sendOptions.mediaReadFile).toBe(mediaReadFile);
    expect(sendOptions.reply).toEqual({ messageId: "reply-123", scope: "all" });
    expect(result.channel).toBe("discord");
    expect(result.messageId).toBe("m1");
  });

  it("splits text and video into separate sends for attached outbound delivery", async () => {
    const sendMessageDiscord = vi
      .fn()
      .mockResolvedValueOnce(discordTestSendResult("text-1"))
      .mockResolvedValueOnce(discordTestSendResult("video-1"));

    const result = await discordPlugin.outbound!.sendMedia!({
      cfg: EMPTY_DISCORD_TEST_CONFIG,
      to: "channel:123",
      text: "done - tiny cyber-lobster clip incoming",
      mediaUrl: "/tmp/molty.mp4",
      accountId: "work",
      replyToId: "reply-123",
      threadId: "thread-123",
      deps: {
        discord: sendMessageDiscord,
      },
    });

    expect(sendMessageDiscord).toHaveBeenCalledTimes(2);
    expect(argAt(sendMessageDiscord, 0, 0)).toBe("channel:thread-123");
    expect(argAt(sendMessageDiscord, 0, 1)).toBe("done - tiny cyber-lobster clip incoming");
    expect(objectArgAt(sendMessageDiscord, 0, 2).reply).toEqual({
      messageId: "reply-123",
      scope: "all",
    });
    expect(argAt(sendMessageDiscord, 1, 0)).toBe("channel:thread-123");
    expect(argAt(sendMessageDiscord, 1, 1)).toBe("");
    expect(objectArgAt(sendMessageDiscord, 1, 2).mediaUrl).toBe("/tmp/molty.mp4");
    expect(result.channel).toBe("discord");
    expect(result.messageId).toBe("video-1");
  });

  it("threads poll sends through the thread target", async () => {
    const sendPollDiscord = vi.fn(async () => discordTestSendResult("poll-1"));
    const sendPollSpy = vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(sendPollDiscord);
    try {
      const result = await discordPlugin.outbound!.sendPoll!({
        cfg: EMPTY_DISCORD_TEST_CONFIG,
        to: "channel:123",
        poll: {
          question: "Best shell?",
          options: ["molty", "molter"],
        },
        accountId: "work",
        threadId: "thread-123",
      });

      expect(argAt(sendPollDiscord, 0, 0)).toBe("channel:thread-123");
      expect(argAt(sendPollDiscord, 0, 1)).toEqual({
        question: "Best shell?",
        options: ["molty", "molter"],
      });
      expect(objectArgAt(sendPollDiscord, 0, 2).accountId).toBe("work");
      const pollResult = result as { channel?: string; messageId?: string };
      expect(pollResult.channel).toBe("discord");
      expect(pollResult.messageId).toBe("poll-1");
    } finally {
      sendPollSpy.mockRestore();
    }
  });

  it("forwards heartbeat typing through the run config and attached target", async () => {
    const sendTypingDiscord = vi.fn(async () => ({ ok: true, channelId: "thread-123" }));
    const sendTypingSpy = vi
      .spyOn(sendModule, "sendTypingDiscord")
      .mockImplementation(sendTypingDiscord);
    try {
      const cfg = createCfg();

      await discordPlugin.heartbeat!.sendTyping!({
        cfg,
        to: "channel:123",
        accountId: "work",
        threadId: "thread-123",
      });

      expect(sendTypingDiscord).toHaveBeenCalledWith("thread-123", {
        cfg,
        accountId: "work",
      });
    } finally {
      sendTypingSpy.mockRestore();
    }
  });

  it("uses direct Discord probe helpers for status probes", async () => {
    const runtimeProbeDiscord = vi.fn(async () => {
      throw new Error("runtime Discord probe should not be used");
    });
    installDiscordRuntime({
      probeDiscord: runtimeProbeDiscord,
    });
    probeDiscordMock.mockResolvedValue({
      ok: true,
      bot: { username: "Bob" },
      application: {
        intents: {
          messageContent: "limited",
          guildMembers: "disabled",
          presence: "disabled",
        },
      },
      elapsedMs: 1,
    });

    const cfg = createCfg();
    const account = resolveAccount(cfg);

    await discordPlugin.status!.probeAccount!({
      account,
      timeoutMs: 5000,
      cfg,
    });

    expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", expect.any(Number), {
      includeApplication: true,
    });
    const forwardedTimeoutMs = Number(argAt(probeDiscordMock, 0, 1));
    expect(forwardedTimeoutMs).toBeGreaterThan(0);
    expect(forwardedTimeoutMs).toBeLessThanOrEqual(5_000);
    expect(runtimeProbeDiscord).not.toHaveBeenCalled();
  });

  it("subtracts lazy probe loading from the status budget", async () => {
    const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(1_000).mockReturnValueOnce(1_200);
    probeDiscordMock.mockResolvedValue({ ok: true, elapsedMs: 1 });
    try {
      const cfg = createCfg();
      await discordPlugin.status!.probeAccount!({
        account: resolveAccount(cfg),
        timeoutMs: 5_000,
        cfg,
      });

      expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 4_800, {
        includeApplication: true,
      });
    } finally {
      nowSpy.mockRestore();
    }
  });

  it("reports missing voice permissions in targeted capabilities diagnostics", async () => {
    const fetchPermissionsSpy = vi
      .spyOn(sendModule, "fetchChannelPermissionsDiscord")
      .mockResolvedValueOnce({
        channelId: "222",
        guildId: "123",
        permissions: ["ViewChannel", "SendMessages"],
        raw: "0",
        isDm: false,
        channelType: ChannelType.GuildVoice,
      });
    try {
      const cfg = createCfg();
      const diagnostics = await discordPlugin.status!.buildCapabilitiesDiagnostics!({
        account: resolveAccount(cfg),
        timeoutMs: 5000,
        cfg,
        target: "channel:222",
      });

      expect(argAt(fetchPermissionsSpy, 0, 0)).toBe("222");
      expect(objectArgAt(fetchPermissionsSpy, 0, 1).token).toBe("discord-token");
      const permissions = recordField(diagnostics?.details?.permissions, "permissions");
      expect(permissions.channelId).toBe("222");
      expect(permissions.missingRequired).toEqual(["Connect", "Speak", "ReadMessageHistory"]);
      expect(diagnostics?.lines?.map((line) => line.text).join("\n")).toContain(
        "Missing required: Connect, Speak, ReadMessageHistory",
      );
    } finally {
      fetchPermissionsSpy.mockRestore();
    }
  });

  it("returns a timeout error when capabilities diagnostics exceed the timeout", async () => {
    let diagnosticSignal: AbortSignal | undefined;
    const fetchPermissionsSpy = vi
      .spyOn(sendModule, "fetchChannelPermissionsDiscord")
      .mockImplementation(
        async (_channelId, opts) =>
          await new Promise<never>((_, reject) => {
            diagnosticSignal = opts.signal;
            opts.signal?.addEventListener(
              "abort",
              () => reject(new Error("permission lookup aborted")),
              { once: true },
            );
          }),
      );
    try {
      const cfg = createCfg();
      const diagnostics = await discordPlugin.status!.buildCapabilitiesDiagnostics!({
        account: resolveAccount(cfg),
        timeoutMs: 10,
        cfg,
        target: "channel:222",
      });

      const timeoutPerms = recordField(diagnostics?.details?.permissions, "permissions");
      expect(String(timeoutPerms.error)).toContain("timed out");
      expect(diagnostics?.lines?.[0]?.tone).toBe("error");
      expect(objectArgAt(fetchPermissionsSpy, 0, 1).timeoutMs).toBe(10);
      expect(diagnosticSignal?.aborted).toBe(true);
    } finally {
      fetchPermissionsSpy.mockRestore();
    }
  });

  it("uses direct Discord startup helpers for async startup enrichment", async () => {
    const runtimeProbeDiscord = vi.fn(async () => {
      throw new Error("runtime Discord probe should not be used");
    });
    const runtimeMonitorDiscordProvider = vi.fn(async () => {
      throw new Error("runtime Discord monitor should not be used");
    });
    installDiscordRuntime({
      probeDiscord: runtimeProbeDiscord,
      monitorDiscordProvider: runtimeMonitorDiscordProvider,
    });
    probeDiscordMock.mockResolvedValue({
      ok: true,
      bot: { username: "Bob" },
      application: {
        intents: {
          messageContent: "limited",
          guildMembers: "disabled",
          presence: "disabled",
        },
      },
      elapsedMs: 1,
    });
    monitorDiscordProviderMock.mockResolvedValue(undefined);

    const cfg = createCfg();
    await startDiscordAccount(cfg);

    await vi.waitFor(() =>
      expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
        includeApplication: true,
      }),
    );
    const monitorParams = objectArgAt(monitorDiscordProviderMock, 0, 0);
    expect(monitorParams.token).toBe("discord-token");
    expect(monitorParams.accountId).toBe("default");
    expect(sleepWithAbortMock).not.toHaveBeenCalled();
    expect(runtimeProbeDiscord).not.toHaveBeenCalled();
    expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
  });

  it("fails loudly before provider startup when a token SecretRef is configured but unresolved", async () => {
    const cfg = {
      channels: {
        discord: {
          token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" },
        },
      },
    } as unknown as OpenClawConfig;

    await expect(startDiscordAccount(cfg)).rejects.toThrow(
      'Discord bot token configured for account "default" is unavailable',
    );
    expect(probeDiscordMock).not.toHaveBeenCalled();
    expect(monitorDiscordProviderMock).not.toHaveBeenCalled();
  });

  it("does not block Discord monitor startup on the startup probe", async () => {
    let resolveProbe:
      | ((value: {
          ok: true;
          bot: { username: string };
          application: { intents: { messageContent: "limited" } };
          elapsedMs: number;
        }) => void)
      | undefined;
    probeDiscordMock.mockReturnValue(
      new Promise((resolveLocal) => {
        resolveProbe = resolveLocal;
      }),
    );
    monitorDiscordProviderMock.mockResolvedValue(undefined);

    const cfg = createCfg();
    const statusPatches: Array<Record<string, unknown>> = [];
    const ctx = createStartAccountContext({
      account: resolveAccount(cfg),
      cfg,
      statusPatchSink: (next) => statusPatches.push({ ...next }),
    });

    await discordPlugin.gateway!.startAccount!(ctx);

    const monitorParams = objectArgAt(monitorDiscordProviderMock, 0, 0);
    expect(monitorParams.token).toBe("discord-token");
    expect(monitorParams.accountId).toBe("default");
    await vi.waitFor(() =>
      expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
        includeApplication: true,
      }),
    );
    expect(statusPatches.filter((patch) => "bot" in patch || "application" in patch)).toEqual([]);

    if (!resolveProbe) {
      throw new Error("Expected Discord startup probe resolver to be initialized");
    }
    resolveProbe({
      ok: true,
      bot: { username: "AsyncBob" },
      application: { intents: { messageContent: "limited" } },
      elapsedMs: 1,
    });

    await vi.waitFor(() =>
      expect(
        statusPatches
          .filter(
            (patch) => (patch.bot as { username?: string } | undefined)?.username === "AsyncBob",
          )
          .map((patch) => ({
            bot: patch.bot,
            application: patch.application,
          })),
      ).toEqual([
        {
          bot: { username: "AsyncBob" },
          application: { intents: { messageContent: "limited" } },
        },
      ]),
    );
  });

  it("opens the SQLite command deployment cache and passes it to the provider", async () => {
    prepareDiscordStartupMocks();
    const commandDeployHashStore = {
      lookup: vi.fn(async () => undefined),
      register: vi.fn(async () => undefined),
    };
    const openKeyedStore = vi.fn(() => commandDeployHashStore);
    installDiscordRuntime({}, openKeyedStore);

    await startDiscordAccount(createCfg());

    expect(openKeyedStore).toHaveBeenCalledWith({
      namespace: "command-deploy-hashes",
      maxEntries: 10_000,
      overflowPolicy: "evict-oldest",
    });
    expect(objectArgAt(monitorDiscordProviderMock, 0, 0).commandDeployHashStore).toBe(
      commandDeployHashStore,
    );
  });

  it("continues Discord startup when the command deployment cache cannot open", async () => {
    prepareDiscordStartupMocks();
    installDiscordRuntime({}, () => {
      throw new Error("SQLite unavailable");
    });

    await startDiscordAccount(createCfg());

    expect(objectArgAt(monitorDiscordProviderMock, 0, 0).commandDeployHashStore).toBeUndefined();
  });

  it("clears stale Discord probe metadata when the async startup probe degrades", async () => {
    probeDiscordMock.mockResolvedValue({
      ok: false,
      status: 401,
      error: "getMe failed (401)",
      elapsedMs: 1,
    });
    monitorDiscordProviderMock.mockResolvedValue(undefined);

    const cfg = createCfg();
    const statusPatches: Array<Record<string, unknown>> = [];
    const ctx = createStartAccountContext({
      account: resolveAccount(cfg),
      cfg,
      statusPatchSink: (next) => statusPatches.push({ ...next }),
    });
    ctx.setStatus({
      accountId: "default",
      bot: { username: "OldBot" },
      application: { intents: { messageContent: "enabled" } },
    });

    await discordPlugin.gateway!.startAccount!(ctx);

    await expectStaleProbeMetadataCleared(statusPatches);
  });

  it("clears stale Discord probe metadata when the async startup probe throws", async () => {
    probeDiscordMock.mockRejectedValue(new Error("probe timed out"));
    monitorDiscordProviderMock.mockResolvedValue(undefined);

    const cfg = createCfg();
    const statusPatches: Array<Record<string, unknown>> = [];
    const ctx = createStartAccountContext({
      account: resolveAccount(cfg),
      cfg,
      statusPatchSink: (next) => statusPatches.push({ ...next }),
    });
    ctx.setStatus({
      accountId: "default",
      bot: { username: "OldBot" },
      application: { intents: { messageContent: "enabled" } },
    });

    await discordPlugin.gateway!.startAccount!(ctx);

    await expectStaleProbeMetadataCleared(statusPatches);
  });

  it("stagger starts later accounts in multi-bot setups", async () => {
    prepareDiscordStartupMocks();

    const cfg = {
      channels: {
        discord: {
          accounts: {
            // "alpha" sorts before "zeta" so alpha is index 0, zeta is index 1
            alpha: { token: "Bot alpha-token", enabled: true },
            zeta: { token: "Bot zeta-token", enabled: true },
          },
        },
      },
    } as OpenClawConfig;

    await expectDiscordStartupDelay(cfg, "alpha", 0);
    await expectDiscordStartupDelay(cfg, "zeta", 10_000);
  });

  it("starts the configured default account before staggering secondary accounts", async () => {
    prepareDiscordStartupMocks();

    const cfg = {
      channels: {
        discord: {
          defaultAccount: "main",
          accounts: {
            billy: { token: "Bot billy-token", enabled: true },
            farber: { token: "Bot farber-token", enabled: true },
            main: { token: "Bot main-token", enabled: true },
          },
        },
      },
    } as OpenClawConfig;

    await expectDiscordStartupDelay(cfg, "main", 0);
    await expectDiscordStartupDelay(cfg, "billy", 10_000);
    await expectDiscordStartupDelay(cfg, "farber", 20_000);
  });

  it("does not promote a duplicate-token defaultAccount to the zero-delay startup slot", async () => {
    prepareDiscordStartupMocks();

    const cfg = {
      channels: {
        discord: {
          defaultAccount: "main",
          accounts: {
            billy: { token: "Bot billy-token", enabled: true },
            // "farber" sorts before "main", so it owns the shared token.
            farber: { token: "Bot shared-token", enabled: true },
            main: { token: "Bot shared-token", enabled: true },
          },
        },
      },
    } as OpenClawConfig;

    await expectDiscordStartupDelay(cfg, "billy", 0);
    await expectDiscordStartupDelay(cfg, "farber", 10_000);
  });

  it("does not assign startup slots to enabled but unconfigured accounts", async () => {
    prepareDiscordStartupMocks();

    const cfg = {
      channels: {
        discord: {
          accounts: {
            alpha: { enabled: true },
            zeta: { token: "Bot zeta-token", enabled: true },
          },
        },
      },
    } as OpenClawConfig;

    await expectDiscordStartupDelay(cfg, "zeta", 0);
  });
});
