import { ChannelType, type Guild } from "@buape/carbon";
import { describe, expect, it, vi } from "vitest";
import { sleep } from "../utils.js";
import {
  allowListMatches,
  buildDiscordMediaPayload,
  type DiscordGuildEntryResolved,
  isDiscordGroupAllowedByPolicy,
  normalizeDiscordAllowList,
  normalizeDiscordSlug,
  registerDiscordListener,
  resolveDiscordChannelConfig,
  resolveDiscordChannelConfigWithFallback,
  resolveDiscordGuildEntry,
  resolveDiscordReplyTarget,
  resolveDiscordShouldRequireMention,
  resolveGroupDmAllow,
  sanitizeDiscordThreadName,
  shouldEmitDiscordReactionNotification,
} from "./monitor.js";
import { DiscordMessageListener, DiscordReactionListener } from "./monitor/listeners.js";

const fakeGuild = (id: string, name: string) => ({ id, name }) as Guild;

const makeEntries = (
  entries: Record<string, Partial<DiscordGuildEntryResolved>>,
): Record<string, DiscordGuildEntryResolved> => {
  const out: Record<string, DiscordGuildEntryResolved> = {};
  for (const [key, value] of Object.entries(entries)) {
    out[key] = {
      slug: value.slug,
      requireMention: value.requireMention,
      reactionNotifications: value.reactionNotifications,
      users: value.users,
      channels: value.channels,
    };
  }
  return out;
};

describe("registerDiscordListener", () => {
  class FakeListener {}

  it("dedupes listeners by constructor", () => {
    const listeners: object[] = [];

    expect(registerDiscordListener(listeners, new FakeListener())).toBe(true);
    expect(registerDiscordListener(listeners, new FakeListener())).toBe(false);
    expect(listeners).toHaveLength(1);
  });
});

describe("DiscordMessageListener", () => {
  it("returns before the handler finishes", async () => {
    let handlerResolved = false;
    let resolveHandler: (() => void) | null = null;
    const handlerPromise = new Promise<void>((resolve) => {
      resolveHandler = () => {
        handlerResolved = true;
        resolve();
      };
    });
    const handler = vi.fn(() => handlerPromise);
    const listener = new DiscordMessageListener(handler);

    await listener.handle(
      {} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
      {} as unknown as import("@buape/carbon").Client,
    );

    expect(handler).toHaveBeenCalledOnce();
    expect(handlerResolved).toBe(false);

    resolveHandler?.();
    await handlerPromise;
  });

  it("logs handler failures", async () => {
    const logger = {
      warn: vi.fn(),
      error: vi.fn(),
    } as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>;
    const handler = vi.fn(async () => {
      throw new Error("boom");
    });
    const listener = new DiscordMessageListener(handler, logger);

    await listener.handle(
      {} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
      {} as unknown as import("@buape/carbon").Client,
    );
    await sleep(0);

    expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("discord handler failed"));
  });

  it("logs slow handlers after the threshold", async () => {
    vi.useFakeTimers();
    vi.setSystemTime(0);

    try {
      let resolveHandler: (() => void) | null = null;
      const handlerPromise = new Promise<void>((resolve) => {
        resolveHandler = resolve;
      });
      const handler = vi.fn(() => handlerPromise);
      const logger = {
        warn: vi.fn(),
        error: vi.fn(),
      } as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>;
      const listener = new DiscordMessageListener(handler, logger);

      await listener.handle(
        {} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
        {} as unknown as import("@buape/carbon").Client,
      );

      vi.setSystemTime(31_000);
      resolveHandler?.();
      await handlerPromise;
      await Promise.resolve();

      expect(logger.warn).toHaveBeenCalled();
      const [, meta] = logger.warn.mock.calls[0] ?? [];
      expect(meta?.durationMs).toBeGreaterThanOrEqual(30_000);
    } finally {
      vi.useRealTimers();
    }
  });
});

