import { ChannelType, MessageFlags, Routes } from "discord-api-types/v10";
// Discord tests cover send.creates thread plugin behavior.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { RateLimitError } from "./internal/discord.js";
import { makeDiscordRest } from "./send.test-harness.js";

vi.mock("openclaw/plugin-sdk/web-media", async () => {
  const { discordWebMediaMockFactory } = await import("./send.test-harness.js");
  return discordWebMediaMockFactory();
});

let addRoleDiscord: typeof import("./send.js").addRoleDiscord;
let banMemberDiscord: typeof import("./send.js").banMemberDiscord;
let createThreadDiscord: typeof import("./send.js").createThreadDiscord;
let discordOutbound: typeof import("./outbound-adapter.js").discordOutbound;
let DiscordThreadInitialMessageError: typeof import("./send.js").DiscordThreadInitialMessageError;
let listGuildEmojisDiscord: typeof import("./send.js").listGuildEmojisDiscord;
let listThreadsDiscord: typeof import("./send.js").listThreadsDiscord;
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
let removeRoleDiscord: typeof import("./send.js").removeRoleDiscord;
let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord;
let sendPollDiscord: typeof import("./send.js").sendPollDiscord;
let sendStickerDiscord: typeof import("./send.js").sendStickerDiscord;
let timeoutMemberDiscord: typeof import("./send.js").timeoutMemberDiscord;
let uploadEmojiDiscord: typeof import("./send.js").uploadEmojiDiscord;
let uploadStickerDiscord: typeof import("./send.js").uploadStickerDiscord;

const DISCORD_TEST_CFG = {
  channels: {
    discord: {
      accounts: {
        default: {},
      },
    },
  },
};

function discordClientOpts(rest: ReturnType<typeof makeDiscordRest>["rest"]) {
  return { cfg: DISCORD_TEST_CFG, rest, token: "t" };
}

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

const requireRecord = createRequireRecord("object", "expected-label");

function mockArg(source: MockCallSource, callIndex: number, argIndex: number, label: string) {
  const call = source.mock.calls[callIndex];
  if (!call) {
    throw new Error(`expected mock call: ${label}`);
  }
  return call[argIndex];
}

function requestOptions(source: MockCallSource, callIndex = 0) {
  return requireRecord(
    mockArg(source, callIndex, 1, `request options ${callIndex}`),
    "request options",
  );
}

function requestPath(source: MockCallSource, callIndex = 0) {
  return mockArg(source, callIndex, 0, `request path ${callIndex}`);
}

function requestBody(source: MockCallSource, callIndex = 0) {
  return requireRecord(requestOptions(source, callIndex).body, `request body ${callIndex}`);
}

function timerDelayAt(source: MockCallSource, callIndex = 0) {
  return mockArg(source, callIndex, 1, `timer delay ${callIndex}`);
}

function createDiscordForumPayloadHarness(parentType: ChannelType = ChannelType.GuildForum) {
  const parentId = "700";
  const { rest, getMock, postMock } = makeDiscordRest();
  let threadCount = 0;
  let messageCount = 0;

  getMock.mockImplementation(async (path: unknown) => {
    const channelId = String(path).split("/").at(-1);
    return {
      id: channelId,
      type: channelId === parentId ? parentType : ChannelType.PublicThread,
    };
  });
  postMock.mockImplementation(async (path: unknown) => {
    if (path === Routes.threads(parentId)) {
      threadCount += 1;
      const threadId = String(700 + threadCount);
      return {
        id: threadId,
        message: { id: `starter-${threadCount}`, channel_id: threadId },
      };
    }
    const channelId = String(path).split("/").at(-2);
    messageCount += 1;
    return { id: `message-${messageCount}`, channel_id: channelId };
  });

  return {
    parentId,
    postMock,
    run: async (
      payload: { text: string; mediaUrls?: string[] },
      options: {
        threadId?: string;
        onDeliveryResult?: Parameters<
          NonNullable<typeof discordOutbound.sendPayload>
        >[0]["onDeliveryResult"];
      } = {},
    ) =>
      await discordOutbound.sendPayload?.({
        cfg: DISCORD_TEST_CFG,
        to: `channel:${parentId}`,
        text: payload.text,
        payload,
        ...(options.threadId ? { threadId: options.threadId } : {}),
        ...(options.onDeliveryResult ? { onDeliveryResult: options.onDeliveryResult } : {}),
        deps: {
          discord: async (...[target, text, sendOptions]: Parameters<typeof sendMessageDiscord>) =>
            await sendMessageDiscord(target, text, {
              ...sendOptions,
              rest,
              token: "t",
            }),
        },
      }),
  };
}

