import { ChannelType, type Guild } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { typedCases } from "../../../test/helpers/plugins/typed-cases.js";
import {
  allowListMatches,
  buildDiscordMediaPayload,
  type DiscordGuildEntryResolved,
  isDiscordGroupAllowedByPolicy,
  normalizeDiscordAllowList,
  normalizeDiscordSlug,
  registerDiscordListener,
  resolveDiscordChannelConfig,
  resolveDiscordChannelConfigWithFallback,
  resolveDiscordGuildEntry,
  resolveDiscordReplyTarget,
  resolveDiscordShouldRequireMention,
  resolveGroupDmAllow,
  sanitizeDiscordThreadName,
  shouldEmitDiscordReactionNotification,
} from "./monitor.js";
type DiscordReactionEvent = Parameters<
  import("./monitor/listeners.js").DiscordReactionListener["handle"]
>[0];
type DiscordReactionClient = Parameters<
  import("./monitor/listeners.js").DiscordReactionListener["handle"]
>[1];

const readAllowFromStoreMock = vi.hoisted(() => vi.fn());

vi.mock("../../../src/pairing/pairing-store.js", () => ({
  readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
}));

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,
      roles: value.roles,
      channels: value.channels,
    };
  }
  return out;
};

function createAutoThreadMentionContext() {
  const guildInfo: DiscordGuildEntryResolved = {
    requireMention: true,
    channels: {
      general: { enabled: true, autoThread: true },
    },
  };
  const channelConfig = resolveDiscordChannelConfig({
    guildInfo,
    channelId: "1",
    channelName: "General",
    channelSlug: "general",
  });
  return { guildInfo, channelConfig };
}

beforeEach(() => {
  vi.useRealTimers();
  readAllowFromStoreMock.mockReset().mockResolvedValue([]);
});

