import { ChannelType, MessageFlags, PermissionFlagsBits, Routes } from "discord-api-types/v10";
// Discord tests cover send.sends basic channel messages plugin behavior.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { Container, TextDisplay } from "./internal/discord.js";
import {
  createDiscordLoopbackRest,
  discordWebMediaMockFactory,
  makeDiscordRest,
} from "./send.test-harness.js";

vi.mock("openclaw/plugin-sdk/web-media", () => discordWebMediaMockFactory());

let deleteMessageDiscord: typeof import("./send.js").deleteMessageDiscord;
let editMessageDiscord: typeof import("./send.js").editMessageDiscord;
let canViewDiscordGuildChannel: typeof import("./send.js").canViewDiscordGuildChannel;
let fetchChannelPermissionsDiscord: typeof import("./send.js").fetchChannelPermissionsDiscord;
let fetchReactionsDiscord: typeof import("./send.js").fetchReactionsDiscord;
let pinMessageDiscord: typeof import("./send.js").pinMessageDiscord;
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
let readMessagesDiscord: typeof import("./send.js").readMessagesDiscord;
let removeOwnReactionsDiscord: typeof import("./send.js").removeOwnReactionsDiscord;
let removeReactionDiscord: typeof import("./send.js").removeReactionDiscord;
let searchMessagesDiscord: typeof import("./send.js").searchMessagesDiscord;
let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord;
let unpinMessageDiscord: typeof import("./send.js").unpinMessageDiscord;
let resolveDiscordTargetChannelId: typeof import("./send.shared.js").resolveDiscordTargetChannelId;
let loadWebMedia: typeof import("openclaw/plugin-sdk/web-media").loadWebMedia;
let clearDiscordDirectoryCacheForTest: typeof import("./directory-cache.test-support.js").clearDiscordDirectoryCacheForTest;
let rememberDiscordDirectoryUser: typeof import("./directory-cache.js").rememberDiscordDirectoryUser;

const DISCORD_TEST_CFG = {
  channels: { discord: { token: "t" } },
};

const DISCORD_MARKDOWN_GOLDENS = [
  {
    name: "normalizes CommonMark underscore bold without changing other Discord markdown",
    before: "__bold__ *italic* ~~strike~~ `code`",
    after: "**bold** *italic* ~~strike~~ `code`",
  },
  {
    name: "normalizes nested CommonMark emphasis and strong spans",
    before:
      "__*nested italic*__ __foo*bar*baz__ __a*x*.__ __foo**bar**baz__ __outer __inner__ tail__",
    after:
      "**_nested italic_** **foo*bar*baz** **a*x*.** **foo****bar****baz** **outer **inner** tail**",
  },
  {
    name: "normalizes CommonMark bold containing links without changing destinations",
    before:
      "__See https://example.com and [__docs__](https://example.com)__ __See https://example.com__. __*see https://example.com*__ __<mailto:user*tag@example.com>__",
    after:
      "**See https://example.com and [**docs**](https://example.com)** **See https://example.com**. **_see https://example.com_** **<mailto:user*tag@example.com>**",
  },
  {
    name: "normalizes CommonMark bold around URLs with parentheses and asterisks",
    before:
      "__https://example.com/a(b)*c__ __<https://example.com/a(b)*c>__ ____https://example.com____ https://[2001:db8::1]/__v1__ ftp://example.com/__v2__ WWW.example.com/__v3__",
    after:
      "__https://example.com/a(b)*c__ **<https://example.com/a(b)*c>** ****https://example.com**** https://[2001:db8::1]/__v1__ ftp://example.com/__v2__ WWW.example.com/__v3__",
  },
  {
    name: "keeps escaped and intraword underscores literal",
    before: "\\__literal__ foo__bar__baz awww.__bold__ \\\\__bold__",
    after: "\\__literal__ foo__bar__baz awww.**bold** \\\\**bold**",
  },
  {
    name: "keeps underscore markers inside code byte-identical",
    before:
      "`__inline__` ``tick ` __literal__`` `a` __bold__ `b` `__` __outside__\n\n````md\nline\n```\n__fenced__\n````",
    after:
      "`__inline__` ``tick ` __literal__`` `a` **bold** `b` `__` **outside**\n\n````md\nline\n```\n__fenced__\n````",
  },
  {
    name: "keeps indentation and special link destinations byte-identical",
    before:
      '    a\n    b\n\n[x](<https://example.test/__v1__/a)>)\n<https://example.test/__v1__/>\nhttps://example.test/__v1__/bare\n<:__wave__:123456789012345678> <a:__dance__:123456789012345679> </__foo__:123456789012345680>\n\n[r]: https://example.test/__v1__/unused\n  "__title__"',
    after:
      '    a\n    b\n\n[x](<https://example.test/__v1__/a)>)\n<https://example.test/__v1__/>\nhttps://example.test/__v1__/bare\n<:__wave__:123456789012345678> <a:__dance__:123456789012345679> </__foo__:123456789012345680>\n\n[r]: https://example.test/__v1__/unused\n  "__title__"',
  },
  {
    name: "keeps compact reference links byte-identical before channel chunking",
    before: "[x][r] [x][r]\n\n[r]: https://example.test/a/very/long/reference",
    after: "[x][r] [x][r]\n\n[r]: https://example.test/a/very/long/reference",
  },
  {
    name: "escapes literal asterisks when normalizing underscore bold",
    before: "__safe__ and __a * b__ and __foo **bar__",
    after: "**safe** and **a \\* b** and **foo \\*\\*bar**",
  },
];

beforeAll(async () => {
  ({
    deleteMessageDiscord,
    editMessageDiscord,
    canViewDiscordGuildChannel,
    fetchChannelPermissionsDiscord,
    fetchReactionsDiscord,
    pinMessageDiscord,
    reactMessageDiscord,
    readMessagesDiscord,
    removeOwnReactionsDiscord,
    removeReactionDiscord,
    searchMessagesDiscord,
    sendMessageDiscord,
    unpinMessageDiscord,
  } = await import("./send.js"));
  ({ resolveDiscordTargetChannelId } = await import("./send.shared.js"));
  ({ loadWebMedia } = await import("openclaw/plugin-sdk/web-media"));
  ({ rememberDiscordDirectoryUser } = await import("./directory-cache.js"));
  ({ clearDiscordDirectoryCacheForTest } = await import("./directory-cache.test-support.js"));
});

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

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

