import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
  discordOutbound,
  imessageOutbound,
  signalOutbound,
  slackOutbound,
  telegramOutbound,
  whatsappOutbound,
} from "../../../test/channel-outbounds.js";
import type {
  ChannelMessagingAdapter,
  ChannelOutboundAdapter,
  ChannelPlugin,
  ChannelThreadingAdapter,
} from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";

const mocks = vi.hoisted(() => ({
  sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
  sendMessageIMessage: vi.fn(async () => ({ messageId: "ok" })),
  sendMessageMSTeams: vi.fn(async (_params: unknown) => ({
    messageId: "m1",
    conversationId: "c1",
  })),
  sendMessageSignal: vi.fn(async () => ({ messageId: "t1" })),
  sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
  sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
  sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1", toJid: "jid" })),
  sendMessageMattermost: vi.fn(async (..._args: unknown[]) => ({
    messageId: "m1",
    channelId: "c1",
  })),
  deliverOutboundPayloads: vi.fn(),
}));

vi.mock("../../../extensions/discord/src/send.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../../../extensions/discord/src/send.js")>();
  return {
    ...actual,
    sendMessageDiscord: mocks.sendMessageDiscord,
    sendPollDiscord: mocks.sendMessageDiscord,
    sendWebhookMessageDiscord: vi.fn(),
  };
});
vi.mock("../../../extensions/imessage/src/send.js", () => ({
  sendMessageIMessage: mocks.sendMessageIMessage,
}));
vi.mock("../../../extensions/signal/src/send.js", () => ({
  sendMessageSignal: mocks.sendMessageSignal,
}));
vi.mock("../../../extensions/slack/src/send.js", () => ({
  sendMessageSlack: mocks.sendMessageSlack,
}));
vi.mock("../../../extensions/telegram/src/send.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../../../extensions/telegram/src/send.js")>();
  return {
    ...actual,
    sendMessageTelegram: mocks.sendMessageTelegram,
  };
});
vi.mock("../../../extensions/whatsapp/src/send.js", () => ({
  sendMessageWhatsApp: mocks.sendMessageWhatsApp,
  sendPollWhatsApp: mocks.sendMessageWhatsApp,
}));
vi.mock("../../../extensions/mattermost/src/mattermost/send.js", () => ({
  sendMessageMattermost: mocks.sendMessageMattermost,
}));
vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
  const actual = await vi.importActual<typeof import("../../infra/outbound/deliver-runtime.js")>(
    "../../infra/outbound/deliver-runtime.js",
  );
  return {
    ...actual,
    deliverOutboundPayloads: mocks.deliverOutboundPayloads,
  };
});
const actualDeliver = await vi.importActual<
  typeof import("../../infra/outbound/deliver-runtime.js")
>("../../infra/outbound/deliver-runtime.js");

const { routeReply } = await import("./route-reply.js");

const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
  plugins: [],
  tools: [],
  hooks: [],
  typedHooks: [],
  commands: [],
  channels,
  channelSetups: channels.map((entry) => ({
    pluginId: entry.pluginId,
    plugin: entry.plugin,
    source: entry.source,
    enabled: true,
  })),
  providers: [],
  speechProviders: [],
  mediaUnderstandingProviders: [],
  imageGenerationProviders: [],
  webSearchProviders: [],
  gatewayHandlers: {},
  httpRoutes: [],
  cliRegistrars: [],
  services: [],
  conversationBindingResolvedHandlers: [],
  diagnostics: [],
});

const createMSTeamsOutbound = (): ChannelOutboundAdapter => ({
  deliveryMode: "direct",
  sendText: async ({ cfg, to, text }) => {
    const result = await mocks.sendMessageMSTeams({ cfg, to, text });
    return { channel: "msteams", ...result };
  },
  sendMedia: async ({ cfg, to, text, mediaUrl }) => {
    const result = await mocks.sendMessageMSTeams({ cfg, to, text, mediaUrl });
    return { channel: "msteams", ...result };
  },
});

const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): ChannelPlugin => ({
  id: "msteams",
  meta: {
    id: "msteams",
    label: "Microsoft Teams",
    selectionLabel: "Microsoft Teams (Bot Framework)",
    docsPath: "/channels/msteams",
    blurb: "Teams SDK; enterprise support.",
  },
  capabilities: { chatTypes: ["direct"] },
  config: {
    listAccountIds: () => [],
    resolveAccount: () => ({}),
  },
  outbound: params.outbound,
});

const slackMessaging: ChannelMessagingAdapter = {
  enableInteractiveReplies: ({ cfg }) =>
    (cfg.channels?.slack as { capabilities?: { interactiveReplies?: boolean } } | undefined)
      ?.capabilities?.interactiveReplies === true,
  hasStructuredReplyPayload: ({ payload }) => {
    const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks;
    if (typeof blocks === "string") {
      return blocks.trim().length > 0;
    }
    return Array.isArray(blocks) && blocks.length > 0;
  },
};