function createRateLimitError(
  response: Response,
  body: { message: string; retry_after: number; global: boolean },
  request?: Request,
): RateLimitError {
  const fallbackRequest =
    request ??
    new Request("https://discord.com/api/v10/channels/789/messages", {
      method: "POST",
    });
  const RateLimitErrorCtor = RateLimitError as unknown as new (
    response: Response,
    body: { message: string; retry_after: number; global: boolean },
    request?: Request,
  ) => RateLimitError;
  return new RateLimitErrorCtor(response, body, fallbackRequest);
}

beforeAll(async () => {
  ({
    addRoleDiscord,
    banMemberDiscord,
    createThreadDiscord,
    DiscordThreadInitialMessageError,
    listGuildEmojisDiscord,
    listThreadsDiscord,
    reactMessageDiscord,
    removeRoleDiscord,
    sendMessageDiscord,
    sendPollDiscord,
    sendStickerDiscord,
    timeoutMemberDiscord,
    uploadEmojiDiscord,
    uploadStickerDiscord,
  } = await import("./send.js"));
  ({ discordOutbound } = await import("./outbound-adapter.js"));
});

beforeEach(() => {
  vi.clearAllMocks();
});

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

afterAll(() => {
  vi.doUnmock("openclaw/plugin-sdk/web-media");
});