function requireArray(value: unknown, label: string): unknown[] {
  if (!Array.isArray(value)) {
    throw new Error(`expected ${label} to be an array`);
  }
  return value;
}

function expectRecordFields(value: unknown, label: string, expected: Record<string, unknown>) {
  const record = requireRecord(value, label);
  for (const [key, expectedValue] of Object.entries(expected)) {
    expect(record[key]).toEqual(expectedValue);
  }
}

function requireMockCall(mock: ReturnType<typeof vi.fn>, label: string, callIndex = 0): unknown[] {
  const call = mock.mock.calls[callIndex];
  if (!call) {
    throw new Error(`expected ${label} call ${callIndex + 1}`);
  }
  return call;
}

function requireMockArg(
  mock: ReturnType<typeof vi.fn>,
  label: string,
  callIndex: number,
  argIndex: number,
): unknown {
  return requireMockCall(mock, label, callIndex)[argIndex];
}

function expectRestRoute(mock: ReturnType<typeof vi.fn>, callIndex: number, expected: string) {
  expect(requireMockArg(mock, "Discord REST", callIndex, 0)).toBe(expected);
}

function requireRestOptions(mock: ReturnType<typeof vi.fn>, callIndex: number) {
  return requireRecord(requireMockArg(mock, "Discord REST", callIndex, 1), "Discord REST options");
}

function requireRestBody(mock: ReturnType<typeof vi.fn>, callIndex = 0) {
  return requireRecord(requireRestOptions(mock, callIndex).body, "Discord REST body");
}

function expectSingleReceiptPart(receipt: unknown, expected: Record<string, unknown>) {
  const receiptRecord = requireRecord(receipt, "send receipt");
  const parts = requireArray(receiptRecord.parts, "send receipt parts");
  expect(parts).toHaveLength(1);
  expectRecordFields(parts[0], "send receipt part", expected);
}

function expectBodyFileName(body: unknown, expectedName: string) {
  const files = requireArray(requireRecord(body, "Discord REST body").files, "Discord files");
  expect(files).toHaveLength(1);
  expectRecordFields(files[0], "Discord file", { name: expectedName });
}