describe("registerDiscordListener", () => {
  class FakeListener {
    readonly testListener = true;
  }

  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", () => {
  function createDeferred() {
    let resolve: (() => void) | null = null;
    const promise = new Promise<void>((done) => {
      resolve = done;
    });
    return {
      promise,
      resolve: () => {
        if (typeof resolve === "function") {
          (resolve as () => void)();
        }
      },
    };
  }

  async function flushAsyncWork() {
    await Promise.resolve();
    await Promise.resolve();
  }

  it("returns immediately while handler continues in background", async () => {
    let handlerResolved = false;
    const deferred = createDeferred();
    const handler = vi.fn(async () => {
      await deferred.promise;
      handlerResolved = true;
    });
    const listener = new DiscordMessageListener(handler);

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

    // handle() returns immediately while the background queue starts on the next tick.
    await expect(handlePromise).resolves.toBeUndefined();
    await flushAsyncWork();
    expect(handler).toHaveBeenCalledOnce();
    expect(handlerResolved).toBe(false);

    // Release and let background handler finish.
    deferred.resolve();
    await Promise.resolve();
    expect(handlerResolved).toBe(true);
  });

  it("dispatches subsequent events concurrently without blocking on prior handler", async () => {
    const first = createDeferred();
    const second = createDeferred();
    let runCount = 0;
    const handler = vi.fn(async () => {
      runCount += 1;
      if (runCount === 1) {
        await first.promise;
        return;
      }
      await second.promise;
    });
    const listener = new DiscordMessageListener(handler);

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

    // Both handlers are dispatched concurrently (fire-and-forget).
    await flushAsyncWork();
    expect(handler).toHaveBeenCalledTimes(2);

    first.resolve();
    second.resolve();
    await Promise.resolve();
  });

  it("logs handler failures", async () => {
    const logger = {
      warn: vi.fn(),
      error: vi.fn(),
    } as unknown as ReturnType<
      typeof import("openclaw/plugin-sdk/logging-core").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 flushAsyncWork();
    expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("discord handler failed"));
  });

  it("does not apply its own slow-listener logging (owned by inbound worker)", async () => {
    const deferred = createDeferred();
    const handler = vi.fn(() => deferred.promise);
    const logger = {
      warn: vi.fn(),
      error: vi.fn(),
    } as unknown as ReturnType<
      typeof import("openclaw/plugin-sdk/logging-core").createSubsystemLogger
    >;
    const listener = new DiscordMessageListener(handler, logger);

    const handlePromise = listener.handle(
      {} as unknown as import("./monitor/listeners.js").DiscordMessageEvent,
      {} as unknown as import("@buape/carbon").Client,
    );
    await expect(handlePromise).resolves.toBeUndefined();

    deferred.resolve();
    await flushAsyncWork();
    expect(handler).toHaveBeenCalledOnce();
    // The listener no longer wraps handlers with slow-listener logging;
    // that responsibility moved to the inbound worker.
    expect(logger.warn).not.toHaveBeenCalled();
  });
});

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 by default and names only when enabled", () => {
    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(false);
    expect(allowListMatches(allow, { name: "friends-of-openclaw" })).toBe(false);
    expect(allowListMatches(allow, { name: "steipete" }, { allowNameMatching: true })).toBe(true);
    expect(
      allowListMatches(allow, { name: "friends-of-openclaw" }, { allowNameMatching: true }),
    ).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 raw guild id when guild object is missing", () => {
    const guildEntries = makeEntries({
      "123": { slug: "friends-of-openclaw" },
    });
    const resolved = resolveDiscordGuildEntry({
      guildId: "123",
      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: { enabled: true },
        help: {
          enabled: true,
          requireMention: true,
          skills: ["search"],
          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(true);
    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: { enabled: true },
      },
    };
    const channel = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "999",
      channelName: "random",
      channelSlug: "random",
    });
    expect(channel?.allowed).toBe(false);
  });

  it("treats empty channel config map as no channel allowlist", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {},
    };
    const channel = resolveDiscordChannelConfig({
      guildInfo,
      channelId: "999",
      channelName: "random",
      channelSlug: "random",
    });
    expect(channel).toBeNull();
  });

  it("inherits parent config for thread channels", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {
        general: { enabled: true },
        random: { enabled: 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: { enabled: true },
        random: { enabled: 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: { enabled: true, requireMention: false },
        "*": { enabled: 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: {
        "*": { enabled: 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");
  });

  it("treats empty channel config map as no thread allowlist", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      channels: {},
    };
    const thread = resolveDiscordChannelConfigWithFallback({
      guildInfo,
      channelId: "thread-123",
      channelName: "topic",
      channelSlug: "topic",
      parentId: "parent-999",
      parentName: "general",
      parentSlug: "general",
      scope: "thread",
    });
    expect(thread).toBeNull();
  });
});

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

  it("applies autoThread mention rules based on thread ownership", () => {
    const cases = [
      { name: "bot-owned thread", threadOwnerId: "bot123", expected: false },
      { name: "user-owned thread", threadOwnerId: "user456", expected: true },
      { name: "unknown thread owner", threadOwnerId: undefined, expected: true },
    ] as const;

    for (const testCase of cases) {
      const { guildInfo, channelConfig } = createAutoThreadMentionContext();
      expect(
        resolveDiscordShouldRequireMention({
          isGuildMessage: true,
          isThread: true,
          botId: "bot123",
          threadOwnerId: testCase.threadOwnerId,
          channelConfig,
          guildInfo,
        }),
        testCase.name,
      ).toBe(testCase.expected);
    }
  });

  it("inherits parent channel mention rules for threads", () => {
    const guildInfo: DiscordGuildEntryResolved = {
      requireMention: true,
      channels: {
        "parent-1": { enabled: 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("applies open/disabled/allowlist policy rules", () => {
    const cases = [
      {
        name: "open policy always allows",
        input: {
          groupPolicy: "open" as const,
          guildAllowlisted: false,
          channelAllowlistConfigured: false,
          channelAllowed: false,
        },
        expected: true,
      },
      {
        name: "disabled policy always blocks",
        input: {
          groupPolicy: "disabled" as const,
          guildAllowlisted: true,
          channelAllowlistConfigured: true,
          channelAllowed: true,
        },
        expected: false,
      },
      {
        name: "allowlist blocks when guild not allowlisted",
        input: {
          groupPolicy: "allowlist" as const,
          guildAllowlisted: false,
          channelAllowlistConfigured: false,
          channelAllowed: true,
        },
        expected: false,
      },
      {
        name: "allowlist allows when guild allowlisted and no channel allowlist",
        input: {
          groupPolicy: "allowlist" as const,
          guildAllowlisted: true,
          channelAllowlistConfigured: false,
          channelAllowed: true,
        },
        expected: true,
      },
      {
        name: "allowlist allows when channel is allowed",
        input: {
          groupPolicy: "allowlist" as const,
          guildAllowlisted: true,
          channelAllowlistConfigured: true,
          channelAllowed: true,
        },
        expected: true,
      },
      {
        name: "allowlist blocks when channel is not allowed",
        input: {
          groupPolicy: "allowlist" as const,
          guildAllowlisted: true,
          channelAllowlistConfigured: true,
          channelAllowed: false,
        },
        expected: false,
      },
    ] as const;

    for (const testCase of cases) {
      expect(isDiscordGroupAllowedByPolicy(testCase.input), testCase.name).toBe(testCase.expected);
    }
  });
});

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("handles off/first/all reply modes", () => {
    const cases = [
      { name: "off mode", replyToMode: "off" as const, hasReplied: false, expected: undefined },
      {
        name: "first mode before reply",
        replyToMode: "first" as const,
        hasReplied: false,
        expected: "123",
      },
      {
        name: "first mode after reply",
        replyToMode: "first" as const,
        hasReplied: true,
        expected: undefined,
      },
      {
        name: "all mode before reply",
        replyToMode: "all" as const,
        hasReplied: false,
        expected: "123",
      },
      {
        name: "all mode after reply",
        replyToMode: "all" as const,
        hasReplied: true,
        expected: "123",
      },
    ] as const;

    for (const testCase of cases) {
      expect(
        resolveDiscordReplyTarget({
          replyToMode: testCase.replyToMode,
          replyToId: "123",
          hasReplied: testCase.hasReplied,
        }),
        testCase.name,
      ).toBe(testCase.expected);
    }
  });
});

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("applies mode-specific reaction notification rules", () => {
    const cases = typedCases<{
      name: string;
      input: Parameters<typeof shouldEmitDiscordReactionNotification>[0];
      expected: boolean;
    }>([
      {
        name: "unset defaults to own (author is bot)",
        input: {
          mode: undefined,
          botId: "bot-1",
          messageAuthorId: "bot-1",
          userId: "user-1",
        },
        expected: true,
      },
      {
        name: "unset defaults to own (author is not bot)",
        input: {
          mode: undefined,
          botId: "bot-1",
          messageAuthorId: "user-1",
          userId: "user-2",
        },
        expected: false,
      },
      {
        name: "off mode",
        input: {
          mode: "off" as const,
          botId: "bot-1",
          messageAuthorId: "bot-1",
          userId: "user-1",
        },
        expected: false,
      },
      {
        name: "all mode",
        input: {
          mode: "all" as const,
          botId: "bot-1",
          messageAuthorId: "user-1",
          userId: "user-2",
        },
        expected: true,
      },
      {
        name: "all mode blocks non-allowlisted guild member",
        input: {
          mode: "all" as const,
          botId: "bot-1",
          messageAuthorId: "user-1",
          userId: "user-2",
          guildInfo: { users: ["trusted-user"] },
        },
        expected: false,
      },
      {
        name: "own mode with bot-authored message",
        input: {
          mode: "own" as const,
          botId: "bot-1",
          messageAuthorId: "bot-1",
          userId: "user-2",
        },
        expected: true,
      },
      {
        name: "own mode with non-bot-authored message",
        input: {
          mode: "own" as const,
          botId: "bot-1",
          messageAuthorId: "user-2",
          userId: "user-3",
        },
        expected: false,
      },
      {
        name: "own mode still blocks member outside users allowlist",
        input: {
          mode: "own" as const,
          botId: "bot-1",
          messageAuthorId: "bot-1",
          userId: "user-3",
          guildInfo: { users: ["trusted-user"] },
        },
        expected: false,
      },
      {
        name: "allowlist mode without match",
        input: {
          mode: "allowlist" as const,
          botId: "bot-1",
          messageAuthorId: "user-1",
          userId: "user-2",
          allowlist: [] as string[],
        },
        expected: false,
      },
      {
        name: "allowlist mode with id match",
        input: {
          mode: "allowlist" as const,
          botId: "bot-1",
          messageAuthorId: "user-1",
          userId: "123",
          userName: "steipete",
          guildInfo: { users: ["123", "other"] },
        },
        expected: true,
      },
      {
        name: "allowlist mode does not match usernames by default",
        input: {
          mode: "allowlist" as const,
          botId: "bot-1",
          messageAuthorId: "user-1",
          userId: "999",
          userName: "trusted-user",
          guildInfo: { users: ["trusted-user"] },
        },
        expected: false,
      },
      {
        name: "allowlist mode matches usernames when explicitly enabled",
        input: {
          mode: "allowlist" as const,
          botId: "bot-1",
          messageAuthorId: "user-1",
          userId: "999",
          userName: "trusted-user",
          guildInfo: { users: ["trusted-user"] },
          allowNameMatching: true,
        },
        expected: true,
      },
      {
        name: "allowlist mode matches allowed role",
        input: {
          mode: "allowlist" as const,
          botId: "bot-1",
          messageAuthorId: "user-1",
          userId: "999",
          guildInfo: { roles: ["role:trusted-role"] },
          memberRoleIds: ["trusted-role"],
        },
        expected: true,
      },
    ]);

    for (const testCase of cases) {
      expect(
        shouldEmitDiscordReactionNotification({
          ...testCase.input,
        }),
        testCase.name,
      ).toBe(testCase.expected);
    }
  });
});

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((params: unknown) => ({
    agentId: "default",
    channel: "discord",
    accountId: "acc-1",
    sessionKey: "discord:acc-1:dm:user-1",
    mainSessionKey: "discord:acc-1:dm:user-1",
    lastRoutePolicy: "session" as const,
    matchedBy: "default" as const,
    ...(typeof params === "object" && params !== null ? { _params: params } : {}),
  })),
}));