describe("discord allowlist helpers", () => {
  it("normalizes slugs", () => {
    expect(normalizeDiscordSlug("Friends of OpenClaw")).toBe("friends-of-openclaw");
    expect(normalizeDiscordSlug("#General")).toBe("general");
    expect(normalizeDiscordSlug("Dev__Chat")).toBe("dev-chat");
  });

  it("matches ids or names", () => {
    const allow = normalizeDiscordAllowList(
      ["123", "steipete", "Friends of OpenClaw"],
      ["discord:", "user:", "guild:", "channel:"],
    );
    expect(allow).not.toBeNull();
    if (!allow) {
      throw new Error("Expected allow list to be normalized");
    }
    expect(allowListMatches(allow, { id: "123" })).toBe(true);
    expect(allowListMatches(allow, { name: "steipete" })).toBe(true);
    expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(true);
    expect(allowListMatches(allow, { name: "other" })).toBe(false);
  });

  it("matches pk-prefixed allowlist entries", () => {
    const allow = normalizeDiscordAllowList(["pk:member-123"], ["discord:", "user:", "pk:"]);
    expect(allow).not.toBeNull();
    if (!allow) {
      throw new Error("Expected allow list to be normalized");
    }
    expect(allowListMatches(allow, { id: "member-123" })).toBe(true);
    expect(allowListMatches(allow, { id: "member-999" })).toBe(false);
  });
});

describe("discord guild/channel resolution", () => {
  it("resolves guild entry by id", () => {
    const guildEntries = makeEntries({
      "123": { slug: "friends-of-openclaw" },
    });
    const resolved = resolveDiscordGuildEntry({
      guild: fakeGuild("123", "Friends of OpenClaw"),
      guildEntries,
    });
    expect(resolved?.id).toBe("123");
    expect(resolved?.slug).toBe("friends-of-openclaw");
  });

  it("resolves guild entry by slug key", () => {
    const guildEntries = makeEntries({
      "friends-of-openclaw": { slug: "friends-of-openclaw" },
    });
    const resolved = resolveDiscordGuildEntry({
      guild: fakeGuild("123", "Friends of OpenClaw"),
      guildEntries,
    });
    expect(resolved?.id).toBe("123");
    expect(resolved?.slug).toBe("friends-of-openclaw");
  });

  it("falls back to wildcard guild entry", () => {
    const guildEntries = makeEntries({
      "*": { requireMention: false },
    });
    const resolved = resolveDiscordGuildEntry({
      guild: fakeGuild("123", "Friends of OpenClaw"),
      guildEntries,
    });
    expect(resolved?.id).toBe("123");
    expect(resolved?.requireMention).toBe(false);
  });

  it("resolves channel config by slug", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {
        general: { allow: true },
        help: {
          allow: true,
          requireMention: true,
          skills: ["search"],
          enabled: false,
          users: ["123"],
          systemPrompt: "Use short answers.",
          autoThread: true,
        },
      },
    };
    const channel = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "456",
      channelName: "General",
      channelSlug: "general",
    });
    expect(channel?.allowed).toBe(true);
    expect(channel?.requireMention).toBeUndefined();

    const help = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "789",
      channelName: "Help",
      channelSlug: "help",
    });
    expect(help?.allowed).toBe(true);
    expect(help?.requireMention).toBe(true);
    expect(help?.skills).toEqual(["search"]);
    expect(help?.enabled).toBe(false);
    expect(help?.users).toEqual(["123"]);
    expect(help?.systemPrompt).toBe("Use short answers.");
    expect(help?.autoThread).toBe(true);
  });

  it("denies channel when config present but no match", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {
        general: { allow: true },
      },
    };
    const channel = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "999",
      channelName: "random",
      channelSlug: "random",
    });
    expect(channel?.allowed).toBe(false);
  });

  it("inherits parent config for thread channels", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {
        general: { allow: true },
        random: { allow: false },
      },
    };
    const thread = resolveDiscordChannelConfigWithFallback({
      guildInfo,
      channelId: "thread-123",
      channelName: "topic",
      channelSlug: "topic",
      parentId: "999",
      parentName: "random",
      parentSlug: "random",
      scope: "thread",
    });
    expect(thread?.allowed).toBe(false);
  });

  it("does not match thread name/slug when resolving allowlists", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {
        general: { allow: true },
        random: { allow: false },
      },
    };
    const thread = resolveDiscordChannelConfigWithFallback({
      guildInfo,
      channelId: "thread-999",
      channelName: "general",
      channelSlug: "general",
      parentId: "999",
      parentName: "random",
      parentSlug: "random",
      scope: "thread",
    });
    expect(thread?.allowed).toBe(false);
  });

  it("applies wildcard channel config when no specific match", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {
        general: { allow: true, requireMention: false },
        "*": { allow: true, autoThread: true, requireMention: true },
      },
    };
    // Specific channel should NOT use wildcard
    const general = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "123",
      channelName: "general",
      channelSlug: "general",
    });
    expect(general?.allowed).toBe(true);
    expect(general?.requireMention).toBe(false);
    expect(general?.autoThread).toBeUndefined();
    expect(general?.matchSource).toBe("direct");

    // Unknown channel should use wildcard
    const random = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "999",
      channelName: "random",
      channelSlug: "random",
    });
    expect(random?.allowed).toBe(true);
    expect(random?.autoThread).toBe(true);
    expect(random?.requireMention).toBe(true);
    expect(random?.matchSource).toBe("wildcard");
  });

  it("falls back to wildcard when thread channel and parent are missing", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {
        "*": { allow: true, requireMention: false },
      },
    };
    const thread = resolveDiscordChannelConfigWithFallback({
      guildInfo,
      channelId: "thread-123",
      channelName: "topic",
      channelSlug: "topic",
      parentId: "parent-999",
      parentName: "general",
      parentSlug: "general",
      scope: "thread",
    });
    expect(thread?.allowed).toBe(true);
    expect(thread?.matchKey).toBe("*");
    expect(thread?.matchSource).toBe("wildcard");
  });
});