describe("resolveDiscordTargetChannelId", () => {
  it("creates a DM channel for user targets", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValueOnce({ id: "dm-1" });

    await expect(
      resolveDiscordTargetChannelId("user:U1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
      }),
    ).resolves.toEqual({ channelId: "dm-1", dm: true });

    expect(postMock).toHaveBeenCalledWith(Routes.userChannels(), {
      body: { recipient_id: "U1" },
    });
  });

  it("keeps channel targets on the channel path", async () => {
    const { rest, postMock } = makeDiscordRest();

    await expect(
      resolveDiscordTargetChannelId("channel:C1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
      }),
    ).resolves.toEqual({ channelId: "C1" });

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

describe("sendMessageDiscord", () => {
  function expectReplyReference(
    body: { message_reference?: unknown } | undefined,
    messageId: string,
  ) {
    expect(body?.message_reference).toEqual({
      message_id: messageId,
      fail_if_not_exists: false,
    });
  }

  function expectNoReplyReference(body: { message_reference?: unknown } | undefined) {
    expect(body?.message_reference).toBeUndefined();
  }

  async function sendChunkedReplyAndCollectBodies(params: {
    text: string;
    mediaUrl?: string;
    replyScope?: "all" | "first";
  }) {
    const { rest, postMock } = makeDiscordRest();
    postMock
      .mockResolvedValueOnce({ id: "msg1", channel_id: "789" })
      .mockResolvedValueOnce({ id: "msg2", channel_id: "789" });
    const result = await sendMessageDiscord("channel:789", params.text, {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      reply: { messageId: "orig-123", scope: params.replyScope ?? "all" },
      ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
    });
    expect(postMock).toHaveBeenCalledTimes(2);
    return {
      firstBody: requireRestBody(postMock, 0) as { message_reference?: unknown },
      secondBody: requireRestBody(postMock, 1) as { message_reference?: unknown },
      result,
    };
  }

  function setupForumSend(secondResponse: { id: string; channel_id: string }) {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum });
    postMock
      .mockResolvedValueOnce({
        id: "thread1",
        message: { id: "starter1", channel_id: "thread1" },
      })
      .mockResolvedValueOnce(secondResponse);
    return { rest, postMock };
  }

  it("sends basic channel messages", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    // Channel type lookup returns a normal text channel (not a forum).
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({
      id: "msg1",
      channel_id: "789",
    });
    const res = await sendMessageDiscord("channel:789", "hello world", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });
    expect(res.messageId).toBe("msg1");
    expect(res.channelId).toBe("789");
    expectRecordFields(res.receipt, "send receipt", {
      primaryPlatformMessageId: "msg1",
      platformMessageIds: ["msg1"],
    });
    expectSingleReceiptPart(res.receipt, { platformMessageId: "msg1", kind: "text" });
    expectRestRoute(postMock, 0, Routes.channelMessages("789"));
    expect(requireRestBody(postMock).content).toBe("hello world");
    expect(requireRestBody(postMock).flags).toBe(MessageFlags.SuppressEmbeds);
  });

  it.each(DISCORD_MARKDOWN_GOLDENS)("$name", async ({ before, after }) => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });

    await sendMessageDiscord("channel:789", before, {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });

    expect(requireRestBody(postMock).content).toBe(after);
  });

  it("reports the first Discord chunk before a later chunk fails", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock
      .mockResolvedValueOnce({ id: "msg1", channel_id: "789" })
      .mockRejectedValueOnce(new Error("second chunk failed"));
    const onDeliveryResult = vi.fn();

    await expect(
      sendMessageDiscord("channel:789", "a".repeat(2500), {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
        onDeliveryResult,
      }),
    ).rejects.toThrow("second chunk failed");

    expect(onDeliveryResult.mock.calls.map((call) => call[0]?.messageId)).toEqual(["msg1"]);
  });

  it("allows Discord link embeds when suppressEmbeds is disabled", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });

    await sendMessageDiscord("channel:789", "https://example.com", {
      rest,
      token: "t",
      cfg: {
        channels: {
          discord: {
            token: "t",
            suppressEmbeds: false,
          },
        },
      } as never,
    });

    const body = requireRestBody(postMock);
    expect(body).toMatchObject({
      content: "https://example.com",
      enforce_nonce: true,
    });
    expect(body.nonce).toMatch(/^[0-9a-f]{24}$/);
    expect(body.flags).toBeUndefined();
  });

  it("uses account-level suppressEmbeds overrides", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });

    await sendMessageDiscord("channel:789", "https://example.com", {
      rest,
      token: "t",
      accountId: "alerts",
      cfg: {
        channels: {
          discord: {
            token: "t",
            suppressEmbeds: false,
            accounts: {
              alerts: {
                suppressEmbeds: true,
              },
            },
          },
        },
      } as never,
    });

    expect(requireRestBody(postMock).flags).toBe(MessageFlags.SuppressEmbeds);
  });

  it("combines suppress embeds with silent notifications", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });

    await sendMessageDiscord("channel:789", "https://example.com", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      silent: true,
    });

    expect(requireRestBody(postMock).flags).toBe(
      MessageFlags.SuppressEmbeds | MessageFlags.SuppressNotifications,
    );
  });

  it("applies explicit allowed mentions to fresh messages", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });

    await sendMessageDiscord("channel:789", "heads up @everyone <@123>", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      allowedMentions: { parse: [] },
    });

    expect(requireRestBody(postMock).allowed_mentions).toEqual({ parse: [] });
  });

  it("does not suppress explicit Discord embeds by default", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });

    await sendMessageDiscord("channel:789", "card", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      embeds: [{ title: "Release notes", url: "https://example.com" }],
    });

    const body = requireRestBody(postMock);
    expect(body.content).toBe("card");
    expect(body.embeds).toHaveLength(1);
    expect(body).not.toHaveProperty("flags");
  });

  it("rewrites cached @username mentions to id-based mentions", async () => {
    rememberDiscordDirectoryUser({
      accountId: "default",
      userId: "123456789012345678",
      handles: ["Alice"],
    });
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({
      id: "msg1",
      channel_id: "789",
    });
    await sendMessageDiscord("channel:789", "ping @Alice", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      accountId: "default",
    });
    expectRestRoute(postMock, 0, Routes.channelMessages("789"));
    expect(requireRestBody(postMock).content).toBe("ping <@123456789012345678>");
  });

  it("rewrites configured @username aliases to id-based mentions", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({
      id: "msg1",
      channel_id: "789",
    });
    await sendMessageDiscord("channel:789", "ping @OpsLead", {
      rest,
      token: "t",
      cfg: {
        channels: {
          discord: {
            token: "t",
            mentionAliases: {
              opslead: "123456789012345678",
            },
          },
        },
      } as never,
      accountId: "default",
    });
    expectRestRoute(postMock, 0, Routes.channelMessages("789"));
    expect(requireRestBody(postMock).content).toBe("ping <@123456789012345678>");
  });

  it("uses configured defaultAccount for cached mention rewriting when accountId is omitted", async () => {
    rememberDiscordDirectoryUser({
      accountId: "work",
      userId: "222333444555666777",
      handles: ["Alice"],
    });
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValue({
      id: "msg1",
      channel_id: "789",
    });
    await sendMessageDiscord("channel:789", "ping @Alice", {
      rest,
      token: "t",
      cfg: {
        channels: {
          discord: {
            defaultAccount: "work",
            accounts: {
              work: {
                token: "Bot work-token", // pragma: allowlist secret
              },
            },
          },
        },
      } as never,
    });
    expectRestRoute(postMock, 0, Routes.channelMessages("789"));
    expect(requireRestBody(postMock).content).toBe("ping <@222333444555666777>");
  });

  it("auto-creates a forum thread when target is a Forum channel", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    // Channel type lookup returns a Forum channel.
    getMock.mockResolvedValueOnce({
      type: ChannelType.GuildForum,
      default_auto_archive_duration: 1440,
    });
    postMock.mockResolvedValue({
      id: "thread1",
      message: { id: "starter1", channel_id: "thread1" },
    });
    const res = await sendMessageDiscord("channel:forum1", "Discussion topic\nBody of the post", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });
    expect(res.messageId).toBe("starter1");
    expect(res.channelId).toBe("thread1");
    expectRecordFields(res.receipt, "send receipt", {
      threadId: "thread1",
      platformMessageIds: ["starter1"],
    });
    expectSingleReceiptPart(res.receipt, { platformMessageId: "starter1", kind: "text" });
    // Should POST to threads route, not channelMessages.
    expectRestRoute(postMock, 0, Routes.threads("forum1"));
    expect(requireRestBody(postMock)).toEqual({
      name: "Discussion topic",
      auto_archive_duration: 1440,
      message: {
        content: "Discussion topic\nBody of the post",
        flags: MessageFlags.SuppressEmbeds,
      },
    });
  });

  it("explains how to create a forum thread when the parent requires an applied tag", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({
      type: ChannelType.GuildForum,
      flags: 1 << 4,
      available_tags: [{ id: "tag1", name: "Question", moderated: false }],
    });

    await expect(
      sendMessageDiscord("channel:forum1", "Discussion topic", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
      }),
    ).rejects.toThrow(/thread-create with appliedTags/);
    expect(postMock).not.toHaveBeenCalled();
  });

  it("posts media as a follow-up message in forum channels", async () => {
    const { rest, postMock } = setupForumSend({ id: "media1", channel_id: "thread1" });
    const res = await sendMessageDiscord("channel:forum1", "Topic", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "file:///tmp/photo.jpg",
    });
    expect(res.messageId).toBe("starter1");
    expect(res.channelId).toBe("thread1");
    expectRecordFields(res.receipt, "send receipt", {
      threadId: "thread1",
      platformMessageIds: ["starter1", "media1"],
    });
    expect(
      res.receipt.parts.map(({ platformMessageId, kind, index }) => ({
        platformMessageId,
        kind,
        index,
      })),
    ).toEqual([
      { platformMessageId: "starter1", kind: "text", index: 0 },
      { platformMessageId: "media1", kind: "media", index: 1 },
    ]);
    expectRestRoute(postMock, 0, Routes.threads("forum1"));
    expect(requireRestBody(postMock, 0)).toEqual({
      name: "Topic",
      message: { content: "Topic", flags: MessageFlags.SuppressEmbeds },
    });
    expectRestRoute(postMock, 1, Routes.channelMessages("thread1"));
    expectBodyFileName(requireRestBody(postMock, 1), "photo.jpg");
  });

  it("chunks long forum posts into follow-up messages", async () => {
    const { rest, postMock } = setupForumSend({ id: "msg2", channel_id: "thread1" });
    const longText = "a".repeat(2001);
    const result = await sendMessageDiscord("channel:forum1", longText, {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });
    const firstBody = requireRestBody(postMock, 0) as {
      message?: { content?: string };
    };
    const secondBody = requireRestBody(postMock, 1) as { content?: string };
    expect(firstBody?.message?.content).toHaveLength(2000);
    expect(secondBody?.content).toBe("a");
    expect(result.receipt.platformMessageIds).toEqual(["starter1", "msg2"]);
    expect(result.receipt.parts.map(({ kind, index }) => ({ kind, index }))).toEqual([
      { kind: "text", index: 0 },
      { kind: "text", index: 1 },
    ]);
  });

  it("starts DM when recipient is a user", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock
      .mockResolvedValueOnce({ id: "chan1" })
      .mockResolvedValueOnce({ id: "msg1", channel_id: "chan1" });
    const res = await sendMessageDiscord("user:123", "hiya", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });
    expectRestRoute(postMock, 0, Routes.userChannels());
    expect(requireRestBody(postMock, 0).recipient_id).toBe("123");
    expectRestRoute(postMock, 1, Routes.channelMessages("chan1"));
    expect(requireRestBody(postMock, 1).content).toBe("hiya");
    expect(res.channelId).toBe("chan1");
  });

  it("treats bare numeric outbound IDs as channels", async () => {
    const { rest, postMock, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
    postMock.mockResolvedValueOnce({
      id: "msg1",
      channel_id: "273512430271856640",
    });

    const result = await sendMessageDiscord("273512430271856640", "hello", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });

    expect(result.channelId).toBe("273512430271856640");
    expectRestRoute(postMock, 0, Routes.channelMessages("273512430271856640"));
    expect(requireRestBody(postMock).content).toBe("hello");
  });

  it.each([
    {
      name: "adds missing permission hints on 50013",
      permissions: PermissionFlagsBits.ViewChannel,
      expectedErrors: [/missing permissions/i, /SendMessages/],
    },
    {
      name: "keeps 50013 context when permission probe finds baseline permissions",
      permissions: PermissionFlagsBits.ViewChannel | PermissionFlagsBits.SendMessages,
      expectedErrors: [
        /permission probe did not identify missing ViewChannel\/SendMessages/,
        /code=50013 status=403/,
      ],
    },
  ])("$name", async ({ permissions, expectedErrors }) => {
    const { rest, postMock, getMock } = makeDiscordRest();
    const apiError = Object.assign(new Error("Missing Permissions"), {
      code: 50013,
      status: 403,
    });
    postMock.mockRejectedValueOnce(apiError);
    getMock
      .mockResolvedValueOnce({ type: ChannelType.GuildText })
      .mockResolvedValueOnce({
        id: "789",
        guild_id: "guild1",
        type: 0,
        permission_overwrites: [],
      })
      .mockResolvedValueOnce({ id: "bot1" })
      .mockResolvedValueOnce({
        id: "guild1",
        roles: [{ id: "guild1", permissions: permissions.toString() }],
      })
      .mockResolvedValueOnce({ roles: [] });

    let error: unknown;
    try {
      await sendMessageDiscord("channel:789", "hello", { rest, token: "t", cfg: DISCORD_TEST_CFG });
    } catch (err) {
      error = err;
    }
    for (const expectedError of expectedErrors) {
      expect(String(error)).toMatch(expectedError);
    }
  });

  it("uploads media attachments", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
    const res = await sendMessageDiscord("channel:789", "photo", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "file:///tmp/photo.jpg",
    });
    expect(res.messageId).toBe("msg");
    expectRestRoute(postMock, 0, Routes.channelMessages("789"));
    expectBodyFileName(requireRestBody(postMock), "photo.jpg");
    expect(loadWebMedia).toHaveBeenCalledWith("file:///tmp/photo.jpg", {
      maxBytes: 100 * 1024 * 1024,
    });
  });

  it("sends the detected JPEG media type across a real loopback multipart request", async () => {
    const loopback = await createDiscordLoopbackRest();
    try {
      await sendMessageDiscord("channel:789", "photo", {
        rest: loopback.rest,
        token: "test-token",
        cfg: DISCORD_TEST_CFG,
        mediaUrl: "file:///tmp/photo.jpg",
      });

      const upload = loopback.requests.find((request) => request.method === "POST");
      expect(upload?.path).toContain("/channels/789/messages");
      expect(upload?.contentType).toMatch(/^multipart\/form-data; boundary=/);
      expect(upload?.body).toContain('name="files[0]"; filename="photo.jpg"');
      expect(upload?.body).toContain("Content-Type: image/jpeg");
    } finally {
      await loopback.close();
    }
  });

  it("preserves text when Discord rejects an upload with error 40005", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock
      .mockRejectedValueOnce(
        Object.assign(new Error("Bad Request"), {
          status: 400,
          rawError: { code: 40005 },
        }),
      )
      .mockResolvedValueOnce({ id: "fallback-msg", channel_id: "789" });

    const res = await sendMessageDiscord("channel:789", "Here is the report", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "file:///tmp/report.pdf",
      reply: { messageId: "orig-123", scope: "all" },
      components: [new Container([new TextDisplay("Attachment controls")])],
      embeds: [{ title: "Attachment preview" }],
    });

    expect(res.messageId).toBe("fallback-msg");
    expect(postMock).toHaveBeenCalledTimes(2);
    expectBodyFileName(requireRestBody(postMock, 0), "photo.jpg");
    const fallbackBody = requireRestBody(postMock, 1);
    expect(fallbackBody.content).toBe(
      "Here is the report\n\n[Attachment skipped: Discord rejected the file as too large.]",
    );
    expect(fallbackBody).not.toHaveProperty("files");
    expect(fallbackBody).not.toHaveProperty("components");
    expect(fallbackBody).not.toHaveProperty("embeds");
    expectReplyReference(fallbackBody, "orig-123");
  });

  it("preserves implicit reply scope and delivery progress in upload fallback chunks", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock
      .mockRejectedValueOnce(
        Object.assign(new Error("Bad Request"), {
          status: 400,
          rawError: { code: 40005 },
        }),
      )
      .mockResolvedValueOnce({ id: "fallback-1", channel_id: "789" })
      .mockResolvedValueOnce({ id: "fallback-2", channel_id: "789" });
    const onDeliveryResult = vi.fn();

    await sendMessageDiscord("channel:789", "a".repeat(2500), {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "file:///tmp/report.pdf",
      reply: { messageId: "orig-123", scope: "first" },
      onDeliveryResult,
    });

    expect(postMock).toHaveBeenCalledTimes(3);
    expectReplyReference(requireRestBody(postMock, 1), "orig-123");
    expectNoReplyReference(requireRestBody(postMock, 2));
    expect(onDeliveryResult.mock.calls.map((call) => call[0]?.messageId)).toEqual([
      "fallback-1",
      "fallback-2",
    ]);
    expect(onDeliveryResult.mock.calls.map((call) => call[0]?.receipt.parts[0]?.replyToId)).toEqual(
      ["orig-123", undefined],
    );
  });

  it("reports a media-only upload rejected with HTTP 413", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock
      .mockRejectedValueOnce(Object.assign(new Error("Bad Request"), { status: 413 }))
      .mockResolvedValueOnce({ id: "fallback-msg", channel_id: "789" });

    const res = await sendMessageDiscord("channel:789", "", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "file:///tmp/photo.jpg",
    });

    expect(res.messageId).toBe("fallback-msg");
    expect(requireRestBody(postMock, 1).content).toBe(
      "Attachment skipped: Discord rejected the file as too large.",
    );
    expect(requireRestBody(postMock, 1)).not.toHaveProperty("files");
  });

  it("does not mask unrelated media upload failures", async () => {
    const { rest, postMock } = makeDiscordRest();
    const error = Object.assign(new Error("Internal Server Error"), { status: 500 });
    postMock.mockRejectedValue(error);

    await expect(
      sendMessageDiscord("channel:789", "report", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
        mediaUrl: "file:///tmp/report.pdf",
        retry: { attempts: 1 },
      }),
    ).rejects.toBe(error);
    expect(postMock).toHaveBeenCalledTimes(1);
  });

  it("passes mediaAccess workspaceDir when loading relative media attachments", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg", channel_id: "789" });

    await sendMessageDiscord("channel:789", "", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "chart.png",
      mediaAccess: {
        workspaceDir: "/tmp/agent-workspace",
      },
    });

    const mediaOptions = requireRecord(
      requireMockArg(vi.mocked(loadWebMedia), "loadWebMedia", 0, 1),
      "media load options",
    );
    expect(mediaOptions.workspaceDir).toBe("/tmp/agent-workspace");
  });

  it("prefers the caller-provided filename for media attachments", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg", channel_id: "789" });

    await sendMessageDiscord("channel:789", "photo", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "file:///tmp/generated-image",
      filename: "renderable.png",
    });

    expectRestRoute(postMock, 0, Routes.channelMessages("789"));
    expectBodyFileName(requireRestBody(postMock), "renderable.png");
  });

  it("uses configured discord mediaMaxMb for uploads", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg", channel_id: "789" });

    await sendMessageDiscord("channel:789", "photo", {
      rest,
      token: "t",
      mediaUrl: "file:///tmp/photo.jpg",
      cfg: {
        channels: {
          discord: {
            mediaMaxMb: 32,
          },
        },
      },
    });

    expect(loadWebMedia).toHaveBeenCalledWith("file:///tmp/photo.jpg", {
      maxBytes: 32 * 1024 * 1024,
    });
  });

  it("sends media with empty text without content field", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
    const res = await sendMessageDiscord("channel:789", "", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "file:///tmp/photo.jpg",
    });
    expect(res.messageId).toBe("msg");
    const body = requireRestBody(postMock);
    expect(body).not.toHaveProperty("content");
    expect(body).toHaveProperty("files");
  });

  it("preserves whitespace in media captions", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
    await sendMessageDiscord("channel:789", "  spaced  ", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      mediaUrl: "file:///tmp/photo.jpg",
    });
    const body = requireRestBody(postMock);
    expect(body).toHaveProperty("content", "  spaced  ");
  });

  it("includes message_reference when replying", async () => {
    const { rest, postMock } = makeDiscordRest();
    postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
    await sendMessageDiscord("channel:789", "hello", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
      reply: { messageId: "orig-123", scope: "all" },
    });
    const body = requireRestBody(postMock);
    expect(body?.message_reference).toEqual({
      message_id: "orig-123",
      fail_if_not_exists: false,
    });
  });

  it.each([
    {
      name: "preserves reply reference across all text chunks by default",
      params: { text: "a".repeat(2001) },
      expectsSecondReply: true,
    },
    {
      name: "limits reply reference to the first text chunk when requested",
      params: { text: "a".repeat(2001), replyScope: "first" as const },
      expectsSecondReply: false,
      checksReceipt: true,
    },
    {
      name: "preserves reply reference for follow-up text chunks after media caption split by default",
      params: { text: "a".repeat(2500), mediaUrl: "file:///tmp/photo.jpg" },
      expectsSecondReply: true,
    },
    {
      name: "limits media caption reply reference to the first physical message when requested",
      params: {
        text: "a".repeat(2500),
        mediaUrl: "file:///tmp/photo.jpg",
        replyScope: "first" as const,
      },
      expectsSecondReply: false,
    },
  ])("$name", async ({ params, expectsSecondReply, checksReceipt }) => {
    const { firstBody, secondBody, result } = await sendChunkedReplyAndCollectBodies(params);
    expectReplyReference(firstBody, "orig-123");
    if (expectsSecondReply) {
      expectReplyReference(secondBody, "orig-123");
    } else {
      expectNoReplyReference(secondBody);
    }
    if (checksReceipt) {
      expect(result.receipt.replyToId).toBe("orig-123");
      expect(result.receipt.parts.map((part) => part.replyToId)).toEqual(["orig-123", undefined]);
      expect(() => JSON.stringify(result.receipt)).not.toThrow();
    }
  });
});

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

  it.each([
    { name: "reacts with unicode emoji", emoji: "✅", encoded: "%E2%9C%85" },
    {
      name: "normalizes variation selectors in unicode emoji",
      emoji: "⭐️",
      encoded: "%E2%AD%90",
    },
    {
      name: "reacts with custom emoji syntax",
      emoji: "<:party_blob:123>",
      encoded: "party_blob%3A123",
    },
  ])("$name", async ({ emoji, encoded }) => {
    const { rest, putMock } = makeDiscordRest();
    await reactMessageDiscord("chan1", "msg1", emoji, { rest, token: "t", cfg: DISCORD_TEST_CFG });
    expect(putMock).toHaveBeenCalledWith(
      Routes.channelMessageOwnReaction("chan1", "msg1", encoded),
    );
  });
});

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

  it("removes a unicode emoji reaction", async () => {
    const { rest, deleteMock } = makeDiscordRest();
    await removeReactionDiscord("chan1", "msg1", "✅", { rest, token: "t", cfg: DISCORD_TEST_CFG });
    expect(deleteMock).toHaveBeenCalledWith(
      Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
    );
  });

  it("retries transient failures while removing an idempotent reaction", async () => {
    const { rest, deleteMock } = makeDiscordRest();
    deleteMock
      .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 }))
      .mockResolvedValueOnce(undefined);

    await expect(
      removeReactionDiscord("chan1", "msg1", "✅", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
        retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
      }),
    ).resolves.toEqual({ ok: true });
    expect(deleteMock).toHaveBeenCalledTimes(2);
  });
});

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

  it("removes only owned unicode and custom reactions without repeating emoji", async () => {
    const { rest, getMock, deleteMock } = makeDiscordRest();
    getMock.mockResolvedValue({
      reactions: [
        { me: false, emoji: { name: "👀", id: null } },
        { me: true, emoji: { name: "✅", id: null } },
        { me: true, emoji: { name: "✅", id: null } },
        { me: true, emoji: { name: "party_blob", id: "123" } },
        { me: false, emoji: { name: "other_blob", id: "456" } },
      ],
    });
    const res = await removeOwnReactionsDiscord("chan1", "msg1", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });
    expect(res).toEqual({ ok: true, removed: ["✅", "party_blob:123"] });
    expect(deleteMock).toHaveBeenCalledWith(
      Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
    );
    expect(deleteMock).toHaveBeenCalledWith(
      Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"),
    );
    expect(deleteMock).toHaveBeenCalledTimes(2);
  });

  it("does not send removal requests when all reactions belong to other users", async () => {
    const { rest, getMock, deleteMock } = makeDiscordRest();
    getMock.mockResolvedValue({
      reactions: [
        { me: false, emoji: { name: "👀", id: null } },
        { me: false, emoji: { name: "other_blob", id: "456" } },
      ],
    });

    await expect(
      removeOwnReactionsDiscord("chan1", "msg1", { rest, token: "t", cfg: DISCORD_TEST_CFG }),
    ).resolves.toEqual({ ok: true, removed: [] });
    expect(deleteMock).not.toHaveBeenCalled();
  });

  it("retries transient failures while listing and clearing owned reactions", async () => {
    const { rest, getMock, deleteMock } = makeDiscordRest();
    getMock
      .mockRejectedValueOnce(Object.assign(new Error("service unavailable"), { status: 503 }))
      .mockResolvedValueOnce({ reactions: [{ me: true, emoji: { name: "✅", id: null } }] });
    deleteMock
      .mockRejectedValueOnce(Object.assign(new Error("bad gateway"), { status: 502 }))
      .mockResolvedValueOnce(undefined);

    await expect(
      removeOwnReactionsDiscord("chan1", "msg1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
        retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
      }),
    ).resolves.toEqual({ ok: true, removed: ["✅"] });
    expect(getMock).toHaveBeenCalledTimes(2);
    expect(deleteMock).toHaveBeenCalledTimes(2);
  });

  it("surfaces a failed deletion instead of reporting false success", async () => {
    const { rest, getMock, deleteMock } = makeDiscordRest();
    getMock.mockResolvedValue({
      reactions: [
        { me: true, emoji: { name: "✅", id: null } },
        { me: true, emoji: { name: "party_blob", id: "123" } },
      ],
    });
    const apiError = new Error("Discord API 500");
    deleteMock.mockResolvedValueOnce(undefined);
    deleteMock.mockRejectedValueOnce(apiError);
    await expect(
      removeOwnReactionsDiscord("chan1", "msg1", { rest, token: "t", cfg: DISCORD_TEST_CFG }),
    ).rejects.toThrow("Discord API 500");
    // Both deletions are still attempted; the rejection just propagates.
    expect(deleteMock).toHaveBeenCalledTimes(2);
  });
});

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

  it("returns reactions with users", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock
      .mockResolvedValueOnce({
        reactions: [
          { count: 2, emoji: { name: "✅", id: null } },
          { count: 1, emoji: { name: "party_blob", id: "123" } },
        ],
      })
      .mockResolvedValueOnce([{ id: "u1", username: "alpha", discriminator: "0001" }])
      .mockResolvedValueOnce([{ id: "u2", username: "beta" }]);
    const res = await fetchReactionsDiscord("chan1", "msg1", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });
    expect(res).toEqual([
      {
        emoji: { id: null, name: "✅", raw: "✅" },
        count: 2,
        users: [{ id: "u1", username: "alpha", tag: "alpha#0001" }],
      },
      {
        emoji: { id: "123", name: "party_blob", raw: "party_blob:123" },
        count: 1,
        users: [{ id: "u2", username: "beta", tag: "beta" }],
      },
    ]);
  });

  it.each([
    { operation: "message lookup", firstFailure: true, status: 503 },
    { operation: "reaction-user lookup", firstFailure: false, status: 502 },
  ])("retries a transient $operation failure", async ({ firstFailure, status }) => {
    const { rest, getMock } = makeDiscordRest();
    const transientError = Object.assign(new Error("Discord temporarily unavailable"), { status });
    const message = { reactions: [{ count: 1, emoji: { name: "✅", id: null } }] };
    const users = [{ id: "u1", username: "alpha" }];
    if (firstFailure) {
      getMock.mockRejectedValueOnce(transientError).mockResolvedValueOnce(message);
    } else {
      getMock.mockResolvedValueOnce(message).mockRejectedValueOnce(transientError);
    }
    getMock.mockResolvedValueOnce(users);

    await expect(
      fetchReactionsDiscord("chan1", "msg1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
        retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
      }),
    ).resolves.toEqual([
      {
        emoji: { id: null, name: "✅", raw: "✅" },
        count: 1,
        users: [{ id: "u1", username: "alpha", tag: "alpha" }],
      },
    ]);
    expect(getMock).toHaveBeenCalledTimes(3);
  });
});

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

  it("calculates permissions from guild roles", async () => {
    const { rest, getMock } = makeDiscordRest();
    const perms = PermissionFlagsBits.ViewChannel | PermissionFlagsBits.SendMessages;
    getMock
      .mockResolvedValueOnce({
        id: "chan1",
        guild_id: "guild1",
        permission_overwrites: [],
      })
      .mockResolvedValueOnce({ id: "bot1" })
      .mockResolvedValueOnce({
        id: "guild1",
        roles: [
          { id: "guild1", permissions: perms.toString() },
          { id: "role2", permissions: "0" },
        ],
      })
      .mockResolvedValueOnce({ roles: ["role2"] });
    const res = await fetchChannelPermissionsDiscord("chan1", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });
    expect(res.guildId).toBe("guild1");
    expect(res.permissions).toContain("ViewChannel");
    expect(res.permissions).toContain("SendMessages");
    expect(res.isDm).toBe(false);
  });

  it("stops permission lookup when the caller deadline aborts", async () => {
    const { rest, getMock } = makeDiscordRest();
    const controller = new AbortController();
    getMock.mockImplementationOnce(async () => {
      controller.abort();
      return {
        id: "chan1",
        guild_id: "guild1",
        permission_overwrites: [],
      };
    });

    await expect(
      fetchChannelPermissionsDiscord("chan1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
        signal: controller.signal,
      }),
    ).rejects.toMatchObject({ name: "AbortError" });
    expect(getMock).toHaveBeenCalledTimes(1);
  });

  it("treats Administrator as all permissions despite overwrites", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock
      .mockResolvedValueOnce({
        id: "chan1",
        guild_id: "guild1",
        permission_overwrites: [
          {
            id: "guild1",
            deny: PermissionFlagsBits.ViewChannel.toString(),
            allow: "0",
          },
        ],
      })
      .mockResolvedValueOnce({ id: "bot1" })
      .mockResolvedValueOnce({
        id: "guild1",
        roles: [{ id: "guild1", permissions: PermissionFlagsBits.Administrator.toString() }],
      })
      .mockResolvedValueOnce({ roles: [] });
    const res = await fetchChannelPermissionsDiscord("chan1", {
      rest,
      token: "t",
      cfg: DISCORD_TEST_CFG,
    });
    expect(res.permissions).toContain("Administrator");
    expect(res.permissions).toContain("ViewChannel");
  });

  it("checks whether an arbitrary member can view a guild channel", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock
      .mockResolvedValueOnce({
        id: "chan1",
        guild_id: "guild1",
        permission_overwrites: [
          {
            id: "guild1",
            deny: PermissionFlagsBits.ViewChannel.toString(),
            allow: "0",
          },
          {
            id: "role2",
            deny: "0",
            allow: PermissionFlagsBits.ViewChannel.toString(),
          },
        ],
      })
      .mockResolvedValueOnce({
        id: "guild1",
        roles: [
          { id: "guild1", permissions: "0" },
          { id: "role2", permissions: "0" },
        ],
      })
      .mockResolvedValueOnce({ roles: ["role2"] });

    await expect(
      canViewDiscordGuildChannel("guild1", "chan1", "user1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
      }),
    ).resolves.toBe(true);
  });

  it("aggregates conflicting role overwrites before applying allows", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock
      .mockResolvedValueOnce({
        id: "chan1",
        guild_id: "guild1",
        permission_overwrites: [
          {
            id: "role-allow",
            deny: "0",
            allow: PermissionFlagsBits.ViewChannel.toString(),
          },
          {
            id: "role-deny",
            deny: PermissionFlagsBits.ViewChannel.toString(),
            allow: "0",
          },
        ],
      })
      .mockResolvedValueOnce({
        id: "guild1",
        roles: [
          { id: "guild1", permissions: "0" },
          { id: "role-allow", permissions: "0" },
          { id: "role-deny", permissions: "0" },
        ],
      })
      .mockResolvedValueOnce({ roles: ["role-allow", "role-deny"] });

    await expect(
      canViewDiscordGuildChannel("guild1", "chan1", "user1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
      }),
    ).resolves.toBe(true);
  });

  it.each([
    {
      name: "uses parent ViewChannel permissions for a public thread",
      type: ChannelType.GuildPublicThread,
      overwrites: [{ id: "user1", deny: PermissionFlagsBits.ViewChannel.toString(), allow: "0" }],
      permissions: PermissionFlagsBits.ViewChannel,
      membership: "none",
      expected: false,
      expectedCalls: 4,
    },
    {
      name: "requires private-thread membership after parent ViewChannel permission",
      type: ChannelType.GuildPrivateThread,
      overwrites: [],
      permissions: PermissionFlagsBits.ViewChannel,
      membership: "member",
      expected: true,
    },
    {
      name: "fails closed when a user is not a private-thread member",
      type: ChannelType.GuildPrivateThread,
      overwrites: [],
      permissions: PermissionFlagsBits.ViewChannel,
      membership: "missing",
      expected: false,
    },
    {
      name: "allows private-thread moderators without explicit membership",
      type: ChannelType.GuildPrivateThread,
      overwrites: [],
      permissions: PermissionFlagsBits.ViewChannel | PermissionFlagsBits.ManageThreads,
      membership: "none",
      expected: true,
      expectedCalls: 4,
    },
  ])("$name", async ({ type, overwrites, permissions, membership, expected, expectedCalls }) => {
    const { rest, getMock } = makeDiscordRest();
    getMock
      .mockResolvedValueOnce({
        id: "thread1",
        guild_id: "guild1",
        parent_id: "parent1",
        type,
      })
      .mockResolvedValueOnce({
        id: "parent1",
        guild_id: "guild1",
        type: ChannelType.GuildText,
        permission_overwrites: overwrites,
      })
      .mockResolvedValueOnce({
        id: "guild1",
        roles: [{ id: "guild1", permissions: permissions.toString() }],
      })
      .mockResolvedValueOnce({ roles: [] });
    if (membership === "member") {
      getMock.mockResolvedValueOnce({ id: "thread1", user_id: "user1" });
    } else if (membership === "missing") {
      getMock.mockRejectedValueOnce(new Error("404 Unknown Member"));
    }
    await expect(
      canViewDiscordGuildChannel("guild1", "thread1", "user1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
      }),
    ).resolves.toBe(expected);
    if (expectedCalls !== undefined) {
      expect(getMock).toHaveBeenCalledTimes(expectedCalls);
    }
    if (membership === "member") {
      expect(getMock).toHaveBeenLastCalledWith(Routes.threadMembers("thread1", "user1"));
    }
  });

  it("fails closed when the channel belongs to a different guild", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock.mockResolvedValueOnce({
      id: "chan1",
      guild_id: "guild2",
      permission_overwrites: [],
    });

    await expect(
      canViewDiscordGuildChannel("guild1", "chan1", "user1", {
        rest,
        token: "t",
        cfg: DISCORD_TEST_CFG,
      }),
    ).resolves.toBe(false);
  });
});

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

  it("passes query params as an object", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock.mockResolvedValue([]);
    await readMessagesDiscord(
      "chan1",
      { limit: 5, before: "10" },
      { rest, token: "t", cfg: DISCORD_TEST_CFG },
    );
    const options = requireRecord(
      requireMockArg(getMock, "Discord REST GET", 0, 1),
      "Discord REST GET options",
    );
    expect(options).toEqual({ limit: 5, before: "10" });
  });
});