const slackThreading: ChannelThreadingAdapter = {
  resolveReplyTransport: ({ threadId, replyToId }) => ({
    replyToId: replyToId ?? (threadId != null && threadId !== "" ? String(threadId) : undefined),
    threadId: null,
  }),
};

const mattermostOutbound: ChannelOutboundAdapter = {
  deliveryMode: "direct",
  sendText: async ({ to, text, cfg, accountId, replyToId, threadId }) => {
    const result = await mocks.sendMessageMattermost(to, text, {
      cfg,
      accountId: accountId ?? undefined,
      replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
    });
    return { channel: "mattermost", ...result };
  },
  sendMedia: async ({ to, text, cfg, accountId, replyToId, threadId, mediaUrl }) => {
    const result = await mocks.sendMessageMattermost(to, text, {
      cfg,
      accountId: accountId ?? undefined,
      replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
      mediaUrl,
    });
    return { channel: "mattermost", ...result };
  },
};

async function expectSlackNoSend(
  payload: Parameters<typeof routeReply>[0]["payload"],
  overrides: Partial<Parameters<typeof routeReply>[0]> = {},
) {
  mocks.sendMessageSlack.mockClear();
  const res = await routeReply({
    payload,
    channel: "slack",
    to: "channel:C123",
    cfg: {} as never,
    ...overrides,
  });
  expect(res.ok).toBe(true);
  expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
  return res;
}