describe("discord mention gating", () => {
  it("requires mention by default", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      requireMention: true,
      channels: {
        general: { allow: true },
      },
    };
    const channelConfig = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "1",
      channelName: "General",
      channelSlug: "general",
    });
    expect(
      resolveDiscordShouldRequireMention({
        isGuildMessage: true,
        isThread: false,
        channelConfig,
        guildInfo,
      }),
    ).toBe(true);
  });

  it("does not require mention inside autoThread threads", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      requireMention: true,
      channels: {
        general: { allow: true, autoThread: true },
      },
    };
    const channelConfig = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "1",
      channelName: "General",
      channelSlug: "general",
    });
    expect(
      resolveDiscordShouldRequireMention({
        isGuildMessage: true,
        isThread: true,
        botId: "bot123",
        threadOwnerId: "bot123",
        channelConfig,
        guildInfo,
      }),
    ).toBe(false);
  });

  it("requires mention inside user-created threads with autoThread enabled", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      requireMention: true,
      channels: {
        general: { allow: true, autoThread: true },
      },
    };
    const channelConfig = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "1",
      channelName: "General",
      channelSlug: "general",
    });
    expect(
      resolveDiscordShouldRequireMention({
        isGuildMessage: true,
        isThread: true,
        botId: "bot123",
        threadOwnerId: "user456",
        channelConfig,
        guildInfo,
      }),
    ).toBe(true);
  });

  it("requires mention when thread owner is unknown", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      requireMention: true,
      channels: {
        general: { allow: true, autoThread: true },
      },
    };
    const channelConfig = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "1",
      channelName: "General",
      channelSlug: "general",
    });
    expect(
      resolveDiscordShouldRequireMention({
        isGuildMessage: true,
        isThread: true,
        botId: "bot123",
        channelConfig,
        guildInfo,
      }),
    ).toBe(true);
  });

  it("inherits parent channel mention rules for threads", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      requireMention: true,
      channels: {
        "parent-1": { allow: true, requireMention: false },
      },
    };
    const channelConfig = resolveDiscordChannelConfigWithFallback({
      guildInfo,
      channelId: "thread-1",
      channelName: "topic",
      channelSlug: "topic",
      parentId: "parent-1",
      parentName: "Parent",
      parentSlug: "parent",
      scope: "thread",
    });
    expect(channelConfig?.matchSource).toBe("parent");
    expect(channelConfig?.matchKey).toBe("parent-1");
    expect(
      resolveDiscordShouldRequireMention({
        isGuildMessage: true,
        isThread: true,
        channelConfig,
        guildInfo,
      }),
    ).toBe(false);
  });
});