const channelRuntimeModule = await import("openclaw/plugin-sdk/infra-runtime");
vi.spyOn(channelRuntimeModule, "enqueueSystemEvent").mockImplementation(enqueueSystemEventSpy);

const routingModule = await import("openclaw/plugin-sdk/routing");
vi.spyOn(routingModule, "resolveAgentRoute").mockImplementation(resolveAgentRouteMock);

const { DiscordMessageListener, DiscordReactionListener } = await import("./monitor/listeners.js");

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

function makeReactionClient(options?: {
  channelType?: ChannelType;
  channelName?: string;
  parentId?: string;
  parentName?: string;
}) {
  const channelType = options?.channelType ?? ChannelType.DM;
  const channelName =
    options?.channelName ?? (channelType === ChannelType.DM ? undefined : "test-channel");
  const parentId = options?.parentId;
  const parentName = options?.parentName ?? "parent-channel";

  return {
    fetchChannel: vi.fn(async (channelId: string) => {
      if (parentId && channelId === parentId) {
        return { type: ChannelType.GuildText, name: parentName, parentId: undefined };
      }
      return { type: channelType, name: channelName, parentId };
    }),
  } as unknown as DiscordReactionClient;
}

function makeReactionListenerParams(overrides?: {
  botUserId?: string;
  dmEnabled?: boolean;
  groupDmEnabled?: boolean;
  groupDmChannels?: string[];
  dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
  allowFrom?: string[];
  groupPolicy?: "open" | "allowlist" | "disabled";
  allowNameMatching?: boolean;
  guildEntries?: Record<string, DiscordGuildEntryResolved>;
}) {
  return {
    cfg: {} as ReturnType<typeof import("openclaw/plugin-sdk/config-runtime").loadConfig>,
    accountId: "acc-1",
    runtime: {} as import("openclaw/plugin-sdk/runtime-env").RuntimeEnv,
    botUserId: overrides?.botUserId ?? "bot-1",
    dmEnabled: overrides?.dmEnabled ?? true,
    groupDmEnabled: overrides?.groupDmEnabled ?? true,
    groupDmChannels: overrides?.groupDmChannels ?? [],
    dmPolicy: overrides?.dmPolicy ?? "open",
    allowFrom: overrides?.allowFrom ?? [],
    groupPolicy: overrides?.groupPolicy ?? "open",
    allowNameMatching: overrides?.allowNameMatching ?? false,
    guildEntries: overrides?.guildEntries,
    logger: {
      info: vi.fn(),
      warn: vi.fn(),
      error: vi.fn(),
      debug: vi.fn(),
    } as unknown as ReturnType<
      typeof import("openclaw/plugin-sdk/logging-core").createSubsystemLogger
    >,
  };
}