describe("routeReply", () => {
  beforeEach(() => {
    setActivePluginRegistry(defaultRegistry);
    mocks.deliverOutboundPayloads.mockImplementation(actualDeliver.deliverOutboundPayloads);
  });

  afterEach(() => {
    setActivePluginRegistry(emptyRegistry);
  });

  it("skips sends when abort signal is already aborted", async () => {
    mocks.sendMessageSlack.mockClear();
    const controller = new AbortController();
    controller.abort();
    const res = await routeReply({
      payload: { text: "hi" },
      channel: "slack",
      to: "channel:C123",
      cfg: {} as never,
      abortSignal: controller.signal,
    });
    expect(res.ok).toBe(false);
    expect(res.error).toContain("aborted");
    expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
  });

  it("no-ops on empty payload", async () => {
    await expectSlackNoSend({});
  });

  it("suppresses reasoning payloads", async () => {
    await expectSlackNoSend({ text: "Reasoning:\n_step_", isReasoning: true });
  });

  it("drops silent token payloads", async () => {
    await expectSlackNoSend({ text: SILENT_REPLY_TOKEN });
  });

  it("does not drop payloads that merely start with the silent token", async () => {
    mocks.sendMessageSlack.mockClear();
    const res = await routeReply({
      payload: { text: `${SILENT_REPLY_TOKEN} -- (why am I here?)` },
      channel: "slack",
      to: "channel:C123",
      cfg: {} as never,
    });
    expect(res.ok).toBe(true);
    expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
      "channel:C123",
      `${SILENT_REPLY_TOKEN} -- (why am I here?)`,
      expect.any(Object),
    );
  });

  it("applies responsePrefix when routing", async () => {
    mocks.sendMessageSlack.mockClear();
    const cfg = {
      messages: { responsePrefix: "[openclaw]" },
    } as unknown as OpenClawConfig;
    await routeReply({
      payload: { text: "hi" },
      channel: "slack",
      to: "channel:C123",
      cfg,
    });
    expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
      "channel:C123",
      "[openclaw] hi",
      expect.any(Object),
    );
  });

  it("routes directive-only Slack replies when interactive replies are enabled", async () => {
    mocks.sendMessageSlack.mockClear();
    const cfg = {
      channels: {
        slack: {
          capabilities: { interactiveReplies: true },
        },
      },
    } as unknown as OpenClawConfig;
    await routeReply({
      payload: { text: "[[slack_select: Choose one | Alpha:alpha]]" },
      channel: "slack",
      to: "channel:C123",
      cfg,
    });
    expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
      "channel:C123",
      "",
      expect.objectContaining({
        blocks: [
          expect.objectContaining({
            type: "actions",
            block_id: "openclaw_reply_select_1",
          }),
        ],
      }),
    );
  });

  it("does not bypass the empty-reply guard for invalid Slack blocks", async () => {
    await expectSlackNoSend({
      text: " ",
      channelData: {
        slack: {
          blocks: " ",
        },
      },
    });
  });

  it("does not derive responsePrefix from agent identity when routing", async () => {
    mocks.sendMessageSlack.mockClear();
    const cfg = {
      agents: {
        list: [
          {
            id: "rich",
            identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
          },
        ],
      },
      messages: {},
    } as unknown as OpenClawConfig;
    await routeReply({
      payload: { text: "hi" },
      channel: "slack",
      to: "channel:C123",
      sessionKey: "agent:rich:main",
      cfg,
    });
    expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
  });

  it("uses threadId for Slack when replyToId is missing", async () => {
    mocks.sendMessageSlack.mockClear();
    await routeReply({
      payload: { text: "hi" },
      channel: "slack",
      to: "channel:C123",
      threadId: "456.789",
      cfg: {} as never,
    });
    expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
      "channel:C123",
      "hi",
      expect.objectContaining({ threadTs: "456.789" }),
    );
  });

  it("passes thread id to Telegram sends", async () => {
    mocks.deliverOutboundPayloads.mockResolvedValue([]);
    await routeReply({
      payload: { text: "hi" },
      channel: "telegram",
      to: "telegram:123",
      threadId: 42,
      cfg: {} as never,
    });
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "telegram",
        to: "telegram:123",
        threadId: 42,
      }),
    );
  });

  it("formats BTW replies prominently on routed sends", async () => {
    mocks.sendMessageSlack.mockClear();
    await routeReply({
      payload: { text: "323", btw: { question: "what is 17 * 19?" } },
      channel: "slack",
      to: "channel:C123",
      cfg: {} as never,
    });
    expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
      "channel:C123",
      "BTW\nQuestion: what is 17 * 19?\n\n323",
      expect.any(Object),
    );
  });

  it("formats BTW replies prominently on routed discord sends", async () => {
    mocks.sendMessageDiscord.mockClear();
    await routeReply({
      payload: { text: "323", btw: { question: "what is 17 * 19?" } },
      channel: "discord",
      to: "channel:123456",
      cfg: {} as never,
    });
    expect(mocks.sendMessageDiscord).toHaveBeenCalledWith(
      "channel:123456",
      "BTW\nQuestion: what is 17 * 19?\n\n323",
      expect.any(Object),
    );
  });

  it("passes replyToId to Telegram sends", async () => {
    mocks.deliverOutboundPayloads.mockResolvedValue([]);
    await routeReply({
      payload: { text: "hi", replyToId: "123" },
      channel: "telegram",
      to: "telegram:123",
      cfg: {} as never,
    });
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "telegram",
        to: "telegram:123",
        replyToId: "123",
      }),
    );
  });

  it("preserves audioAsVoice on routed outbound payloads", async () => {
    mocks.deliverOutboundPayloads.mockClear();
    mocks.deliverOutboundPayloads.mockResolvedValue([]);
    await routeReply({
      payload: { text: "voice caption", mediaUrl: "file:///tmp/clip.mp3", audioAsVoice: true },
      channel: "slack",
      to: "channel:C123",
      cfg: {} as never,
    });
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledTimes(1);
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "slack",
        to: "channel:C123",
        payloads: [
          expect.objectContaining({
            text: "voice caption",
            mediaUrl: "file:///tmp/clip.mp3",
            audioAsVoice: true,
          }),
        ],
      }),
    );
  });

  it("uses replyToId as threadTs for Slack", async () => {
    mocks.sendMessageSlack.mockClear();
    await routeReply({
      payload: { text: "hi", replyToId: "1710000000.0001" },
      channel: "slack",
      to: "channel:C123",
      cfg: {} as never,
    });
    expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
      "channel:C123",
      "hi",
      expect.objectContaining({ threadTs: "1710000000.0001" }),
    );
  });

  it("uses threadId as threadTs for Slack when replyToId is missing", async () => {
    mocks.sendMessageSlack.mockClear();
    await routeReply({
      payload: { text: "hi" },
      channel: "slack",
      to: "channel:C123",
      threadId: "1710000000.9999",
      cfg: {} as never,
    });
    expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
      "channel:C123",
      "hi",
      expect.objectContaining({ threadTs: "1710000000.9999" }),
    );
  });

  it("uses threadId as replyToId for Mattermost when replyToId is missing", async () => {
    mocks.deliverOutboundPayloads.mockResolvedValue([]);
    await routeReply({
      payload: { text: "hi" },
      channel: "mattermost",
      to: "channel:CHAN1",
      threadId: "post-root",
      cfg: {
        channels: {
          mattermost: {
            enabled: true,
            botToken: "test-token",
            baseUrl: "https://chat.example.com",
          },
        },
      } as unknown as OpenClawConfig,
    });
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "mattermost",
        to: "channel:CHAN1",
        replyToId: "post-root",
        threadId: "post-root",
      }),
    );
  });

  it("sends multiple mediaUrls (caption only on first)", async () => {
    mocks.sendMessageSlack.mockClear();
    await routeReply({
      payload: { text: "caption", mediaUrls: ["a", "b"] },
      channel: "slack",
      to: "channel:C123",
      cfg: {} as never,
    });
    expect(mocks.sendMessageSlack).toHaveBeenCalledTimes(2);
    expect(mocks.sendMessageSlack).toHaveBeenNthCalledWith(
      1,
      "channel:C123",
      "caption",
      expect.objectContaining({ mediaUrl: "a" }),
    );
    expect(mocks.sendMessageSlack).toHaveBeenNthCalledWith(
      2,
      "channel:C123",
      "",
      expect.objectContaining({ mediaUrl: "b" }),
    );
  });

  it("routes WhatsApp via outbound sender (accountId honored)", async () => {
    mocks.sendMessageWhatsApp.mockClear();
    await routeReply({
      payload: { text: "hi" },
      channel: "whatsapp",
      to: "+15551234567",
      accountId: "acc-1",
      cfg: {} as never,
    });
    expect(mocks.sendMessageWhatsApp).toHaveBeenCalledWith(
      "+15551234567",
      "hi",
      expect.objectContaining({ accountId: "acc-1", verbose: false }),
    );
  });

  it("routes MS Teams via proactive sender", async () => {
    mocks.sendMessageMSTeams.mockClear();
    setActivePluginRegistry(
      createRegistry([
        {
          pluginId: "msteams",
          source: "test",
          plugin: createMSTeamsPlugin({
            outbound: createMSTeamsOutbound(),
          }),
        },
      ]),
    );
    const cfg = {
      channels: {
        msteams: {
          enabled: true,
        },
      },
    } as unknown as OpenClawConfig;
    await routeReply({
      payload: { text: "hi" },
      channel: "msteams",
      to: "conversation:19:abc@thread.tacv2",
      cfg,
    });
    expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith(
      expect.objectContaining({
        cfg,
        to: "conversation:19:abc@thread.tacv2",
        text: "hi",
      }),
    );
  });

  it("passes mirror data when sessionKey is set", async () => {
    mocks.deliverOutboundPayloads.mockResolvedValue([]);
    await routeReply({
      payload: { text: "hi" },
      channel: "slack",
      to: "channel:C123",
      sessionKey: "agent:main:main",
      isGroup: true,
      groupId: "channel:C123",
      cfg: {} as never,
    });
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        mirror: expect.objectContaining({
          sessionKey: "agent:main:main",
          text: "hi",
          isGroup: true,
          groupId: "channel:C123",
        }),
      }),
    );
  });

  it("skips mirror data when mirror is false", async () => {
    mocks.deliverOutboundPayloads.mockResolvedValue([]);
    await routeReply({
      payload: { text: "hi" },
      channel: "slack",
      to: "channel:C123",
      sessionKey: "agent:main:main",
      mirror: false,
      cfg: {} as never,
    });
    expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
      expect.objectContaining({
        mirror: undefined,
      }),
    );
  });
});