describe("discord groupPolicy gating", () => {
  it("allows when policy is open", () => {
    expect(
      isDiscordGroupAllowedByPolicy({
        groupPolicy: "open",
        guildAllowlisted: false,
        channelAllowlistConfigured: false,
        channelAllowed: false,
      }),
    ).toBe(true);
  });

  it("blocks when policy is disabled", () => {
    expect(
      isDiscordGroupAllowedByPolicy({
        groupPolicy: "disabled",
        guildAllowlisted: true,
        channelAllowlistConfigured: true,
        channelAllowed: true,
      }),
    ).toBe(false);
  });

  it("blocks allowlist when guild is not allowlisted", () => {
    expect(
      isDiscordGroupAllowedByPolicy({
        groupPolicy: "allowlist",
        guildAllowlisted: false,
        channelAllowlistConfigured: false,
        channelAllowed: true,
      }),
    ).toBe(false);
  });

  it("allows allowlist when guild allowlisted but no channel allowlist", () => {
    expect(
      isDiscordGroupAllowedByPolicy({
        groupPolicy: "allowlist",
        guildAllowlisted: true,
        channelAllowlistConfigured: false,
        channelAllowed: true,
      }),
    ).toBe(true);
  });

  it("allows allowlist when channel is allowed", () => {
    expect(
      isDiscordGroupAllowedByPolicy({
        groupPolicy: "allowlist",
        guildAllowlisted: true,
        channelAllowlistConfigured: true,
        channelAllowed: true,
      }),
    ).toBe(true);
  });

  it("blocks allowlist when channel is not allowed", () => {
    expect(
      isDiscordGroupAllowedByPolicy({
        groupPolicy: "allowlist",
        guildAllowlisted: true,
        channelAllowlistConfigured: true,
        channelAllowed: false,
      }),
    ).toBe(false);
  });
});

describe("discord group DM gating", () => {
  it("allows all when no allowlist", () => {
    expect(
      resolveGroupDmAllow({
        channels: undefined,
        channelId: "1",
        channelName: "dm",
        channelSlug: "dm",
      }),
    ).toBe(true);
  });

  it("matches group DM allowlist", () => {
    expect(
      resolveGroupDmAllow({
        channels: ["openclaw-dm"],
        channelId: "1",
        channelName: "OpenClaw DM",
        channelSlug: "openclaw-dm",
      }),
    ).toBe(true);
    expect(
      resolveGroupDmAllow({
        channels: ["openclaw-dm"],
        channelId: "1",
        channelName: "Other",
        channelSlug: "other",
      }),
    ).toBe(false);
  });
});

describe("discord reply target selection", () => {
  it("skips replies when mode is off", () => {
    expect(
      resolveDiscordReplyTarget({
        replyToMode: "off",
        replyToId: "123",
        hasReplied: false,
      }),
    ).toBeUndefined();
  });

  it("replies only once when mode is first", () => {
    expect(
      resolveDiscordReplyTarget({
        replyToMode: "first",
        replyToId: "123",
        hasReplied: false,
      }),
    ).toBe("123");
    expect(
      resolveDiscordReplyTarget({
        replyToMode: "first",
        replyToId: "123",
        hasReplied: true,
      }),
    ).toBeUndefined();
  });

  it("replies on every message when mode is all", () => {
    expect(
      resolveDiscordReplyTarget({
        replyToMode: "all",
        replyToId: "123",
        hasReplied: false,
      }),
    ).toBe("123");
    expect(
      resolveDiscordReplyTarget({
        replyToMode: "all",
        replyToId: "123",
        hasReplied: true,
      }),
    ).toBe("123");
  });
});

describe("discord autoThread name sanitization", () => {
  it("strips mentions and collapses whitespace", () => {
    const name = sanitizeDiscordThreadName("  <@123>  <@&456> <#789>  Help   here  ", "msg-1");
    expect(name).toBe("Help here");
  });

  it("falls back to thread + id when empty after cleaning", () => {
    const name = sanitizeDiscordThreadName("   <@123>", "abc");
    expect(name).toBe("Thread abc");
  });
});