describe("sendMessageDiscord", () => {
  it.each([
    {
      label: "a 2001-character reply",
      payload: { text: "a".repeat(2001) },
      expectedThreadMessages: 1,
    },
    {
      label: "a reply with two image attachments",
      payload: {
        text: "Generated images",
        mediaUrls: ["https://example.com/first.jpg", "https://example.com/second.jpg"],
      },
      expectedThreadMessages: 2,
    },
  ])("keeps $label in one automatically created forum thread", async (testCase) => {
    const { parentId, postMock, run } = createDiscordForumPayloadHarness();
    const onDeliveryResult = vi.fn();

    const result = await run(testCase.payload, { onDeliveryResult });

    const requestPaths = postMock.mock.calls.map((call) => call[0]);
    expect(requestPaths).toEqual([
      Routes.threads(parentId),
      ...Array.from({ length: testCase.expectedThreadMessages }, () =>
        Routes.channelMessages("701"),
      ),
    ]);
    expect(onDeliveryResult.mock.calls.map(([delivery]) => delivery.channelId)).toEqual(
      Array.from({ length: testCase.expectedThreadMessages + 1 }, () => "701"),
    );
    expect(result?.receipt).toMatchObject({
      threadId: "701",
      platformMessageIds: [
        "starter-1",
        ...Array.from(
          { length: testCase.expectedThreadMessages },
          (_, index) => `message-${index + 1}`,
        ),
      ],
    });
  });

  it("keeps chunked regular-channel replies on their original channel", async () => {
    const { parentId, postMock, run } = createDiscordForumPayloadHarness(ChannelType.GuildText);

    const result = await run({ text: "a".repeat(2001) });

    expect(postMock.mock.calls.map((call) => call[0])).toEqual([
      Routes.channelMessages(parentId),
      Routes.channelMessages(parentId),
    ]);
    expect(result?.receipt?.threadId).toBeUndefined();
    expect(result?.receipt?.platformMessageIds).toEqual(["message-2"]);
  });

  it("keeps chunked replies targeted at an explicitly selected thread", async () => {
    const { postMock, run } = createDiscordForumPayloadHarness();

    const result = await run({ text: "a".repeat(2001) }, { threadId: "701" });

    expect(postMock.mock.calls.map((call) => call[0])).toEqual([
      Routes.channelMessages("701"),
      Routes.channelMessages("701"),
    ]);
    expect(result?.receipt?.threadId).toBeUndefined();
    expect(result?.receipt?.platformMessageIds).toEqual(["message-2"]);
  });

  it("does not attempt a follow-up when forum thread creation is rejected", async () => {
    const { parentId, postMock, run } = createDiscordForumPayloadHarness();
    postMock.mockRejectedValueOnce(new Error("missing access"));

    await expect(run({ text: "a".repeat(2001) })).rejects.toThrow("missing access");

    expect(postMock).toHaveBeenCalledOnce();
    expect(postMock.mock.calls[0]?.[0]).toBe(Routes.threads(parentId));
  });

  it("does not send a forum follow-up when delivery bookkeeping rejects the starter", async () => {
    const { parentId, postMock, run } = createDiscordForumPayloadHarness();
    const onDeliveryResult = vi.fn().mockRejectedValue(new Error("delivery bookkeeping failed"));

    await expect(run({ text: "a".repeat(2001) }, { onDeliveryResult })).rejects.toThrow(
      "delivery bookkeeping failed",
    );

    expect(onDeliveryResult).toHaveBeenCalledOnce();
    expect(postMock.mock.calls.map((call) => call[0])).toEqual([Routes.threads(parentId)]);
  });

  it("creates a thread", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "thread", messageId: "m1" },
      discordClientOpts(rest),
    );
    expect(getMock).not.toHaveBeenCalled();
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.threads("chan1", "m1"));
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({ name: "thread" });
  });

  it("creates forum threads with an initial message", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({ type: ChannelType.GuildForum });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest));
    expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1"));
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.threads("chan1"));
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({
      name: "thread",
      message: { content: "thread" },
    });
  });

  it("inherits default_auto_archive_duration for forum threads", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({
      type: ChannelType.GuildForum,
      default_auto_archive_duration: 1440,
    });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest));
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({
      name: "thread",
      auto_archive_duration: 1440,
      message: { content: "thread" },
    });
  });

  it("inherits default_auto_archive_duration for text-channel threads", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({
      type: ChannelType.GuildText,
      default_auto_archive_duration: 10080,
    });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest));
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({
      name: "thread",
      auto_archive_duration: 10080,
      type: ChannelType.PublicThread,
    });
  });

  it("prefers explicit autoArchiveMinutes over channel default", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({
      type: ChannelType.GuildForum,
      default_auto_archive_duration: 1440,
    });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "thread", autoArchiveMinutes: 4320 },
      discordClientOpts(rest),
    );
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({
      name: "thread",
      auto_archive_duration: 4320,
      message: { content: "thread" },
    });
  });

  it("preserves explicit autoArchiveMinutes for message-attached threads", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "thread", messageId: "m1", autoArchiveMinutes: 4320 },
      discordClientOpts(rest),
    );
    expect(getMock).not.toHaveBeenCalled();
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({
      name: "thread",
      auto_archive_duration: 4320,
    });
  });

  it("creates media threads with provided content", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({ type: ChannelType.GuildMedia });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "thread", content: "initial forum post" },
      discordClientOpts(rest),
    );
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.threads("chan1"));
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({
      name: "thread",
      message: { content: "initial forum post" },
    });
  });

  it("passes applied_tags for forum threads", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({ type: ChannelType.GuildForum });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "tagged post", appliedTags: ["tag1", "tag2"] },
      discordClientOpts(rest),
    );
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.threads("chan1"));
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({
      name: "tagged post",
      message: { content: "tagged post" },
      applied_tags: ["tag1", "tag2"],
    });
  });

  it("omits applied_tags for non-forum threads", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "thread", appliedTags: ["tag1"] },
      discordClientOpts(rest),
    );
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.threads("chan1"));
    expect("applied_tags" in requestBody(postMock as unknown as MockCallSource)).toBe(false);
  });

  it("falls back when channel lookup is unavailable", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockRejectedValue(new Error("lookup failed"));
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord("chan1", { name: "thread" }, discordClientOpts(rest));
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.threads("chan1"));
    expect(requestBody(postMock as unknown as MockCallSource).name).toBe("thread");
    expect(requestBody(postMock as unknown as MockCallSource).type).toBe(ChannelType.PublicThread);
  });

  it("respects explicit thread type for standalone threads", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "thread", type: ChannelType.PrivateThread },
      discordClientOpts(rest),
    );
    expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1"));
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.threads("chan1"));
    expect(requestBody(postMock as unknown as MockCallSource).name).toBe("thread");
    expect(requestBody(postMock as unknown as MockCallSource).type).toBe(ChannelType.PrivateThread);
  });

  it("sends initial message for non-forum threads with content", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "thread", content: "Hello thread!" },
      discordClientOpts(rest),
    );
    expect(postMock).toHaveBeenCalledTimes(2);
    // First call: create thread
    expect(requestPath(postMock as unknown as MockCallSource, 0)).toBe(Routes.threads("chan1"));
    expect(requestBody(postMock as unknown as MockCallSource, 0).name).toBe("thread");
    expect(requestBody(postMock as unknown as MockCallSource, 0).type).toBe(
      ChannelType.PublicThread,
    );
    // Second call: send message to thread
    expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe(
      Routes.channelMessages("t1"),
    );
    expect(requestBody(postMock as unknown as MockCallSource, 1)).toEqual({
      content: "Hello thread!",
    });
  });

  it("keeps created non-forum thread details when initial message send fails", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    getMock.mockResolvedValue({ type: ChannelType.GuildText });
    postMock
      .mockResolvedValueOnce({ id: "t1", name: "thread", type: ChannelType.PublicThread })
      .mockRejectedValueOnce(new Error("missing access"));

    let thrown: unknown;
    try {
      await createThreadDiscord(
        "chan1",
        { name: "thread", content: "Hello thread!" },
        discordClientOpts(rest),
      );
    } catch (error) {
      thrown = error;
    }

    expect(thrown).toBeInstanceOf(DiscordThreadInitialMessageError);
    const error = requireRecord(thrown, "thread initial message error");
    expect(error.name).toBe("DiscordThreadInitialMessageError");
    expect(error.initialMessageError).toBe("missing access");
    expect(error.thread).toEqual({ id: "t1", name: "thread", type: ChannelType.PublicThread });
  });

  it("sends initial message for message-attached threads with content", async () => {
    const { rest, getMock, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "t1" });
    await createThreadDiscord(
      "chan1",
      { name: "thread", messageId: "m1", content: "Discussion here" },
      discordClientOpts(rest),
    );
    // Should not detect channel type for message-attached threads
    expect(getMock).not.toHaveBeenCalled();
    expect(postMock).toHaveBeenCalledTimes(2);
    // First call: create thread from message
    expect(requestPath(postMock as unknown as MockCallSource, 0)).toBe(
      Routes.threads("chan1", "m1"),
    );
    expect(requestBody(postMock as unknown as MockCallSource, 0)).toEqual({ name: "thread" });
    // Second call: send message to thread
    expect(requestPath(postMock as unknown as MockCallSource, 1)).toBe(
      Routes.channelMessages("t1"),
    );
    expect(requestBody(postMock as unknown as MockCallSource, 1)).toEqual({
      content: "Discussion here",
    });
  });

  it("lists active threads by guild", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock.mockResolvedValue({ threads: [] });
    await listThreadsDiscord({ guildId: "g1" }, discordClientOpts(rest));
    expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
  });

  it("times out a member", async () => {
    const { rest, patchMock } = makeDiscordRest();
    patchMock.mockResolvedValue({ id: "m1" });
    await timeoutMemberDiscord(
      { guildId: "g1", userId: "u1", durationMinutes: 10 },
      discordClientOpts(rest),
    );
    expect(requestPath(patchMock as unknown as MockCallSource)).toBe(
      Routes.guildMember("g1", "u1"),
    );
    expect(
      requestBody(patchMock as unknown as MockCallSource).communication_disabled_until,
    ).toBeTypeOf("string");
  });

  it("rejects timeout durations outside Date range", async () => {
    const { rest, patchMock } = makeDiscordRest();

    await expect(
      timeoutMemberDiscord(
        { guildId: "g1", userId: "u1", durationMinutes: 8_640_000_000_000_001 },
        discordClientOpts(rest),
      ),
    ).rejects.toThrow("Discord timeout duration is outside the supported Date range");
    expect(patchMock).not.toHaveBeenCalled();
  });

  it("rejects timeout durations that overflow from the current clock", async () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date(8_640_000_000_000_000));
    const { rest, patchMock } = makeDiscordRest();

    await expect(
      timeoutMemberDiscord(
        { guildId: "g1", userId: "u1", durationMinutes: 1 },
        discordClientOpts(rest),
      ),
    ).rejects.toThrow("Discord timeout duration is outside the supported Date range");
    expect(patchMock).not.toHaveBeenCalled();
  });

  it("adds and removes roles", async () => {
    const { rest, putMock, deleteMock } = makeDiscordRest();
    putMock.mockResolvedValue({});
    deleteMock.mockResolvedValue({});
    await addRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, discordClientOpts(rest));
    await removeRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, discordClientOpts(rest));
    expect(putMock).toHaveBeenCalledWith(Routes.guildMemberRole("g1", "u1", "r1"));
    expect(deleteMock).toHaveBeenCalledWith(Routes.guildMemberRole("g1", "u1", "r1"));
  });

  it("bans a member", async () => {
    const { rest, putMock } = makeDiscordRest();
    putMock.mockResolvedValue({});
    await banMemberDiscord(
      { guildId: "g1", userId: "u1", deleteMessageDays: 2 },
      discordClientOpts(rest),
    );
    expect(requestPath(putMock as unknown as MockCallSource)).toBe(Routes.guildBan("g1", "u1"));
    expect(requestBody(putMock as unknown as MockCallSource)).toEqual({ delete_message_days: 2 });
  });
});