describe("discord DM reaction handling", () => {
  beforeEach(() => {
    enqueueSystemEventSpy.mockClear();
    resolveAgentRouteMock.mockClear();
    readAllowFromStoreMock.mockReset().mockResolvedValue([]);
  });

  it("processes DM reactions with or without guild allowlists", async () => {
    const cases = [
      { name: "no guild allowlist", guildEntries: undefined },
      {
        name: "guild allowlist configured",
        guildEntries: makeEntries({
          "guild-123": { slug: "guild-123" },
        }),
      },
    ] as const;

    for (const testCase of cases) {
      enqueueSystemEventSpy.mockClear();
      resolveAgentRouteMock.mockClear();

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

      await listener.handle(data, client);

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

  it("blocks DM reactions when dmPolicy is disabled", async () => {
    const data = makeReactionEvent({ botAsAuthor: true });
    const client = makeReactionClient({ channelType: ChannelType.DM });
    const listener = new DiscordReactionListener(
      makeReactionListenerParams({ dmPolicy: "disabled" }),
    );

    await listener.handle(data, client);

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

  it("blocks DM reactions for unauthorized sender in allowlist mode", async () => {
    const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" });
    const client = makeReactionClient({ channelType: ChannelType.DM });
    const listener = new DiscordReactionListener(
      makeReactionListenerParams({
        dmPolicy: "allowlist",
        allowFrom: ["user:user-2"],
      }),
    );

    await listener.handle(data, client);

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

  it("allows DM reactions for authorized sender in allowlist mode", async () => {
    const data = makeReactionEvent({ botAsAuthor: true, userId: "user-1" });
    const client = makeReactionClient({ channelType: ChannelType.DM });
    const listener = new DiscordReactionListener(
      makeReactionListenerParams({
        dmPolicy: "allowlist",
        allowFrom: ["user:user-1"],
      }),
    );

    await listener.handle(data, client);

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

  it("blocks group DM reactions when group DMs are disabled", async () => {
    const data = makeReactionEvent({ botAsAuthor: true });
    const client = makeReactionClient({ channelType: ChannelType.GroupDM });
    const listener = new DiscordReactionListener(
      makeReactionListenerParams({ groupDmEnabled: false }),
    );

    await listener.handle(data, client);

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

  it("blocks guild reactions when groupPolicy is disabled", async () => {
    const data = makeReactionEvent({
      guildId: "guild-123",
      botAsAuthor: true,
      guild: { id: "guild-123", name: "Guild" },
    });
    const client = makeReactionClient({ channelType: ChannelType.GuildText });
    const listener = new DiscordReactionListener(
      makeReactionListenerParams({ groupPolicy: "disabled" }),
    );

    await listener.handle(data, client);

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

  it("blocks guild reactions for sender outside users allowlist", async () => {
    const data = makeReactionEvent({
      guildId: "guild-123",
      userId: "attacker-user",
      botAsAuthor: true,
      guild: { id: "guild-123", name: "Test Guild" },
    });
    const client = makeReactionClient({ channelType: ChannelType.GuildText });
    const listener = new DiscordReactionListener(
      makeReactionListenerParams({
        guildEntries: makeEntries({
          "guild-123": {
            users: ["user:trusted-user"],
          },
        }),
      }),
    );

    await listener.handle(data, client);

    expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
    expect(resolveAgentRouteMock).not.toHaveBeenCalled();
  });

  it("allows guild reactions for sender in channel role allowlist override", async () => {
    resolveAgentRouteMock.mockReturnValueOnce({
      agentId: "default",
      channel: "discord",
      accountId: "acc-1",
      sessionKey: "discord:acc-1:guild-123:channel-1",
      mainSessionKey: "discord:acc-1:guild-123:channel-1",
      lastRoutePolicy: "session",
      matchedBy: "default",
    });

    const data = makeReactionEvent({
      guildId: "guild-123",
      userId: "member-user",
      botAsAuthor: true,
      guild: { id: "guild-123", name: "Test Guild" },
      memberRoleIds: ["trusted-role"],
    });
    const client = makeReactionClient({ channelType: ChannelType.GuildText });
    const listener = new DiscordReactionListener(
      makeReactionListenerParams({
        guildEntries: makeEntries({
          "guild-123": {
            roles: ["role:blocked-role"],
            channels: {
              "channel-1": {
                enabled: true,
                roles: ["role:trusted-role"],
              },
            },
          },
        }),
      }),
    );

    await listener.handle(data, client);

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

  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: ChannelType.DM });
    const listener = new DiscordReactionListener(makeReactionListenerParams());

    await listener.handle(data, client);

    expect(resolveAgentRouteMock).toHaveBeenCalledOnce();
    const routeArgs = (resolveAgentRouteMock.mock.calls[0]?.[0] ?? {}) as {
      peer?: unknown;
    };
    if (!routeArgs) {
      throw new Error("expected route arguments");
    }
    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: ChannelType.GroupDM });
    const listener = new DiscordReactionListener(makeReactionListenerParams());

    await listener.handle(data, client);

    expect(resolveAgentRouteMock).toHaveBeenCalledOnce();
    const routeArgs = (resolveAgentRouteMock.mock.calls[0]?.[0] ?? {}) as {
      peer?: unknown;
    };
    if (!routeArgs) {
      throw new Error("expected route arguments");
    }
    expect(routeArgs.peer).toEqual({ kind: "group", id: "channel-1" });
  });
});

describe("discord reaction notification modes", () => {
  const guildId = "guild-900";
  const guild = fakeGuild(guildId, "Mode Guild");

  it("applies message-fetch behavior across notification modes and channel types", async () => {
    const cases = typedCases<{
      name: string;
      reactionNotifications: "off" | "all" | "allowlist" | "own";
      users: string[] | undefined;
      userId: string | undefined;
      channelType: ChannelType;
      channelId: string | undefined;
      parentId: string | undefined;
      messageAuthorId: string;
      expectedMessageFetchCalls: number;
      expectedEnqueueCalls: number;
    }>([
      {
        name: "off mode",
        reactionNotifications: "off" as const,
        users: undefined,
        userId: undefined,
        channelType: ChannelType.GuildText,
        channelId: undefined,
        parentId: undefined,
        messageAuthorId: "other-user",
        expectedMessageFetchCalls: 0,
        expectedEnqueueCalls: 0,
      },
      {
        name: "all mode",
        reactionNotifications: "all" as const,
        users: undefined,
        userId: undefined,
        channelType: ChannelType.GuildText,
        channelId: undefined,
        parentId: undefined,
        messageAuthorId: "other-user",
        expectedMessageFetchCalls: 0,
        expectedEnqueueCalls: 1,
      },
      {
        name: "allowlist mode",
        reactionNotifications: "allowlist" as const,
        users: ["123"] as string[],
        userId: "123",
        channelType: ChannelType.GuildText,
        channelId: undefined,
        parentId: undefined,
        messageAuthorId: "other-user",
        expectedMessageFetchCalls: 0,
        expectedEnqueueCalls: 1,
      },
      {
        name: "own mode",
        reactionNotifications: "own" as const,
        users: undefined,
        userId: undefined,
        channelType: ChannelType.GuildText,
        channelId: undefined,
        parentId: undefined,
        messageAuthorId: "bot-1",
        expectedMessageFetchCalls: 1,
        expectedEnqueueCalls: 1,
      },
      {
        name: "all mode thread channel",
        reactionNotifications: "all" as const,
        users: undefined,
        userId: undefined,
        channelType: ChannelType.PublicThread,
        channelId: "thread-1",
        parentId: "parent-1",
        messageAuthorId: "other-user",
        expectedMessageFetchCalls: 0,
        expectedEnqueueCalls: 1,
      },
    ]);

    for (const testCase of cases) {
      enqueueSystemEventSpy.mockClear();
      resolveAgentRouteMock.mockClear();

      const messageFetch = vi.fn(async () => ({
        author: { id: testCase.messageAuthorId, username: "author", discriminator: "0" },
      }));
      const data = makeReactionEvent({
        guildId,
        guild,
        userId: testCase.userId,
        channelId: testCase.channelId,
        messageFetch,
      });
      const client = makeReactionClient({
        channelType: testCase.channelType,
        parentId: testCase.parentId,
      });
      const guildEntries = makeEntries({
        [guildId]: {
          reactionNotifications: testCase.reactionNotifications,
          users: testCase.users ? [...testCase.users] : undefined,
        },
      });
      const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));

      await listener.handle(data, client);

      expect(messageFetch, testCase.name).toHaveBeenCalledTimes(testCase.expectedMessageFetchCalls);
      expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledTimes(
        testCase.expectedEnqueueCalls,
      );
    }
  });
});
