// Discord tests cover runtime plugin behavior.
import { ChannelType, PermissionFlagsBits } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearPresences, setPresence } from "../monitor/presence-cache.js";
import { DiscordThreadInitialMessageError } from "../send.js";
import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js";
import { handleDiscordAction } from "./runtime.js";
import {
  discordMessagingActionRuntime,
  handleDiscordMessagingAction,
} from "./runtime.messaging.js";
import {
  discordModerationActionRuntime,
  handleDiscordModerationAction,
} from "./runtime.moderation.js";

const originalDiscordMessagingActionRuntime = { ...discordMessagingActionRuntime };
const originalDiscordGuildActionRuntime = { ...discordGuildActionRuntime };
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };

type DiscordChannelInfoTest = {
  id: string;
  type: number;
  guild_id?: string;
  name?: string;
  parent_id?: string;
};

const discordSendMocks = {
  addRoleDiscord: vi.fn(async () => ({ ok: true })),
  banMemberDiscord: vi.fn(async () => ({})),
  canManageGuildRoleDiscord: vi.fn(async () => true),
  canManageGuildMemberRoleDiscord: vi.fn(async () => true),
  createChannelDiscord: vi.fn(async () => ({
    id: "new-channel",
    name: "test",
    type: 0,
  })),
  createScheduledEventDiscord: vi.fn(async () => ({ id: "event-1" })),
  createThreadDiscord: vi.fn(async () => ({})),
  deleteChannelDiscord: vi.fn(async () => ({ ok: true, channelId: "C1" })),
  deleteMessageDiscord: vi.fn(async () => ({})),
  editChannelDiscord: vi.fn(async () => ({
    id: "C1",
    name: "edited",
  })),
  editMessageDiscord: vi.fn(async () => ({})),
  fetchChannelInfoDiscord: vi.fn(
    async (channelId: string): Promise<DiscordChannelInfoTest> => ({ id: channelId, type: 0 }),
  ),
  fetchChannelPermissionsDiscord: vi.fn(async () => ({})),
  fetchGuildInfoDiscord: vi.fn(async (guildId: string) => ({
    id: guildId,
    name: "Guild",
  })),
  hasAnyChannelPermissionDiscord: vi.fn(async () => true),
  hasAnyGuildPermissionDiscord: vi.fn(async () => true),
  fetchMessageDiscord: vi.fn(async () => ({})),
  fetchReactionsDiscord: vi.fn(async () => ({})),
  kickMemberDiscord: vi.fn(async () => ({})),
  listGuildChannelsDiscord: vi.fn(async () => []),
  listPinsDiscord: vi.fn(async () => ({})),
  listThreadsDiscord: vi.fn(async () => ({})),
  moveChannelDiscord: vi.fn(async () => ({ ok: true })),
  pinMessageDiscord: vi.fn(async () => ({})),
  reactMessageDiscord: vi.fn(async () => ({})),
  readMessagesDiscord: vi.fn(async () => []),
  removeChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
  removeOwnReactionsDiscord: vi.fn(async () => ({ removed: ["👍"] })),
  removeReactionDiscord: vi.fn(async () => ({})),
  removeRoleDiscord: vi.fn(async () => ({ ok: true })),
  searchMessagesDiscord: vi.fn(async () => ({})),
  sendDiscordComponentMessage: vi.fn(async () => ({})),
  sendMessageDiscord: vi.fn(async () => ({})),
  sendPollDiscord: vi.fn(async () => ({})),
  sendStickerDiscord: vi.fn(async () => ({})),
  sendVoiceMessageDiscord: vi.fn(async () => ({})),
  setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
  timeoutMemberDiscord: vi.fn(async () => ({})),
  unpinMessageDiscord: vi.fn(async () => ({})),
};

const {
  addRoleDiscord,
  canManageGuildRoleDiscord,
  canManageGuildMemberRoleDiscord,
  createChannelDiscord,
  createScheduledEventDiscord,
  createThreadDiscord,
  deleteChannelDiscord,
  editChannelDiscord,
  fetchChannelInfoDiscord,
  fetchChannelPermissionsDiscord,
  fetchGuildInfoDiscord,
  fetchReactionsDiscord,
  fetchMessageDiscord,
  hasAnyChannelPermissionDiscord,
  hasAnyGuildPermissionDiscord,
  kickMemberDiscord,
  listGuildChannelsDiscord,
  listPinsDiscord,
  listThreadsDiscord,
  moveChannelDiscord,
  reactMessageDiscord,
  readMessagesDiscord,
  removeChannelPermissionDiscord,
  removeOwnReactionsDiscord,
  removeReactionDiscord,
  searchMessagesDiscord,
  sendDiscordComponentMessage,
  sendMessageDiscord,
  sendPollDiscord,
  sendVoiceMessageDiscord,
  setChannelPermissionDiscord,
  timeoutMemberDiscord,
} = discordSendMocks;

const enableAllActions = () => true;
const DISCORD_TEST_CFG = {
  channels: {
    discord: {
      token: "token",
      groupPolicy: "open",
    },
  },
} as OpenClawConfig;

type MockCallSource = { mock: { calls: Array<Array<unknown>> } };

function mockCall(source: MockCallSource, label: string, callIndex = 0): Array<unknown> {
  const call = source.mock.calls[callIndex];
  if (!call) {
    throw new Error(`expected ${label} call ${callIndex}`);
  }
  return call;
}

function mockObjectArg(
  source: MockCallSource,
  label: string,
  callIndex: number,
  argIndex: number,
): Record<string, unknown> {
  const value = mockCall(source, label, callIndex)[argIndex];
  if (!value || typeof value !== "object") {
    throw new Error(`expected ${label} call ${callIndex} argument ${argIndex} to be an object`);
  }
  return value as Record<string, unknown>;
}

function handleMessagingAction(
  action: string,
  params: Record<string, unknown>,
  isActionEnabled: (key: keyof DiscordActionConfig) => boolean,
  cfg: OpenClawConfig = DISCORD_TEST_CFG,
  options?: {
    mediaAccess?: {
      localRoots?: readonly string[];
      readFile?: (filePath: string) => Promise<Buffer>;
      workspaceDir?: string;
    };
    mediaLocalRoots?: readonly string[];
    mediaReadFile?: (filePath: string) => Promise<Buffer>;
  },
) {
  return handleDiscordMessagingAction(action, params, isActionEnabled, cfg, options);
}

function handleGuildAction(
  action: string,
  params: Record<string, unknown>,
  isActionEnabled: (key: keyof DiscordActionConfig) => boolean,
  cfg: OpenClawConfig = DISCORD_TEST_CFG,
  options?: { mediaLocalRoots?: readonly string[] },
) {
  return handleDiscordGuildAction(action, params, isActionEnabled, cfg, options);
}