describe("listGuildEmojisDiscord", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("lists emojis for a guild", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
    await listGuildEmojisDiscord("g1", discordClientOpts(rest));
    expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
  });
});

describe("uploadEmojiDiscord", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("uploads emoji assets", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "e1" });
    await uploadEmojiDiscord(
      {
        guildId: "g1",
        name: "party_blob",
        mediaUrl: "file:///tmp/party.png",
        roleIds: ["r1"],
      },
      discordClientOpts(rest),
    );
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.guildEmojis("g1"));
    expect(requestBody(postMock as unknown as MockCallSource)).toEqual({
      name: "party_blob",
      image: "data:image/png;base64,aW1n",
      roles: ["r1"],
    });
    expect(loadWebMediaRaw).toHaveBeenCalledWith("file:///tmp/party.png", 256 * 1024);
  });
});

describe("uploadStickerDiscord", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("uploads sticker assets", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "s1" });
    await uploadStickerDiscord(
      {
        guildId: "g1",
        name: "openclaw_wave",
        description: "OpenClaw waving",
        tags: "👋",
        mediaUrl: "file:///tmp/wave.png",
      },
      discordClientOpts(rest),
    );
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.guildStickers("g1"));
    const stickerBody = requestBody(postMock as unknown as MockCallSource);
    expect(stickerBody.name).toBe("openclaw_wave");
    expect(stickerBody.description).toBe("OpenClaw waving");
    expect(stickerBody.tags).toBe("👋");
    const files = stickerBody.files as Array<{ name?: string; contentType?: string }>;
    expect(files).toHaveLength(1);
    expect(files[0]?.name).toBe("asset.png");
    expect(files[0]?.contentType).toBe("image/png");
    expect(loadWebMediaRaw).toHaveBeenCalledWith("file:///tmp/wave.png", 512 * 1024);
  });
});