describe("discord reaction notification gating", () => {
  it("defaults to own when mode is unset", () => {
    expect(
      shouldEmitDiscordReactionNotification({
        mode: undefined,
        botId: "bot-1",
        messageAuthorId: "bot-1",
        userId: "user-1",
      }),
    ).toBe(true);
    expect(
      shouldEmitDiscordReactionNotification({
        mode: undefined,
        botId: "bot-1",
        messageAuthorId: "user-1",
        userId: "user-2",
      }),
    ).toBe(false);
  });

  it("skips when mode is off", () => {
    expect(
      shouldEmitDiscordReactionNotification({
        mode: "off",
        botId: "bot-1",
        messageAuthorId: "bot-1",
        userId: "user-1",
      }),
    ).toBe(false);
  });

  it("allows all reactions when mode is all", () => {
    expect(
      shouldEmitDiscordReactionNotification({
        mode: "all",
        botId: "bot-1",
        messageAuthorId: "user-1",
        userId: "user-2",
      }),
    ).toBe(true);
  });

  it("requires bot ownership when mode is own", () => {
    expect(
      shouldEmitDiscordReactionNotification({
        mode: "own",
        botId: "bot-1",
        messageAuthorId: "bot-1",
        userId: "user-2",
      }),
    ).toBe(true);
    expect(
      shouldEmitDiscordReactionNotification({
        mode: "own",
        botId: "bot-1",
        messageAuthorId: "user-2",
        userId: "user-3",
      }),
    ).toBe(false);
  });

  it("requires allowlist matches when mode is allowlist", () => {
    expect(
      shouldEmitDiscordReactionNotification({
        mode: "allowlist",
        botId: "bot-1",
        messageAuthorId: "user-1",
        userId: "user-2",
        allowlist: [],
      }),
    ).toBe(false);
    expect(
      shouldEmitDiscordReactionNotification({
        mode: "allowlist",
        botId: "bot-1",
        messageAuthorId: "user-1",
        userId: "123",
        userName: "steipete",
        allowlist: ["123", "other"],
      }),
    ).toBe(true);
  });
});

describe("discord media payload", () => {
  it("preserves attachment order for MediaPaths/MediaUrls", () => {
    const payload = buildDiscordMediaPayload([
      { path: "/tmp/a.png", contentType: "image/png" },
      { path: "/tmp/b.png", contentType: "image/png" },
      { path: "/tmp/c.png", contentType: "image/png" },
    ]);
    expect(payload.MediaPath).toBe("/tmp/a.png");
    expect(payload.MediaUrl).toBe("/tmp/a.png");
    expect(payload.MediaType).toBe("image/png");
    expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png", "/tmp/c.png"]);
    expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png", "/tmp/c.png"]);
  });
});

// --- DM reaction integration tests ---
// These test that handleDiscordReactionEvent (via DiscordReactionListener)
// properly handles DM reactions instead of silently dropping them.

const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({
  enqueueSystemEventSpy: vi.fn(),
  resolveAgentRouteMock: vi.fn(() => ({
    agentId: "default",
    channel: "discord",
    accountId: "acc-1",
    sessionKey: "discord:acc-1:dm:user-1",
  })),
}));

vi.mock("../infra/system-events.js", () => ({
  enqueueSystemEvent: enqueueSystemEventSpy,
}));

vi.mock("../routing/resolve-route.js", () => ({
  resolveAgentRoute: resolveAgentRouteMock,
}));

function makeReactionEvent(overrides?: {
  guildId?: string;
  channelId?: string;
  userId?: string;
  messageId?: string;
  emojiName?: string;
  botAsAuthor?: boolean;
  guild?: { name?: string };
}) {
  const userId = overrides?.userId ?? "user-1";
  const messageId = overrides?.messageId ?? "msg-1";
  const channelId = overrides?.channelId ?? "channel-1";
  return {
    guild_id: overrides?.guildId,
    channel_id: channelId,
    message_id: messageId,
    emoji: { name: overrides?.emojiName ?? "👍", id: null },
    guild: overrides?.guild,
    user: {
      id: userId,
      bot: false,
      username: "testuser",
      discriminator: "0",
    },
    message: {
      fetch: vi.fn(async () => ({
        author: {
          id: overrides?.botAsAuthor ? "bot-1" : "other-user",
          username: overrides?.botAsAuthor ? "bot" : "otheruser",
          discriminator: "0",
        },
      })),
    },
  } as unknown as Parameters<DiscordReactionListener["handle"]>[0];
}

function makeReactionClient(channelType: ChannelType = ChannelType.DM) {
  return {
    fetchChannel: vi.fn(async () => ({
      type: channelType,
      name: channelType === ChannelType.DM ? undefined : "test-channel",
    })),
  } as unknown as Parameters<DiscordReactionListener["handle"]>[1];
}