const emptyRegistry = createRegistry([]);
const defaultRegistry = createTestRegistry([
  {
    pluginId: "discord",
    plugin: createOutboundTestPlugin({
      id: "discord",
      outbound: discordOutbound,
      label: "Discord",
    }),
    source: "test",
  },
  {
    pluginId: "slack",
    plugin: {
      ...createOutboundTestPlugin({ id: "slack", outbound: slackOutbound, label: "Slack" }),
      messaging: slackMessaging,
      threading: slackThreading,
    },
    source: "test",
  },
  {
    pluginId: "telegram",
    plugin: createOutboundTestPlugin({
      id: "telegram",
      outbound: telegramOutbound,
      label: "Telegram",
    }),
    source: "test",
  },
  {
    pluginId: "whatsapp",
    plugin: createOutboundTestPlugin({
      id: "whatsapp",
      outbound: whatsappOutbound,
      label: "WhatsApp",
    }),
    source: "test",
  },
  {
    pluginId: "signal",
    plugin: createOutboundTestPlugin({ id: "signal", outbound: signalOutbound, label: "Signal" }),
    source: "test",
  },
  {
    pluginId: "imessage",
    plugin: createIMessageTestPlugin({ outbound: imessageOutbound }),
    source: "test",
  },
  {
    pluginId: "msteams",
    plugin: createMSTeamsPlugin({
      outbound: createMSTeamsOutbound(),
    }),
    source: "test",
  },
  {
    pluginId: "mattermost",
    plugin: createOutboundTestPlugin({
      id: "mattermost",
      outbound: mattermostOutbound,
      label: "Mattermost",
    }),
    source: "test",
  },
]);