describe("sendStickerDiscord", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("sends sticker payloads", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
    const res = await sendStickerDiscord("channel:789", ["123"], {
      cfg: DISCORD_TEST_CFG,
      rest,
      token: "t",
      content: "hiya",
    });
    expect(res.messageId).toBe("msg1");
    expect(res.channelId).toBe("789");
    expect(res.receipt.parts[0]?.platformMessageId).toBe("msg1");
    expect(res.receipt.parts[0]?.kind).toBe("card");
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.channelMessages("789"));
    expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({
      content: "hiya",
      flags: MessageFlags.SuppressEmbeds,
      sticker_ids: ["123"],
      enforce_nonce: true,
    });
    expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/);
  });

  it("allows sticker content link embeds when disabled", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
    await sendStickerDiscord("channel:789", ["123"], {
      cfg: DISCORD_TEST_CFG,
      rest,
      token: "t",
      content: "https://example.com",
      suppressEmbeds: false,
    });

    expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({
      content: "https://example.com",
      sticker_ids: ["123"],
      enforce_nonce: true,
    });
    expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/);
  });

  it("reuses a single nonce across a retried 502 for stickers", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock
      .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 }))
      .mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
    await sendStickerDiscord("channel:789", ["123"], {
      cfg: DISCORD_TEST_CFG,
      rest,
      token: "t",
      content: "hiya",
      retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
    });
    expect(postMock).toHaveBeenCalledTimes(2);
    const firstNonce = requestBody(postMock as unknown as MockCallSource, 0).nonce;
    const secondNonce = requestBody(postMock as unknown as MockCallSource, 1).nonce;
    expect(firstNonce).toMatch(/^[0-9a-f]{24}$/);
    expect(secondNonce).toBe(firstNonce);
  });
});