describe("edit/delete message helpers", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("edits message content", async () => {
    const { rest, patchMock } = makeDiscordRest();
    patchMock.mockResolvedValue({ id: "m1" });
    await editMessageDiscord(
      "chan1",
      "m1",
      { content: "hello" },
      { rest, token: "t", cfg: DISCORD_TEST_CFG },
    );
    expectRestRoute(patchMock, 0, Routes.channelMessage("chan1", "m1"));
    expect(requireRestBody(patchMock).content).toBe("hello");
  });

  it("deletes message", async () => {
    const { rest, deleteMock } = makeDiscordRest();
    deleteMock.mockResolvedValue({});
    await deleteMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG });
    expect(deleteMock).toHaveBeenCalledWith(Routes.channelMessage("chan1", "m1"));
  });
});

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

  it("pins and unpins messages", async () => {
    const { rest, putMock, deleteMock } = makeDiscordRest();
    putMock.mockResolvedValue({});
    deleteMock.mockResolvedValue({});
    await pinMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG });
    await unpinMessageDiscord("chan1", "m1", { rest, token: "t", cfg: DISCORD_TEST_CFG });
    expect(putMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
    expect(deleteMock).toHaveBeenCalledWith(Routes.channelPin("chan1", "m1"));
  });
});

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

  it("uses URLSearchParams for search", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock.mockResolvedValue({ total_results: 0, messages: [] });
    await searchMessagesDiscord(
      { guildId: "g1", content: "hello", limit: 5 },
      { rest, token: "t", cfg: DISCORD_TEST_CFG },
    );
    expect(requireMockArg(getMock, "Discord REST GET", 0, 0)).toBe(
      "/guilds/g1/messages/search?content=hello&limit=5",
    );
  });

  it("supports channel/author arrays and clamps limit", async () => {
    const { rest, getMock } = makeDiscordRest();
    getMock.mockResolvedValue({ total_results: 0, messages: [] });
    await searchMessagesDiscord(
      {
        guildId: "g1",
        content: "hello",
        channelIds: ["c1", "c2"],
        authorIds: ["u1"],
        limit: 99,
      },
      { rest, token: "t", cfg: DISCORD_TEST_CFG },
    );
    expect(requireMockArg(getMock, "Discord REST GET", 0, 0)).toBe(
      "/guilds/g1/messages/search?content=hello&channel_id=c1&channel_id=c2&author_id=u1&limit=25",
    );
  });
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