function makeReactionListenerParams(overrides?: {
  botUserId?: string;
  guildEntries?: Record<string, DiscordGuildEntryResolved>;
}) {
  return {
    cfg: {} as ReturnType<typeof import("../config/config.js").loadConfig>,
    accountId: "acc-1",
    runtime: {} as import("../runtime.js").RuntimeEnv,
    botUserId: overrides?.botUserId ?? "bot-1",
    guildEntries: overrides?.guildEntries,
    logger: {
      info: vi.fn(),
      warn: vi.fn(),
      error: vi.fn(),
      debug: vi.fn(),
    } as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>,
  };
}

describe("discord DM reaction handling", () => {
  it("processes DM reactions instead of dropping them", async () => {
    enqueueSystemEventSpy.mockClear();
    resolveAgentRouteMock.mockClear();

    const data = makeReactionEvent({ botAsAuthor: true });
    const client = makeReactionClient(ChannelType.DM);
    const listener = new DiscordReactionListener(makeReactionListenerParams());

    await listener.handle(data, client);

    expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
    const [text, opts] = enqueueSystemEventSpy.mock.calls[0];
    expect(text).toContain("Discord reaction added");
    expect(text).toContain("👍");
    expect(opts.sessionKey).toBe("discord:acc-1:dm:user-1");
  });

  it("does not drop DM reactions when guild allowlist is configured", async () => {
    enqueueSystemEventSpy.mockClear();
    resolveAgentRouteMock.mockClear();

    const data = makeReactionEvent({ botAsAuthor: true });
    const client = makeReactionClient(ChannelType.DM);
    const guildEntries = makeEntries({
      "guild-123": { slug: "guild-123" },
    });
    const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));

    await listener.handle(data, client);

    expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
  });

  it("still processes guild reactions (no regression)", async () => {
    enqueueSystemEventSpy.mockClear();
    resolveAgentRouteMock.mockClear();
    resolveAgentRouteMock.mockReturnValueOnce({
      agentId: "default",
      channel: "discord",
      accountId: "acc-1",
      sessionKey: "discord:acc-1:guild-123:channel-1",
    });

    const data = makeReactionEvent({
      guildId: "guild-123",
      botAsAuthor: true,
      guild: { name: "Test Guild" },
    });
    const client = makeReactionClient(ChannelType.GuildText);
    const listener = new DiscordReactionListener(makeReactionListenerParams());

    await listener.handle(data, client);

    expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
    const [text] = enqueueSystemEventSpy.mock.calls[0];
    expect(text).toContain("Discord reaction added");
  });

  it("uses 'dm' in log text for DM reactions, not 'undefined'", async () => {
    enqueueSystemEventSpy.mockClear();
    resolveAgentRouteMock.mockClear();

    const data = makeReactionEvent({ botAsAuthor: true });
    const client = makeReactionClient(ChannelType.DM);
    const listener = new DiscordReactionListener(makeReactionListenerParams());

    await listener.handle(data, client);

    expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
    const [text] = enqueueSystemEventSpy.mock.calls[0];
    expect(text).toContain("dm");
    expect(text).not.toContain("undefined");
  });

  it("routes DM reactions with peer kind 'direct' and user id", async () => {
    enqueueSystemEventSpy.mockClear();
    resolveAgentRouteMock.mockClear();

    const data = makeReactionEvent({ userId: "user-42", botAsAuthor: true });
    const client = makeReactionClient(ChannelType.DM);
    const listener = new DiscordReactionListener(makeReactionListenerParams());

    await listener.handle(data, client);

    expect(resolveAgentRouteMock).toHaveBeenCalledOnce();
    const routeArgs = resolveAgentRouteMock.mock.calls[0][0];
    expect(routeArgs.peer).toEqual({ kind: "direct", id: "user-42" });
  });

  it("routes group DM reactions with peer kind 'group'", async () => {
    enqueueSystemEventSpy.mockClear();
    resolveAgentRouteMock.mockClear();

    const data = makeReactionEvent({ botAsAuthor: true });
    const client = makeReactionClient(ChannelType.GroupDM);
    const listener = new DiscordReactionListener(makeReactionListenerParams());

    await listener.handle(data, client);

    expect(resolveAgentRouteMock).toHaveBeenCalledOnce();
    const routeArgs = resolveAgentRouteMock.mock.calls[0][0];
    expect(routeArgs.peer).toEqual({ kind: "group", id: "channel-1" });
  });
});