describe("sendPollDiscord", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("sends polls with answers", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
    const res = await sendPollDiscord(
      "channel:789",
      {
        question: "Lunch?",
        options: ["Pizza", "Sushi"],
      },
      {
        cfg: DISCORD_TEST_CFG,
        rest,
        token: "t",
      },
    );
    expect(res.messageId).toBe("msg1");
    expect(res.channelId).toBe("789");
    expect(res.receipt.parts[0]?.platformMessageId).toBe("msg1");
    expect(res.receipt.parts[0]?.kind).toBe("card");
    expect(requestPath(postMock as unknown as MockCallSource)).toBe(Routes.channelMessages("789"));
    expect(requestBody(postMock as unknown as MockCallSource).flags).toBe(
      MessageFlags.SuppressEmbeds,
    );
    expect(requestBody(postMock as unknown as MockCallSource).poll).toEqual({
      question: { text: "Lunch?" },
      answers: [{ poll_media: { text: "Pizza" } }, { poll_media: { text: "Sushi" } }],
      duration: 24,
      allow_multiselect: false,
      layout_type: 1,
    });
    expect(requestBody(postMock as unknown as MockCallSource)).toMatchObject({
      enforce_nonce: true,
    });
    expect(requestBody(postMock as unknown as MockCallSource).nonce).toMatch(/^[0-9a-f]{24}$/);
  });

  it("reuses a single nonce across a retried 502 for polls", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock
      .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 }))
      .mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
    await sendPollDiscord(
      "channel:789",
      {
        question: "Lunch?",
        options: ["Pizza", "Sushi"],
      },
      {
        cfg: DISCORD_TEST_CFG,
        rest,
        token: "t",
        retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
      },
    );
    expect(postMock).toHaveBeenCalledTimes(2);
    const firstNonce = requestBody(postMock as unknown as MockCallSource, 0).nonce;
    const secondNonce = requestBody(postMock as unknown as MockCallSource, 1).nonce;
    expect(firstNonce).toMatch(/^[0-9a-f]{24}$/);
    expect(secondNonce).toBe(firstNonce);
  });

  it("combines silent and suppress-embeds flags for polls", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
    await sendPollDiscord(
      "channel:789",
      {
        question: "Lunch?",
        options: ["Pizza", "Sushi"],
      },
      {
        cfg: DISCORD_TEST_CFG,
        rest,
        token: "t",
        content: "https://example.com",
        silent: true,
      },
    );

    expect(requestBody(postMock as unknown as MockCallSource).flags).toBe(
      MessageFlags.SuppressEmbeds | MessageFlags.SuppressNotifications,
    );
  });
});

function createMockRateLimitError(retryAfter = 0.001): RateLimitError {
  const request = new Request("https://discord.com/api/v10/channels/789/messages", {
    method: "POST",
  });
  const response = new Response(null, {
    status: 429,
    headers: {
      "X-RateLimit-Scope": "user",
      "X-RateLimit-Bucket": "test-bucket",
    },
  });
  return createRateLimitError(
    response,
    {
      message: "You are being rate limited.",
      retry_after: retryAfter,
      global: false,
    },
    request,
  );
}