function handleModerationAction(
  action: string,
  params: Record<string, unknown>,
  isActionEnabled: (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean,
  cfg: OpenClawConfig = DISCORD_TEST_CFG,
) {
  return handleDiscordModerationAction(action, params, isActionEnabled, cfg);
}

const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation";
const rolesEnabled = (key: keyof DiscordActionConfig) => key === "roles";

beforeEach(() => {
  vi.clearAllMocks();
  clearPresences();
  Object.assign(
    discordMessagingActionRuntime,
    originalDiscordMessagingActionRuntime,
    discordSendMocks,
  );
  Object.assign(discordGuildActionRuntime, originalDiscordGuildActionRuntime, discordSendMocks);
  Object.assign(
    discordModerationActionRuntime,
    originalDiscordModerationActionRuntime,
    discordSendMocks,
  );
});

describe("handleDiscordMessagingAction", () => {
  it.each([
    {
      name: "without account",
      params: {
        channelId: "C1",
        messageId: "M1",
        emoji: "✅",
      },
      expectedOptions: { cfg: DISCORD_TEST_CFG, accountId: "default" },
    },
    {
      name: "with accountId",
      params: {
        channelId: "C1",
        messageId: "M1",
        emoji: "✅",
        accountId: "ops",
      },
      expectedOptions: { cfg: DISCORD_TEST_CFG, accountId: "ops" },
    },
  ])("adds reactions $name", async ({ params, expectedOptions }) => {
    await handleMessagingAction("react", params, enableAllActions);
    if (expectedOptions) {
      expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", expectedOptions);
      return;
    }
    expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {
      cfg: DISCORD_TEST_CFG,
    });
  });

  it("uses configured defaultAccount when cfg is provided and accountId is omitted", async () => {
    const cfg = {
      channels: {
        discord: {
          defaultAccount: "work",
          accounts: {
            work: { token: "token-work" },
          },
        },
      },
    } as OpenClawConfig;

    await handleMessagingAction(
      "react",
      {
        channelId: "C1",
        messageId: "M1",
        emoji: "✅",
      },
      enableAllActions,
      cfg,
    );

    expect(reactMessageDiscord).toHaveBeenCalledTimes(1);
    expect(mockCall(reactMessageDiscord, "reactMessageDiscord")).toEqual([
      "C1",
      "M1",
      "✅",
      { cfg, accountId: "work" },
    ]);
  });

  it("surfaces incomplete archived thread pages at the action boundary", async () => {
    listThreadsDiscord.mockResolvedValueOnce({
      threads: [
        {
          id: "thread-1",
          name: "Old project",
          thread_metadata: {
            archive_timestamp: "2026-05-25T17:00:00.000Z",
          },
        },
      ],
      members: [],
      has_more: true,
    });

    const result = await handleMessagingAction(
      "threadList",
      {
        guildId: "G1",
        channelId: "C1",
        includeArchived: true,
        before: "2026-05-26T17:00:00.000Z",
        limit: 1,
      },
      enableAllActions,
    );

    expect(mockCall(listThreadsDiscord, "listThreadsDiscord")).toEqual([
      {
        guildId: "G1",
        channelId: "C1",
        includeArchived: true,
        before: "2026-05-26T17:00:00.000Z",
        limit: 1,
      },
      { cfg: DISCORD_TEST_CFG },
    ]);
    expect(result.details).toMatchObject({
      ok: true,
      complete: false,
      hasMore: true,
      returnedCount: 1,
      source: "discord.threadList.archived",
      nextBefore: "2026-05-25T17:00:00.000Z",
      query: {
        guildId: "G1",
        channelId: "C1",
        includeArchived: true,
        before: "2026-05-26T17:00:00.000Z",
        limit: 1,
      },
    });
    expect((result.details as { threads?: unknown }).threads).toEqual({
      threads: [
        {
          id: "thread-1",
          name: "Old project",
          thread_metadata: {
            archive_timestamp: "2026-05-25T17:00:00.000Z",
          },
        },
      ],
      members: [],
      has_more: true,
    });
  });

  it("omits archived thread pagination cursors when Discord omits archive timestamps", async () => {
    listThreadsDiscord.mockResolvedValueOnce({
      threads: [
        {
          id: "thread-without-archive-timestamp",
          name: "Legacy project",
        },
      ],
      members: [],
      has_more: true,
    });

    const result = await handleMessagingAction(
      "threadList",
      {
        guildId: "G1",
        channelId: "C1",
        includeArchived: true,
        limit: 1,
      },
      enableAllActions,
    );

    expect(result.details).toMatchObject({
      ok: true,
      complete: false,
      hasMore: true,
      returnedCount: 1,
      source: "discord.threadList.archived",
    });
    expect(result.details).not.toHaveProperty("nextBefore");
  });

  it("marks active thread results complete when Discord returns no pagination state", async () => {
    listThreadsDiscord.mockResolvedValueOnce({
      threads: [{ id: "thread-active", name: "Current project" }],
      members: [{ id: "member-1" }],
    });

    const result = await handleMessagingAction(
      "threadList",
      {
        guildId: "G1",
      },
      enableAllActions,
    );

    expect(result.details).toMatchObject({
      ok: true,
      complete: true,
      hasMore: false,
      returnedCount: 1,
      source: "discord.threadList.active",
      query: {
        guildId: "G1",
        includeArchived: false,
      },
    });
    expect((result.details as { threads?: unknown }).threads).toEqual({
      threads: [{ id: "thread-active", name: "Current project" }],
      members: [{ id: "member-1" }],
    });
    expect(result.details).not.toHaveProperty("nextBefore");
  });

  it("resolves Discord DM targets for reaction adds", async () => {
    const resolveReactionTarget = vi.fn(async () => "DM1");
    discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget;

    await handleMessagingAction(
      "react",
      {
        to: "user:U1",
        messageId: "M1",
        emoji: "✅",
      },
      enableAllActions,
    );

    expect(resolveReactionTarget).toHaveBeenCalledWith({
      target: "user:U1",
      cfg: DISCORD_TEST_CFG,
      accountId: "default",
    });
    expect(reactMessageDiscord).toHaveBeenCalledWith("DM1", "M1", "✅", {
      cfg: DISCORD_TEST_CFG,
      accountId: "default",
    });
  });

  it("resolves Discord DM targets for reaction listing", async () => {
    const resolveReactionTarget = vi.fn(async () => "DM1");
    discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget;

    await handleMessagingAction(
      "reactions",
      {
        to: "user:U1",
        messageId: "M1",
      },
      enableAllActions,
    );

    expect(resolveReactionTarget).toHaveBeenCalledWith({
      target: "user:U1",
      cfg: DISCORD_TEST_CFG,
      accountId: "default",
    });
    expect(fetchReactionsDiscord).toHaveBeenCalledWith("DM1", "M1", {
      cfg: DISCORD_TEST_CFG,
      accountId: "default",
      limit: undefined,
    });
  });

  it("rejects fractional Discord reaction limits before fetching reactions", async () => {
    await expect(
      handleMessagingAction(
        "reactions",
        {
          channelId: "C1",
          messageId: "M1",
          limit: 2.5,
        },
        enableAllActions,
      ),
    ).rejects.toThrow("limit must be a positive integer");
    expect(fetchReactionsDiscord).not.toHaveBeenCalled();
  });

  it("rejects Discord reaction reads for non-allowlisted target channels", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleMessagingAction(
        "reactions",
        { channelId: "444", messageId: "M1" },
        enableAllActions,
        cfg,
      ),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(fetchReactionsDiscord).not.toHaveBeenCalled();
  });

  it("removes reactions on empty emoji", async () => {
    await handleMessagingAction(
      "react",
      {
        channelId: "C1",
        messageId: "M1",
        emoji: "",
      },
      enableAllActions,
    );
    expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1", {
      cfg: DISCORD_TEST_CFG,
      accountId: "default",
    });
  });

  it("removes reactions when remove flag set", async () => {
    await handleMessagingAction(
      "react",
      {
        channelId: "C1",
        messageId: "M1",
        emoji: "✅",
        remove: true,
      },
      enableAllActions,
    );
    expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {
      cfg: DISCORD_TEST_CFG,
      accountId: "default",
    });
  });

  it("rejects removes without emoji", async () => {
    await expect(
      handleMessagingAction(
        "react",
        {
          channelId: "C1",
          messageId: "M1",
          emoji: "",
          remove: true,
        },
        enableAllActions,
      ),
    ).rejects.toThrow(/Emoji is required/);
  });

  it("respects reaction gating", async () => {
    await expect(
      handleMessagingAction(
        "react",
        {
          channelId: "C1",
          messageId: "M1",
          emoji: "✅",
        },
        disabledActions,
      ),
    ).rejects.toThrow(/Discord reactions are disabled/);
  });

  it("parses string booleans for poll options", async () => {
    await handleMessagingAction(
      "poll",
      {
        to: "channel:123",
        question: "Lunch?",
        answers: ["Pizza", "Sushi"],
        allowMultiselect: "true",
        durationHours: "24",
      },
      enableAllActions,
    );

    expect(sendPollDiscord).toHaveBeenCalledWith(
      "channel:123",
      {
        question: "Lunch?",
        options: ["Pizza", "Sushi"],
        maxSelections: 2,
        durationHours: 24,
      },
      { cfg: DISCORD_TEST_CFG, content: undefined },
    );
  });

  it("rejects Discord permission reads for non-allowlisted target channels", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleMessagingAction("permissions", { channelId: "444" }, enableAllActions, cfg),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(fetchChannelPermissionsDiscord).not.toHaveBeenCalled();
  });

  it("adds normalized timestamps to readMessages payloads", async () => {
    readMessagesDiscord.mockResolvedValueOnce([
      { id: "1", timestamp: "2026-01-15T10:00:00.000Z" },
    ] as never);

    const result = await handleMessagingAction(
      "readMessages",
      { channelId: "C1" },
      enableAllActions,
    );
    const payload = result.details as {
      messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
    };

    const expectedMs = Date.parse("2026-01-15T10:00:00.000Z");
    expect(payload.messages[0].timestampMs).toBe(expectedMs);
    expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
  });

  it("rejects unexpected readMessages payloads with a boundary error", async () => {
    readMessagesDiscord.mockResolvedValueOnce({ ok: true } as never);

    await expect(
      handleMessagingAction("readMessages", { channelId: "C1" }, enableAllActions),
    ).rejects.toThrow("Discord message read returned object with keys ok instead of an array.");
  });

  it("threads provided cfg into readMessages calls", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
        },
      },
    } as OpenClawConfig;
    await handleMessagingAction("readMessages", { channelId: "C1" }, enableAllActions, cfg);
    expect(readMessagesDiscord).toHaveBeenCalledWith(
      "C1",
      { limit: undefined, before: undefined, after: undefined, around: undefined },
      { cfg },
    );
  });

  it("rejects fractional Discord read limits before reading messages", async () => {
    await expect(
      handleMessagingAction(
        "readMessages",
        {
          channelId: "C1",
          limit: "3.5",
        },
        enableAllActions,
      ),
    ).rejects.toThrow("limit must be a positive integer");
    expect(readMessagesDiscord).not.toHaveBeenCalled();
  });

  it("reads from allowlisted Discord target channels", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await handleMessagingAction("readMessages", { channelId: "222" }, enableAllActions, cfg);

    expect(readMessagesDiscord).toHaveBeenCalledWith(
      "222",
      { limit: undefined, before: undefined, after: undefined, around: undefined },
      { cfg },
    );
  });

  it("reads from Discord target channels allowlisted under a guild slug", async () => {
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "222",
      guild_id: "111",
      type: 0,
    });
    fetchGuildInfoDiscord.mockResolvedValueOnce({
      id: "111",
      name: "Friends of OpenClaw",
    });
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "friends-of-openclaw": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await handleMessagingAction("readMessages", { channelId: "222" }, enableAllActions, cfg);

    expect(fetchGuildInfoDiscord).toHaveBeenCalledWith("111", { cfg });
    expect(readMessagesDiscord).toHaveBeenCalledWith(
      "222",
      { limit: undefined, before: undefined, after: undefined, around: undefined },
      { cfg },
    );
  });

  it("rejects Discord reads for non-allowlisted target channels", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleMessagingAction("readMessages", { channelId: "333" }, enableAllActions, cfg),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(readMessagesDiscord).not.toHaveBeenCalled();
  });

  it("fails closed for Discord message reads when provider config is missing", async () => {
    const cfg = {} as OpenClawConfig;

    await expect(
      handleMessagingAction("readMessages", { channelId: "C1" }, enableAllActions, cfg),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(readMessagesDiscord).not.toHaveBeenCalled();

    await expect(
      handleMessagingAction(
        "fetchMessage",
        { messageLink: "https://discord.com/channels/111/222/333" },
        enableAllActions,
        cfg,
      ),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(fetchMessageDiscord).not.toHaveBeenCalled();
  });

  it("adds normalized timestamps to fetchMessage payloads", async () => {
    fetchMessageDiscord.mockResolvedValueOnce({
      id: "1",
      timestamp: "2026-01-15T11:00:00.000Z",
    });

    const result = await handleMessagingAction(
      "fetchMessage",
      { guildId: "G1", channelId: "C1", messageId: "M1" },
      enableAllActions,
    );
    const payload = result.details as { message?: { timestampMs?: number; timestampUtc?: string } };

    const expectedMs = Date.parse("2026-01-15T11:00:00.000Z");
    expect(payload.message?.timestampMs).toBe(expectedMs);
    expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
  });

  it("threads provided cfg into fetchMessage calls", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
        },
      },
    } as OpenClawConfig;
    await handleMessagingAction(
      "fetchMessage",
      { guildId: "G1", channelId: "C1", messageId: "M1" },
      enableAllActions,
      cfg,
    );
    expect(fetchMessageDiscord).toHaveBeenCalledWith("C1", "M1", { cfg });
  });

  it("fetches Discord messages from channels allowlisted under a guild slug", async () => {
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "222",
      guild_id: "111",
      type: 0,
    });
    fetchGuildInfoDiscord.mockResolvedValueOnce({
      id: "111",
      name: "Friends of OpenClaw",
    });
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "friends-of-openclaw": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await handleMessagingAction(
      "fetchMessage",
      { messageLink: "https://discord.com/channels/111/222/333" },
      enableAllActions,
      cfg,
    );

    expect(fetchGuildInfoDiscord).toHaveBeenCalledWith("111", { cfg });
    expect(fetchMessageDiscord).toHaveBeenCalledWith("222", "333", { cfg });
  });

  it("rejects Discord message links for non-allowlisted target channels", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleMessagingAction(
        "fetchMessage",
        { messageLink: "https://discord.com/channels/111/333/444" },
        enableAllActions,
        cfg,
      ),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(fetchMessageDiscord).not.toHaveBeenCalled();
  });

  it("allows Discord message links in threads under allowlisted parent channels", async () => {
    fetchChannelInfoDiscord.mockImplementation(async (channelId: string) => {
      if (channelId === "333") {
        return { id: "333", name: "incident-thread", parent_id: "222", type: 11 };
      }
      if (channelId === "222") {
        return { id: "222", name: "team-updates", type: 0 };
      }
      return { id: channelId, type: 0 };
    });
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await handleMessagingAction(
      "fetchMessage",
      { messageLink: "https://discord.com/channels/111/333/444" },
      enableAllActions,
      cfg,
    );

    expect(fetchMessageDiscord).toHaveBeenCalledWith("333", "444", { cfg });
  });

  it("rejects Discord message links when the fetched channel belongs to a different guild", async () => {
    fetchChannelInfoDiscord.mockImplementation(async (channelId: string) => ({
      id: channelId,
      guild_id: "222",
      name: "allowed-channel",
      type: 0,
    }));
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "allowed-channel": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleMessagingAction(
        "fetchMessage",
        { messageLink: "https://discord.com/channels/111/333/444" },
        enableAllActions,
        cfg,
      ),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(fetchMessageDiscord).not.toHaveBeenCalled();
  });

  it.each([
    { action: "readMessages", runtimeCall: readMessagesDiscord },
    { action: "listPins", runtimeCall: listPinsDiscord },
  ])(
    "rejects Discord $action when the fetched guild is not allowlisted by a matching channel name",
    async ({ action, runtimeCall }) => {
      fetchChannelInfoDiscord.mockImplementation(async (channelId: string) => ({
        id: channelId,
        guild_id: "222",
        name: "allowed-channel",
        type: 0,
      }));
      const cfg = {
        channels: {
          discord: {
            token: "token",
            groupPolicy: "allowlist",
            guilds: {
              "111": {
                channels: {
                  "allowed-channel": { enabled: true },
                },
              },
            },
          },
        },
      } as OpenClawConfig;

      await expect(
        handleMessagingAction(action, { channelId: "333" }, enableAllActions, cfg),
      ).rejects.toThrow("Discord read target channel is not allowed.");
      expect(runtimeCall).not.toHaveBeenCalled();
    },
  );

  it("adds normalized timestamps to listPins payloads", async () => {
    listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);

    const result = await handleMessagingAction("listPins", { channelId: "C1" }, enableAllActions);
    const payload = result.details as {
      pins: Array<{ timestampMs?: number; timestampUtc?: string }>;
    };

    const expectedMs = Date.parse("2026-01-15T12:00:00.000Z");
    expect(payload.pins[0].timestampMs).toBe(expectedMs);
    expect(payload.pins[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
  });

  it("rejects Discord pin reads for non-allowlisted target channels", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleMessagingAction("listPins", { channelId: "444" }, enableAllActions, cfg),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(listPinsDiscord).not.toHaveBeenCalled();
  });

  it("adds normalized timestamps to searchMessages payloads", async () => {
    searchMessagesDiscord.mockResolvedValueOnce({
      total_results: 1,
      messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]],
    });

    const result = await handleMessagingAction(
      "searchMessages",
      { guildId: "G1", content: "hi" },
      enableAllActions,
    );
    const payload = result.details as {
      results?: { messages?: Array<Array<{ timestampMs?: number; timestampUtc?: string }>> };
    };

    const expectedMs = Date.parse("2026-01-15T13:00:00.000Z");
    expect(payload.results?.messages?.[0]?.[0]?.timestampMs).toBe(expectedMs);
    expect(payload.results?.messages?.[0]?.[0]?.timestampUtc).toBe(
      new Date(expectedMs).toISOString(),
    );
  });

  it("rejects Discord searches for non-allowlisted target channels", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleMessagingAction(
        "searchMessages",
        { guildId: "111", channelId: "444", content: "canary" },
        enableAllActions,
        cfg,
      ),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(searchMessagesDiscord).not.toHaveBeenCalled();
  });

  it("requires explicit Discord search targets when channels are allowlisted", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "222": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleMessagingAction(
        "searchMessages",
        { guildId: "111", content: "canary" },
        enableAllActions,
        cfg,
      ),
    ).rejects.toThrow(
      "Discord message search requires channelId or channelIds so each read target can be authorized.",
    );
    expect(searchMessagesDiscord).not.toHaveBeenCalled();
  });

  it("fails closed for Discord guild-wide searches when provider config is missing", async () => {
    const cfg = {} as OpenClawConfig;

    await expect(
      handleMessagingAction(
        "searchMessages",
        { guildId: "111", content: "canary" },
        enableAllActions,
        cfg,
      ),
    ).rejects.toThrow("Discord read target channel is not allowed.");
    expect(searchMessagesDiscord).not.toHaveBeenCalled();
  });

  it("allows guild-wide Discord searches when the guild has a wildcard channel allowlist", async () => {
    const cfg = {
      channels: {
        discord: {
          token: "token",
          groupPolicy: "allowlist",
          guilds: {
            "111": {
              channels: {
                "*": { enabled: true },
              },
            },
          },
        },
      },
    } as OpenClawConfig;

    await handleMessagingAction(
      "searchMessages",
      { guildId: "111", content: "canary" },
      enableAllActions,
      cfg,
    );

    expect(searchMessagesDiscord).toHaveBeenCalledWith(
      {
        guildId: "111",
        content: "canary",
        channelIds: undefined,
        authorIds: undefined,
        limit: undefined,
      },
      { cfg },
    );
  });

  it("resolves guildId from channel info when guildId is omitted in searchMessages", async () => {
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "C1",
      type: 0,
      guild_id: "resolved-guild",
    });
    searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });

    await handleMessagingAction(
      "searchMessages",
      { channelId: "C1", content: "hello" },
      enableAllActions,
    );

    expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", expect.anything());
    expect(searchMessagesDiscord).toHaveBeenCalledWith(
      expect.objectContaining({ guildId: "resolved-guild", content: "hello" }),
      expect.anything(),
    );
  });

  it("normalizes channel: prefixed channelId before resolving guildId in searchMessages", async () => {
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "C1",
      type: 0,
      guild_id: "resolved-guild",
    });
    searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });

    await handleMessagingAction(
      "searchMessages",
      { channelId: "channel:C1", content: "hello" },
      enableAllActions,
    );

    expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", expect.anything());
    expect(searchMessagesDiscord).toHaveBeenCalledWith(
      expect.objectContaining({ guildId: "resolved-guild", content: "hello", channelIds: ["C1"] }),
      expect.anything(),
    );
  });

  it("accepts query as alias for content in searchMessages", async () => {
    searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });

    await handleMessagingAction(
      "searchMessages",
      { guildId: "G1", query: "find this" },
      enableAllActions,
    );

    expect(searchMessagesDiscord).toHaveBeenCalledWith(
      expect.objectContaining({ guildId: "G1", content: "find this" }),
      expect.anything(),
    );
  });

  it("throws descriptive error when guildId cannot be resolved in searchMessages", async () => {
    await expect(
      handleMessagingAction("searchMessages", { content: "hello" }, enableAllActions),
    ).rejects.toThrow(
      "Discord search requires guildId. Provide guildId explicitly, or provide channelId so the guild can be resolved from the channel.",
    );
    expect(searchMessagesDiscord).not.toHaveBeenCalled();
  });

  it("sends voice messages from a local file path", async () => {
    sendVoiceMessageDiscord.mockClear();
    sendMessageDiscord.mockClear();

    await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:123",
        path: "/tmp/voice.mp3",
        asVoice: true,
        silent: true,
      },
      enableAllActions,
    );

    expect(sendVoiceMessageDiscord).toHaveBeenCalledWith("channel:123", "/tmp/voice.mp3", {
      cfg: DISCORD_TEST_CFG,
      replyTo: undefined,
      silent: true,
    });
    expect(sendMessageDiscord).not.toHaveBeenCalled();
  });

  it("forwards trusted mediaLocalRoots into sendMessageDiscord", async () => {
    sendMessageDiscord.mockClear();
    const mediaReadFile = vi.fn(async () => Buffer.from("image"));
    const mediaAccess = { localRoots: ["/tmp/agent-root"], readFile: mediaReadFile };
    await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:123",
        content: "hello",
        mediaUrl: "/tmp/image.png",
      },
      enableAllActions,
      DISCORD_TEST_CFG,
      { mediaAccess, mediaLocalRoots: ["/tmp/agent-root"], mediaReadFile },
    );
    expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
    const call = mockCall(sendMessageDiscord, "sendMessageDiscord");
    const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
    expect(call[0]).toBe("channel:123");
    expect(call[1]).toBe("hello");
    expect(sendOptions.mediaAccess).toBe(mediaAccess);
    expect(sendOptions.mediaUrl).toBe("/tmp/image.png");
    expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
    expect(sendOptions.mediaReadFile).toBe(mediaReadFile);
  });

  it("allows media-only message sends", async () => {
    sendMessageDiscord.mockClear();
    await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:123",
        mediaUrl: "/tmp/image.png",
      },
      enableAllActions,
      DISCORD_TEST_CFG,
      { mediaLocalRoots: ["/tmp/agent-root"] },
    );
    expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
    const call = mockCall(sendMessageDiscord, "sendMessageDiscord");
    const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
    expect(call[0]).toBe("channel:123");
    const content = call[1];
    expect(content).toBe("");
    expect(sendOptions.mediaUrl).toBe("/tmp/image.png");
    expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
  });

  it("ignores empty components objects for regular media sends", async () => {
    sendMessageDiscord.mockClear();
    sendDiscordComponentMessage.mockClear();

    await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:123",
        content: "hello",
        mediaUrl: "/tmp/image.png",
        components: {},
      },
      enableAllActions,
      DISCORD_TEST_CFG,
      { mediaLocalRoots: ["/tmp/agent-root"] },
    );

    expect(sendDiscordComponentMessage).not.toHaveBeenCalled();
    expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
    const call = mockCall(sendMessageDiscord, "sendMessageDiscord");
    const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
    expect(call[0]).toBe("channel:123");
    const content = call[1];
    expect(content).toBe("hello");
    expect(sendOptions.mediaUrl).toBe("/tmp/image.png");
    expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]);
  });

  it("forwards the optional filename into sendMessageDiscord", async () => {
    sendMessageDiscord.mockClear();
    await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:123",
        content: "hello",
        mediaUrl: "/tmp/generated-image",
        filename: "image.png",
      },
      enableAllActions,
    );
    expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
    const call = mockCall(sendMessageDiscord, "sendMessageDiscord");
    const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
    expect(call[0]).toBe("channel:123");
    const content = call[1];
    expect(content).toBe("hello");
    expect(sendOptions.mediaUrl).toBe("/tmp/generated-image");
    expect(sendOptions.filename).toBe("image.png");
  });

  it("renames an existing thread when threadName is provided on sendMessage", async () => {
    sendMessageDiscord.mockResolvedValueOnce({
      messageId: "M1",
      channelId: "T1",
    });
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "T1",
      type: 11,
    });
    editChannelDiscord.mockResolvedValueOnce({
      id: "T1",
      name: "new-thread",
    });

    const result = await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:T1",
        content: "hello",
        threadName: "new-thread",
      },
      enableAllActions,
    );

    expect(sendMessageDiscord).toHaveBeenCalledWith("channel:T1", "hello", {
      cfg: DISCORD_TEST_CFG,
      accountId: undefined,
      mediaAccess: undefined,
      mediaUrl: undefined,
      filename: undefined,
      mediaLocalRoots: undefined,
      mediaReadFile: undefined,
      replyTo: undefined,
      components: undefined,
      embeds: undefined,
      silent: false,
    });
    expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("T1", { cfg: DISCORD_TEST_CFG });
    expect(editChannelDiscord).toHaveBeenCalledWith(
      {
        channelId: "T1",
        name: "new-thread",
      },
      { cfg: DISCORD_TEST_CFG },
    );
    expect(result.details).toEqual({
      ok: true,
      result: {
        messageId: "M1",
        channelId: "T1",
      },
      threadRename: {
        ok: true,
        channelId: "T1",
        name: "new-thread",
      },
    });
  });

  it("forwards sendMessage suppressEmbeds overrides", async () => {
    sendMessageDiscord.mockClear();

    await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:123",
        content: "https://example.com",
        suppressEmbeds: false,
      },
      enableAllActions,
    );

    const sendOptions = mockObjectArg(sendMessageDiscord, "sendMessageDiscord", 0, 2);
    expect(sendOptions.suppressEmbeds).toBe(false);
  });

  it("warns instead of renaming when threadName is provided but channel management is disabled", async () => {
    sendMessageDiscord.mockResolvedValueOnce({
      messageId: "M1",
      channelId: "T1",
    });

    const messagesOnly = (key: keyof DiscordActionConfig) => key === "messages";
    const result = await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:T1",
        content: "hello",
        threadName: "new-thread",
      },
      messagesOnly,
    );

    expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
    expect(fetchChannelInfoDiscord).not.toHaveBeenCalled();
    expect(editChannelDiscord).not.toHaveBeenCalled();
    expect(result.details).toEqual({
      ok: true,
      result: {
        messageId: "M1",
        channelId: "T1",
      },
      warning: "Discord threadName was ignored because Discord channel management is disabled.",
    });
  });

  it("warns instead of renaming when threadName is provided for a non-thread send target", async () => {
    sendMessageDiscord.mockResolvedValueOnce({
      messageId: "M1",
      channelId: "C1",
    });
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "C1",
      type: 0,
    });

    const result = await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:C1",
        content: "hello",
        threadName: "new-thread",
      },
      enableAllActions,
    );

    expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", { cfg: DISCORD_TEST_CFG });
    expect(editChannelDiscord).not.toHaveBeenCalled();
    expect(result.details).toEqual({
      ok: true,
      result: {
        messageId: "M1",
        channelId: "C1",
      },
      warning: "Discord threadName was ignored because the send target is not a thread.",
    });
  });

  it("preserves message delivery and warns when thread rename fails", async () => {
    sendMessageDiscord.mockResolvedValueOnce({
      messageId: "M1",
      channelId: "T1",
    });
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "T1",
      type: 11,
    });
    editChannelDiscord.mockRejectedValueOnce(new Error("missing permissions"));

    const result = await handleMessagingAction(
      "sendMessage",
      {
        to: "channel:T1",
        content: "hello",
        threadName: "new-thread",
      },
      enableAllActions,
    );

    expect(sendMessageDiscord).toHaveBeenCalledTimes(1);
    expect(editChannelDiscord).toHaveBeenCalledWith(
      {
        channelId: "T1",
        name: "new-thread",
      },
      { cfg: DISCORD_TEST_CFG },
    );
    expect(result.details).toEqual({
      ok: true,
      result: {
        messageId: "M1",
        channelId: "T1",
      },
      warning: "Discord message was sent, but thread rename failed: missing permissions",
    });
  });

  it("rejects voice messages that include content", async () => {
    await expect(
      handleMessagingAction(
        "sendMessage",
        {
          to: "channel:123",
          mediaUrl: "/tmp/voice.mp3",
          asVoice: true,
          content: "hello",
        },
        enableAllActions,
      ),
    ).rejects.toThrow(/Voice messages cannot include text content/);
  });

  it("forwards optional thread content", async () => {
    createThreadDiscord.mockClear();
    await handleMessagingAction(
      "threadCreate",
      {
        channelId: "C1",
        name: "Forum thread",
        content: "Initial forum post body",
      },
      enableAllActions,
    );
    expect(createThreadDiscord).toHaveBeenCalledWith(
      "C1",
      {
        name: "Forum thread",
        messageId: undefined,
        autoArchiveMinutes: undefined,
        content: "Initial forum post body",
        appliedTags: undefined,
      },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("returns partial success when Discord creates the thread but initial message send fails", async () => {
    const thread = { id: "T1", name: "thread", type: 11 };
    createThreadDiscord.mockRejectedValueOnce(
      new DiscordThreadInitialMessageError(
        thread as ConstructorParameters<typeof DiscordThreadInitialMessageError>[0],
        new Error("missing access"),
      ),
    );

    const result = await handleMessagingAction(
      "threadCreate",
      {
        channelId: "C1",
        name: "thread",
        content: "Initial post",
      },
      enableAllActions,
    );

    expect(result.details).toEqual({
      ok: true,
      partial: true,
      thread,
      warning: "Discord thread was created, but sending the initial message failed.",
      initialMessageError: "missing access",
    });
  });
});

describe("handleDiscordGuildAction", () => {
  it("uses configured defaultAccount for omitted memberInfo presence lookup", async () => {
    setPresence("work", "U1", {
      user: { id: "U1" },
      guild_id: "G1",
      status: "online",
      activities: [],
      client_status: {},
    } as never);

    discordGuildActionRuntime.fetchMemberInfoDiscord = vi.fn(async () => ({
      user: { id: "U1" },
    })) as never;

    const cfg = {
      channels: {
        discord: {
          defaultAccount: "work",
          accounts: {
            work: { token: "token-work" },
          },
        },
      },
    } as OpenClawConfig;
    const result = await handleGuildAction(
      "memberInfo",
      {
        guildId: "G1",
        userId: "U1",
      },
      enableAllActions,
      cfg,
    );

    expect(discordGuildActionRuntime.fetchMemberInfoDiscord).toHaveBeenCalledWith("G1", "U1", {
      cfg,
      accountId: "work",
    });
    const details = result.details as Record<string, unknown>;
    expect(details.ok).toBe(true);
    expect(details.status).toBe("online");
    expect(details.activities).toEqual([]);
  });
});

const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
const channelsDisabled = () => false;

describe("handleDiscordGuildAction - channel management", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("creates a channel", async () => {
    const result = await handleGuildAction(
      "channelCreate",
      {
        guildId: "G1",
        name: "test-channel",
        type: 0,
        topic: "Test topic",
      },
      channelsEnabled,
    );
    expect(createChannelDiscord).toHaveBeenCalledWith(
      {
        guildId: "G1",
        name: "test-channel",
        type: 0,
        parentId: undefined,
        topic: "Test topic",
        position: undefined,
        nsfw: undefined,
      },
      { cfg: DISCORD_TEST_CFG },
    );
    expect(result.details).toEqual({
      ok: true,
      channel: {
        id: "new-channel",
        name: "test",
        type: 0,
      },
    });
  });

  it("prefers channelType when creating a channel", async () => {
    await handleGuildAction(
      "channelCreate",
      {
        guildId: "G1",
        name: "forum-thread",
        channelType: 11,
        type: 0,
      },
      channelsEnabled,
    );
    expect(createChannelDiscord).toHaveBeenCalledWith(
      expect.objectContaining({
        guildId: "G1",
        name: "forum-thread",
        type: 11,
      }),
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("respects channel gating for channelCreate", async () => {
    await expect(
      handleGuildAction("channelCreate", { guildId: "G1", name: "test" }, channelsDisabled),
    ).rejects.toThrow(/Discord channel management is disabled/);
  });

  it("preserves trusted owner/manual channel actions without sender ids", async () => {
    await handleGuildAction("channelDelete", { channelId: "C1" }, channelsEnabled);

    expect(hasAnyChannelPermissionDiscord).not.toHaveBeenCalled();
    expect(hasAnyGuildPermissionDiscord).not.toHaveBeenCalled();
    expect(deleteChannelDiscord).toHaveBeenCalledWith("C1", { cfg: DISCORD_TEST_CFG });
  });

  it("rejects Discord sender channel actions when sender lacks MANAGE_CHANNELS", async () => {
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "C1",
      type: 0,
      guild_id: "G1",
    });
    hasAnyChannelPermissionDiscord.mockResolvedValueOnce(false);

    await expect(
      handleGuildAction(
        "channelDelete",
        { channelId: "C1", senderUserId: "sender-1", accountId: "ops" },
        channelsEnabled,
      ),
    ).rejects.toThrow(/required permissions/);

    expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", {
      cfg: DISCORD_TEST_CFG,
      accountId: "ops",
    });
    expect(hasAnyChannelPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "C1",
      "sender-1",
      [PermissionFlagsBits.ManageChannels],
      { cfg: DISCORD_TEST_CFG, accountId: "ops" },
    );
    expect(hasAnyGuildPermissionDiscord).not.toHaveBeenCalled();
    expect(deleteChannelDiscord).not.toHaveBeenCalled();
  });

  it("uses guild permissions for Discord sender channel create actions", async () => {
    await handleGuildAction(
      "channelCreate",
      { guildId: "G1", name: "test", senderUserId: "sender-1", accountId: "ops" },
      channelsEnabled,
    );

    expect(hasAnyGuildPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "sender-1",
      [PermissionFlagsBits.ManageChannels],
      { cfg: DISCORD_TEST_CFG, accountId: "ops" },
    );
    expect(hasAnyChannelPermissionDiscord).not.toHaveBeenCalled();
    expect(createChannelDiscord).toHaveBeenCalled();
  });

  it("uses thread permissions for Discord sender thread edits", async () => {
    const threadChannel = {
      id: "T1",
      type: ChannelType.GuildPublicThread,
      guild_id: "G1",
    };
    fetchChannelInfoDiscord
      .mockResolvedValueOnce(threadChannel)
      .mockResolvedValueOnce(threadChannel);

    await handleGuildAction(
      "channelEdit",
      { channelId: "T1", archived: true, senderUserId: "sender-1" },
      channelsEnabled,
    );

    expect(hasAnyChannelPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "T1",
      "sender-1",
      [PermissionFlagsBits.ManageThreads],
      { cfg: DISCORD_TEST_CFG },
    );
    expect(editChannelDiscord).toHaveBeenCalled();
  });

  it("requires ManageThreads for Discord sender thread unlocks", async () => {
    const threadChannel = {
      id: "T1",
      type: ChannelType.GuildPublicThread,
      guild_id: "G1",
    };
    fetchChannelInfoDiscord
      .mockResolvedValueOnce(threadChannel)
      .mockResolvedValueOnce(threadChannel);

    await handleGuildAction(
      "channelEdit",
      { channelId: "T1", locked: false, senderUserId: "sender-1" },
      channelsEnabled,
    );

    expect(hasAnyChannelPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "T1",
      "sender-1",
      [PermissionFlagsBits.ManageThreads],
      { cfg: DISCORD_TEST_CFG },
    );
    expect(editChannelDiscord).toHaveBeenCalled();
  });

  it("requires ManageThreads to reopen locked Discord sender threads", async () => {
    const threadChannel = {
      id: "T1",
      type: ChannelType.GuildPublicThread,
      guild_id: "G1",
      thread_metadata: { locked: true },
    };
    fetchChannelInfoDiscord
      .mockResolvedValueOnce(threadChannel)
      .mockResolvedValueOnce(threadChannel);

    await handleGuildAction(
      "channelEdit",
      { channelId: "T1", archived: false, senderUserId: "sender-1" },
      channelsEnabled,
    );

    expect(hasAnyChannelPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "T1",
      "sender-1",
      [PermissionFlagsBits.ManageThreads],
      { cfg: DISCORD_TEST_CFG },
    );
    expect(editChannelDiscord).toHaveBeenCalled();
  });

  it("allows SendMessagesInThreads for unlocked Discord sender thread reopens", async () => {
    const threadChannel = {
      id: "T1",
      type: ChannelType.GuildPublicThread,
      guild_id: "G1",
      thread_metadata: { locked: false },
    };
    fetchChannelInfoDiscord
      .mockResolvedValueOnce(threadChannel)
      .mockResolvedValueOnce(threadChannel);

    await handleGuildAction(
      "channelEdit",
      { channelId: "T1", archived: false, senderUserId: "sender-1" },
      channelsEnabled,
    );

    expect(hasAnyChannelPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "T1",
      "sender-1",
      [PermissionFlagsBits.ManageThreads, PermissionFlagsBits.SendMessagesInThreads],
      { cfg: DISCORD_TEST_CFG },
    );
    expect(editChannelDiscord).toHaveBeenCalled();
  });

  it("uses channel event permissions for Discord sender channel events", async () => {
    await handleGuildAction(
      "eventCreate",
      {
        guildId: "G1",
        channelId: "C1",
        name: "standup",
        startTime: "2026-05-27T12:00:00.000Z",
        senderUserId: "sender-1",
      },
      (key) => key === "events",
    );

    expect(hasAnyChannelPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "C1",
      "sender-1",
      [PermissionFlagsBits.ManageEvents, PermissionFlagsBits.CreateEvents],
      { cfg: DISCORD_TEST_CFG },
    );
    expect(hasAnyGuildPermissionDiscord).not.toHaveBeenCalled();
    expect(createScheduledEventDiscord).toHaveBeenCalled();
  });

  it("reports disabled channel actions before Discord permission lookups", async () => {
    await expect(
      handleGuildAction(
        "channelDelete",
        { channelId: "C1", senderUserId: "sender-1" },
        channelsDisabled,
      ),
    ).rejects.toThrow(/Discord channel management is disabled/);

    expect(fetchChannelInfoDiscord).not.toHaveBeenCalled();
    expect(hasAnyChannelPermissionDiscord).not.toHaveBeenCalled();
    expect(hasAnyGuildPermissionDiscord).not.toHaveBeenCalled();
    expect(deleteChannelDiscord).not.toHaveBeenCalled();
  });

  it("preserves trusted owner/manual role actions without sender ids", async () => {
    await handleGuildAction("roleAdd", { guildId: "G1", userId: "U1", roleId: "R1" }, rolesEnabled);

    expect(hasAnyGuildPermissionDiscord).not.toHaveBeenCalled();
    expect(canManageGuildMemberRoleDiscord).not.toHaveBeenCalled();
    expect(addRoleDiscord).toHaveBeenCalledWith(
      { guildId: "G1", userId: "U1", roleId: "R1" },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("rejects Discord sender role actions when sender cannot manage the role hierarchy", async () => {
    canManageGuildMemberRoleDiscord.mockResolvedValueOnce(false);

    await expect(
      handleGuildAction(
        "roleAdd",
        { guildId: "G1", userId: "U1", roleId: "R1", senderUserId: "sender-1" },
        rolesEnabled,
      ),
    ).rejects.toThrow(/cannot manage/);

    expect(hasAnyGuildPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "sender-1",
      [PermissionFlagsBits.ManageRoles],
      { cfg: DISCORD_TEST_CFG },
    );
    expect(canManageGuildMemberRoleDiscord).toHaveBeenCalledWith(
      "G1",
      "sender-1",
      "U1",
      "R1",
      { cfg: DISCORD_TEST_CFG },
      { assignablePermissionCeiling: true },
    );
    expect(addRoleDiscord).not.toHaveBeenCalled();
  });

  it("forwards accountId for channelList", async () => {
    await handleGuildAction("channelList", { guildId: "G1", accountId: "ops" }, channelInfoEnabled);
    expect(listGuildChannelsDiscord).toHaveBeenCalledWith("G1", {
      cfg: DISCORD_TEST_CFG,
      accountId: "ops",
    });
  });

  it("edits a channel", async () => {
    await handleGuildAction(
      "channelEdit",
      {
        channelId: "C1",
        name: "new-name",
        topic: "new topic",
      },
      channelsEnabled,
    );
    expect(editChannelDiscord).toHaveBeenCalledWith(
      {
        channelId: "C1",
        name: "new-name",
        topic: "new topic",
        position: undefined,
        parentId: undefined,
        nsfw: undefined,
        rateLimitPerUser: undefined,
        archived: undefined,
        locked: undefined,
        autoArchiveDuration: undefined,
      },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("forwards thread edit fields", async () => {
    await handleGuildAction(
      "channelEdit",
      {
        channelId: "C1",
        archived: true,
        locked: false,
        autoArchiveDuration: 1440,
      },
      channelsEnabled,
    );
    expect(editChannelDiscord).toHaveBeenCalledWith(
      {
        channelId: "C1",
        name: undefined,
        topic: undefined,
        position: undefined,
        parentId: undefined,
        nsfw: undefined,
        rateLimitPerUser: undefined,
        archived: true,
        locked: false,
        autoArchiveDuration: 1440,
      },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("rejects fractional Discord channel edit integers before editing channels", async () => {
    await expect(
      handleGuildAction(
        "channelEdit",
        {
          channelId: "C1",
          position: 1.5,
        },
        channelsEnabled,
      ),
    ).rejects.toThrow("position must be a non-negative integer");
    expect(editChannelDiscord).not.toHaveBeenCalled();
  });

  it.each([
    ["parentId is null", { parentId: null }],
    ["clearParent is true", { clearParent: true }],
  ])("clears the channel parent when %s", async (_label, payload) => {
    await handleGuildAction(
      "channelEdit",
      {
        channelId: "C1",
        ...payload,
      },
      channelsEnabled,
    );
    expect(editChannelDiscord).toHaveBeenCalledWith(
      {
        channelId: "C1",
        name: undefined,
        topic: undefined,
        position: undefined,
        parentId: null,
        nsfw: undefined,
        rateLimitPerUser: undefined,
        archived: undefined,
        locked: undefined,
        autoArchiveDuration: undefined,
      },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("deletes a channel", async () => {
    await handleGuildAction("channelDelete", { channelId: "C1" }, channelsEnabled);
    expect(deleteChannelDiscord).toHaveBeenCalledWith("C1", { cfg: DISCORD_TEST_CFG });
  });

  it("moves a channel", async () => {
    await handleGuildAction(
      "channelMove",
      {
        guildId: "G1",
        channelId: "C1",
        parentId: "P1",
        position: 5,
      },
      channelsEnabled,
    );
    expect(moveChannelDiscord).toHaveBeenCalledWith(
      {
        guildId: "G1",
        channelId: "C1",
        parentId: "P1",
        position: 5,
      },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("rejects fractional Discord channel move positions before moving channels", async () => {
    await expect(
      handleGuildAction(
        "channelMove",
        {
          guildId: "G1",
          channelId: "C1",
          position: "5.5",
        },
        channelsEnabled,
      ),
    ).rejects.toThrow("position must be a non-negative integer");
    expect(moveChannelDiscord).not.toHaveBeenCalled();
  });

  it.each([
    ["parentId is null", { parentId: null }],
    ["clearParent is true", { clearParent: true }],
  ])("clears the channel parent on move when %s", async (_label, payload) => {
    await handleGuildAction(
      "channelMove",
      {
        guildId: "G1",
        channelId: "C1",
        ...payload,
      },
      channelsEnabled,
    );
    expect(moveChannelDiscord).toHaveBeenCalledWith(
      {
        guildId: "G1",
        channelId: "C1",
        parentId: null,
        position: undefined,
      },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("creates a category with type=4", async () => {
    await handleGuildAction(
      "categoryCreate",
      { guildId: "G1", name: "My Category" },
      channelsEnabled,
    );
    expect(createChannelDiscord).toHaveBeenCalledWith(
      {
        guildId: "G1",
        name: "My Category",
        type: 4,
        position: undefined,
      },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("edits a category", async () => {
    await handleGuildAction(
      "categoryEdit",
      { categoryId: "CAT1", name: "Renamed Category" },
      channelsEnabled,
    );
    expect(editChannelDiscord).toHaveBeenCalledWith(
      {
        channelId: "CAT1",
        name: "Renamed Category",
        position: undefined,
      },
      { cfg: DISCORD_TEST_CFG },
    );
  });

  it("deletes a category", async () => {
    await handleGuildAction("categoryDelete", { categoryId: "CAT1" }, channelsEnabled);
    expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1", { cfg: DISCORD_TEST_CFG });
  });

  it.each([
    {
      name: "role",
      params: {
        channelId: "C1",
        targetId: "R1",
        targetType: "role" as const,
        allow: "1024",
        deny: "2048",
      },
      expected: {
        channelId: "C1",
        targetId: "R1",
        targetType: 0,
        allow: "1024",
        deny: "2048",
      },
    },
    {
      name: "member",
      params: {
        channelId: "C1",
        targetId: "U1",
        targetType: "member" as const,
        allow: "1024",
      },
      expected: {
        channelId: "C1",
        targetId: "U1",
        targetType: 1,
        allow: "1024",
        deny: undefined,
      },
    },
  ])("sets channel permissions for $name", async ({ params, expected }) => {
    await handleGuildAction("channelPermissionSet", params, channelsEnabled);
    expect(setChannelPermissionDiscord).toHaveBeenCalledWith(expected, {
      cfg: DISCORD_TEST_CFG,
    });
  });

  it("uses channel-scoped ManageRoles for Discord sender channel permission edits", async () => {
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "C1",
      type: 0,
      guild_id: "G1",
    });

    await handleGuildAction(
      "channelPermissionSet",
      {
        channelId: "C1",
        targetId: "R1",
        targetType: "role",
        allow: "1024",
        senderUserId: "sender-1",
      },
      channelsEnabled,
    );

    expect(hasAnyChannelPermissionDiscord).toHaveBeenCalledWith(
      "G1",
      "C1",
      "sender-1",
      [PermissionFlagsBits.ManageRoles],
      { cfg: DISCORD_TEST_CFG },
    );
    expect(setChannelPermissionDiscord).toHaveBeenCalled();
  });

  it("rejects Discord sender role overwrites above the sender role hierarchy", async () => {
    fetchChannelInfoDiscord.mockResolvedValueOnce({
      id: "C1",
      type: 0,
      guild_id: "G1",
    });
    canManageGuildRoleDiscord.mockResolvedValueOnce(false);

    await expect(
      handleGuildAction(
        "channelPermissionSet",
        {
          channelId: "C1",
          targetId: "R1",
          targetType: "role",
          allow: "1024",
          senderUserId: "sender-1",
        },
        channelsEnabled,
      ),
    ).rejects.toThrow(/role overwrite/);

    expect(canManageGuildRoleDiscord).toHaveBeenCalledWith("G1", "sender-1", "R1", {
      cfg: DISCORD_TEST_CFG,
    });
    expect(setChannelPermissionDiscord).not.toHaveBeenCalled();
  });

  it("removes channel permissions", async () => {
    await handleGuildAction(
      "channelPermissionRemove",
      { channelId: "C1", targetId: "R1" },
      channelsEnabled,
    );
    expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1", {
      cfg: DISCORD_TEST_CFG,
    });
  });
});

describe("handleDiscordModerationAction", () => {
  it("forwards accountId for timeout", async () => {
    await handleModerationAction(
      "timeout",
      {
        guildId: "G1",
        userId: "U1",
        durationMinutes: 5,
        accountId: "ops",
      },
      moderationEnabled,
    );
    expect(timeoutMemberDiscord).toHaveBeenCalledTimes(1);
    const params = mockObjectArg(timeoutMemberDiscord, "timeoutMemberDiscord", 0, 0);
    expect(params.guildId).toBe("G1");
    expect(params.userId).toBe("U1");
    expect(params.durationMinutes).toBe(5);
    expect(mockCall(timeoutMemberDiscord, "timeoutMemberDiscord")[1]).toEqual({
      cfg: DISCORD_TEST_CFG,
      accountId: "ops",
    });
  });

  it("rejects fractional Discord moderation durations before timing out members", async () => {
    await expect(
      handleModerationAction(
        "timeout",
        {
          guildId: "G1",
          userId: "U1",
          durationMinutes: 5.5,
        },
        moderationEnabled,
      ),
    ).rejects.toThrow("durationMinutes must be a non-negative integer");
    expect(timeoutMemberDiscord).not.toHaveBeenCalled();
  });

  it("preserves zero-minute Discord timeouts for clearing existing timeouts", async () => {
    await handleModerationAction(
      "timeout",
      {
        guildId: "G1",
        userId: "U1",
        durationMinutes: 0,
      },
      moderationEnabled,
    );
    expect(timeoutMemberDiscord).toHaveBeenCalledTimes(1);
    const params = mockObjectArg(timeoutMemberDiscord, "timeoutMemberDiscord", 0, 0);
    expect(params.durationMinutes).toBe(0);
  });
});

describe("handleDiscordAction per-account gating", () => {
  it("allows moderation when account config enables it", async () => {
    const cfg = {
      channels: {
        discord: {
          accounts: {
            ops: { token: "tok-ops", actions: { moderation: true } },
          },
        },
      },
    } as OpenClawConfig;

    await handleDiscordAction(
      { action: "timeout", guildId: "G1", userId: "U1", durationMinutes: 5, accountId: "ops" },
      cfg,
    );
    expect(timeoutMemberDiscord).toHaveBeenCalledTimes(1);
    const params = mockObjectArg(timeoutMemberDiscord, "timeoutMemberDiscord", 0, 0);
    expect(params.guildId).toBe("G1");
    expect(params.userId).toBe("U1");
    expect(mockCall(timeoutMemberDiscord, "timeoutMemberDiscord")[1]).toEqual({
      cfg,
      accountId: "ops",
    });
  });

  it("blocks moderation when account omits it", async () => {
    const cfg = {
      channels: {
        discord: {
          accounts: {
            chat: { token: "tok-chat" },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleDiscordAction(
        { action: "timeout", guildId: "G1", userId: "U1", durationMinutes: 5, accountId: "chat" },
        cfg,
      ),
    ).rejects.toThrow(/Discord moderation is disabled/);
  });

  it("uses account-merged config, not top-level config", async () => {
    // Top-level has no moderation, but the account does
    const cfg = {
      channels: {
        discord: {
          token: "tok-base",
          accounts: {
            ops: { token: "tok-ops", actions: { moderation: true } },
          },
        },
      },
    } as OpenClawConfig;

    await handleDiscordAction(
      { action: "kick", guildId: "G1", userId: "U1", accountId: "ops" },
      cfg,
    );
    expect(kickMemberDiscord).toHaveBeenCalled();
  });

  it("inherits top-level channel gate when account overrides moderation only", async () => {
    const cfg = {
      channels: {
        discord: {
          actions: { channels: false },
          accounts: {
            ops: { token: "tok-ops", actions: { moderation: true } },
          },
        },
      },
    } as OpenClawConfig;

    await expect(
      handleDiscordAction(
        { action: "channelCreate", guildId: "G1", name: "alerts", accountId: "ops" },
        cfg,
      ),
    ).rejects.toThrow(/channel management is disabled/i);
  });

  it("allows account to explicitly re-enable top-level disabled channel gate", async () => {
    const cfg = {
      channels: {
        discord: {
          actions: { channels: false },
          accounts: {
            ops: {
              token: "tok-ops",
              actions: { moderation: true, channels: true },
            },
          },
        },
      },
    } as OpenClawConfig;

    await handleDiscordAction(
      { action: "channelCreate", guildId: "G1", name: "alerts", accountId: "ops" },
      cfg,
    );

    expect(createChannelDiscord).toHaveBeenCalledTimes(1);
    const params = mockObjectArg(createChannelDiscord, "createChannelDiscord", 0, 0);
    expect(params.guildId).toBe("G1");
    expect(params.name).toBe("alerts");
    expect(mockCall(createChannelDiscord, "createChannelDiscord")[1]).toEqual({
      cfg,
      accountId: "ops",
    });
  });
});