describe("retry rate limits", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("retries on Discord rate limits", async () => {
    const { rest, postMock } = makeDiscordRest();
    const rateLimitError = createMockRateLimitError(0);

    postMock
      .mockRejectedValueOnce(rateLimitError)
      .mockResolvedValueOnce({ id: "msg1", channel_id: "789" });

    const res = await sendMessageDiscord("channel:789", "hello", {
      cfg: DISCORD_TEST_CFG,
      rest,
      token: "t",
      retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
    });

    expect(res.messageId).toBe("msg1");
    expect(postMock).toHaveBeenCalledTimes(2);
  });

  it("uses retry_after delays when rate limited", async () => {
    const setTimeoutSpy = vi.spyOn(global, "setTimeout");
    try {
      const { rest, postMock } = makeDiscordRest();
      const rateLimitError = createMockRateLimitError(0.001);

      postMock
        .mockRejectedValueOnce(rateLimitError)
        .mockResolvedValueOnce({ id: "msg1", channel_id: "789" });

      const promise = sendMessageDiscord("channel:789", "hello", {
        cfg: DISCORD_TEST_CFG,
        rest,
        token: "t",
        retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
      });

      const result = await promise;
      expect(result.messageId).toBe("msg1");
      expect(result.channelId).toBe("789");
      expect(result.receipt.primaryPlatformMessageId).toBe("msg1");
      expect(result.receipt.platformMessageIds).toEqual(["msg1"]);
      expect(timerDelayAt(setTimeoutSpy as unknown as MockCallSource)).toBe(1);
    } finally {
      setTimeoutSpy.mockRestore();
    }
  });

  it("stops after max retry attempts", async () => {
    const { rest, postMock } = makeDiscordRest();
    const rateLimitError = createMockRateLimitError(0);

    postMock.mockRejectedValue(rateLimitError);

    await expect(
      sendMessageDiscord("channel:789", "hello", {
        cfg: DISCORD_TEST_CFG,
        rest,
        token: "t",
        retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
      }),
    ).rejects.toBeInstanceOf(RateLimitError);
    expect(postMock).toHaveBeenCalledTimes(2);
  });

  it("does not retry permanent non-rate-limit errors", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockRejectedValueOnce(new Error("invalid request"));

    await expect(
      sendMessageDiscord("channel:789", "hello", discordClientOpts(rest)),
    ).rejects.toThrow("invalid request");
    expect(postMock).toHaveBeenCalledTimes(1);
  });

  it("retries ambiguous network errors with one stable enforced nonce", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock
      .mockRejectedValueOnce(new TypeError("fetch failed"))
      .mockResolvedValueOnce({ id: "msg1", channel_id: "789" });

    const result = await sendMessageDiscord("channel:789", "hello", {
      cfg: DISCORD_TEST_CFG,
      rest,
      token: "t",
      retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
    });

    expect(result.messageId).toBe("msg1");
    expect(postMock).toHaveBeenCalledTimes(2);
    const firstBody = requestBody(postMock as unknown as MockCallSource, 0);
    const secondBody = requestBody(postMock as unknown as MockCallSource, 1);
    expect(firstBody.enforce_nonce).toBe(true);
    expect(secondBody.nonce).toBe(firstBody.nonce);
  });

  it("retries reactions on rate limits", async () => {
    const { rest, putMock } = makeDiscordRest();
    const rateLimitError = createMockRateLimitError(0);

    putMock.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(undefined);

    const res = await reactMessageDiscord("chan1", "msg1", "ok", {
      cfg: DISCORD_TEST_CFG,
      rest,
      token: "t",
      retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
    });

    expect(res.ok).toBe(true);
    expect(putMock).toHaveBeenCalledTimes(2);
  });

  it("retries media upload without duplicating overflow text", async () => {
    const { rest, postMock } = makeDiscordRest();
    const rateLimitError = createMockRateLimitError(0);
    const text = "a".repeat(2005);

    postMock
      .mockRejectedValueOnce(rateLimitError)
      .mockResolvedValueOnce({ id: "msg1", channel_id: "789" })
      .mockResolvedValueOnce({ id: "msg2", channel_id: "789" });

    const res = await sendMessageDiscord("channel:789", text, {
      cfg: DISCORD_TEST_CFG,
      rest,
      token: "t",
      mediaUrl: "https://example.com/photo.jpg",
      retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
    });

    expect(res.messageId).toBe("msg1");
    expect(postMock).toHaveBeenCalledTimes(3);
  });
});
