import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import {
  __testing as threadBindingTesting,
  createThreadBindingManager,
} from "./thread-bindings.js";

const sendMessageDiscordMock = vi.hoisted(() => vi.fn());
const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn());
const sendDiscordTextMock = vi.hoisted(() => vi.fn());

vi.mock("../send.js", () => ({
  sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
  sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args),
  sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args),
}));

vi.mock("../send.shared.js", () => ({
  sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args),
}));

describe("deliverDiscordReply", () => {
  const runtime = {} as RuntimeEnv;
  const cfg = {
    channels: { discord: { token: "test-token" } },
  } as OpenClawConfig;
  const createBoundThreadBindings = async (
    overrides: Partial<{
      threadId: string;
      channelId: string;
      targetSessionKey: string;
      agentId: string;
      label: string;
      webhookId: string;
      webhookToken: string;
      introText: string;
    }> = {},
  ) => {
    const threadBindings = createThreadBindingManager({
      accountId: "default",
      persist: false,
      enableSweeper: false,
    });
    await threadBindings.bindTarget({
      threadId: "thread-1",
      channelId: "parent-1",
      targetKind: "subagent",
      targetSessionKey: "agent:main:subagent:child",
      agentId: "main",
      webhookId: "wh_1",
      webhookToken: "tok_1",
      introText: "",
      ...overrides,
    });
    return threadBindings;
  };

  beforeEach(() => {
    sendMessageDiscordMock.mockClear().mockResolvedValue({
      messageId: "msg-1",
      channelId: "channel-1",
    });
    sendVoiceMessageDiscordMock.mockClear().mockResolvedValue({
      messageId: "voice-1",
      channelId: "channel-1",
    });
    sendWebhookMessageDiscordMock.mockClear().mockResolvedValue({
      messageId: "webhook-1",
      channelId: "thread-1",
    });
    sendDiscordTextMock.mockClear().mockResolvedValue({
      id: "msg-direct-1",
      channel_id: "channel-1",
    });
    threadBindingTesting.resetThreadBindingsForTests();
  });

  it("routes audioAsVoice payloads through the voice API and sends text separately", async () => {
    await deliverDiscordReply({
      replies: [
        {
          text: "Hello there",
          mediaUrls: ["https://example.com/voice.ogg", "https://example.com/extra.mp3"],
          audioAsVoice: true,
        },
      ],
      target: "channel:123",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
      replyToId: "reply-1",
    });

    expect(sendVoiceMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendVoiceMessageDiscordMock).toHaveBeenCalledWith(
      "channel:123",
      "https://example.com/voice.ogg",
      expect.objectContaining({ token: "token", replyTo: "reply-1" }),
    );

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
    expect(sendMessageDiscordMock).toHaveBeenNthCalledWith(
      1,
      "channel:123",
      "Hello there",
      expect.objectContaining({ token: "token", replyTo: "reply-1" }),
    );
    expect(sendMessageDiscordMock).toHaveBeenNthCalledWith(
      2,
      "channel:123",
      "",
      expect.objectContaining({
        token: "token",
        mediaUrl: "https://example.com/extra.mp3",
        replyTo: "reply-1",
      }),
    );
  });

  it("skips follow-up text when the voice payload text is blank", async () => {
    await deliverDiscordReply({
      replies: [
        {
          text: "   ",
          mediaUrl: "https://example.com/voice.ogg",
          audioAsVoice: true,
        },
      ],
      target: "channel:456",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
    });

    expect(sendVoiceMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendMessageDiscordMock).not.toHaveBeenCalled();
  });

  it("passes mediaLocalRoots through media sends", async () => {
    const mediaLocalRoots = ["/tmp/workspace-agent"] as const;
    await deliverDiscordReply({
      replies: [
        {
          text: "Media reply",
          mediaUrls: ["https://example.com/first.png", "https://example.com/second.png"],
        },
      ],
      target: "channel:654",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
      mediaLocalRoots,
    });

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
    expect(sendMessageDiscordMock).toHaveBeenNthCalledWith(
      1,
      "channel:654",
      "Media reply",
      expect.objectContaining({
        token: "token",
        mediaUrl: "https://example.com/first.png",
        mediaLocalRoots,
      }),
    );
    expect(sendMessageDiscordMock).toHaveBeenNthCalledWith(
      2,
      "channel:654",
      "",
      expect.objectContaining({
        token: "token",
        mediaUrl: "https://example.com/second.png",
        mediaLocalRoots,
      }),
    );
  });

  it("forwards cfg to Discord send helpers", async () => {
    await deliverDiscordReply({
      replies: [{ text: "cfg path" }],
      target: "channel:101",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
    });

    expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.cfg).toBe(cfg);
  });

  it("uses replyToId only for the first chunk when replyToMode is first", async () => {
    await deliverDiscordReply({
      replies: [
        {
          text: "1234567890",
        },
      ],
      target: "channel:789",
      token: "token",
      runtime,
      cfg,
      textLimit: 5,
      replyToId: "reply-1",
      replyToMode: "first",
    });

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
    expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.replyTo).toBe("reply-1");
    expect(sendMessageDiscordMock.mock.calls[1]?.[2]?.replyTo).toBeUndefined();
  });

  it("does not consume replyToId for replyToMode=first on whitespace-only payloads", async () => {
    await deliverDiscordReply({
      replies: [{ text: "   " }, { text: "actual reply" }],
      target: "channel:789",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
      replyToId: "reply-1",
      replyToMode: "first",
    });

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendMessageDiscordMock).toHaveBeenCalledWith(
      "channel:789",
      "actual reply",
      expect.objectContaining({ token: "token", replyTo: "reply-1" }),
    );
  });

  it("preserves leading whitespace in delivered text chunks", async () => {
    await deliverDiscordReply({
      replies: [{ text: "  leading text" }],
      target: "channel:789",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
    });

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendMessageDiscordMock).toHaveBeenCalledWith(
      "channel:789",
      "  leading text",
      expect.objectContaining({ token: "token" }),
    );
  });

  it("sends text chunks in order via sendDiscordText when rest is provided", async () => {
    const fakeRest = {} as import("@buape/carbon").RequestClient;
    const callOrder: string[] = [];
    sendDiscordTextMock.mockImplementation(
      async (_rest: unknown, _channelId: unknown, text: string) => {
        callOrder.push(text);
        return { id: `msg-${callOrder.length}`, channel_id: "789" };
      },
    );

    await deliverDiscordReply({
      replies: [{ text: "1234567890" }],
      target: "channel:789",
      token: "token",
      rest: fakeRest,
      runtime,
      cfg,
      textLimit: 5,
    });

    expect(sendMessageDiscordMock).not.toHaveBeenCalled();
    expect(sendDiscordTextMock).toHaveBeenCalledTimes(2);
    expect(callOrder).toEqual(["12345", "67890"]);
    expect(sendDiscordTextMock.mock.calls[0]?.[1]).toBe("789");
    expect(sendDiscordTextMock.mock.calls[1]?.[1]).toBe("789");
  });

  it("passes maxLinesPerMessage and chunkMode through the fast path", async () => {
    const fakeRest = {} as import("@buape/carbon").RequestClient;

    await deliverDiscordReply({
      replies: [{ text: Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n") }],
      target: "channel:789",
      token: "token",
      rest: fakeRest,
      runtime,
      cfg,
      textLimit: 2000,
      maxLinesPerMessage: 120,
      chunkMode: "newline",
    });

    expect(sendMessageDiscordMock).not.toHaveBeenCalled();
    expect(sendDiscordTextMock).toHaveBeenCalledTimes(1);
    const firstSendDiscordTextCall = sendDiscordTextMock.mock.calls[0];
    const [, , , , , maxLinesPerMessageArg, , , chunkModeArg] = firstSendDiscordTextCall ?? [];

    expect(maxLinesPerMessageArg).toBe(120);
    expect(chunkModeArg).toBe("newline");
  });

  it("falls back to sendMessageDiscord when rest is not provided", async () => {
    await deliverDiscordReply({
      replies: [{ text: "single chunk" }],
      target: "channel:789",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
    });

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendDiscordTextMock).not.toHaveBeenCalled();
  });

  it("retries bot send on 429 rate limit then succeeds", async () => {
    const rateLimitErr = Object.assign(new Error("rate limited"), { status: 429 });
    sendMessageDiscordMock
      .mockRejectedValueOnce(rateLimitErr)
      .mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" });

    await deliverDiscordReply({
      replies: [{ text: "retry me" }],
      target: "channel:123",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
    });

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
  });

  it("retries bot send on 500 server error then succeeds", async () => {
    const serverErr = Object.assign(new Error("internal"), { status: 500 });
    sendMessageDiscordMock
      .mockRejectedValueOnce(serverErr)
      .mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" });

    await deliverDiscordReply({
      replies: [{ text: "retry me" }],
      target: "channel:123",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
    });

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2);
  });

  it("does not retry on 4xx client errors", async () => {
    const clientErr = Object.assign(new Error("bad request"), { status: 400 });
    sendMessageDiscordMock.mockRejectedValueOnce(clientErr);

    await expect(
      deliverDiscordReply({
        replies: [{ text: "fail" }],
        target: "channel:123",
        token: "token",
        runtime,
        cfg,
        textLimit: 2000,
      }),
    ).rejects.toThrow("bad request");

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
  });

  it("throws after exhausting retry attempts", async () => {
    const rateLimitErr = Object.assign(new Error("rate limited"), { status: 429 });
    sendMessageDiscordMock.mockRejectedValue(rateLimitErr);

    await expect(
      deliverDiscordReply({
        replies: [{ text: "persistent failure" }],
        target: "channel:123",
        token: "token",
        runtime,
        cfg,
        textLimit: 2000,
      }),
    ).rejects.toThrow("rate limited");

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(3);
  });

  it("delivers remaining chunks after a mid-sequence retry", async () => {
    sendMessageDiscordMock
      .mockResolvedValueOnce({ messageId: "c1" })
      .mockRejectedValueOnce(Object.assign(new Error("rate limited"), { status: 429 }))
      .mockResolvedValueOnce({ messageId: "c2-retry" })
      .mockResolvedValueOnce({ messageId: "c3" });

    await deliverDiscordReply({
      replies: [{ text: "A".repeat(6) }],
      target: "channel:123",
      token: "token",
      runtime,
      cfg,
      textLimit: 2,
    });

    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(4);
  });

  it("sends bound-session text replies through webhook delivery", async () => {
    const threadBindings = await createBoundThreadBindings({ label: "codex-refactor" });

    await deliverDiscordReply({
      replies: [{ text: "Hello from subagent" }],
      target: "channel:thread-1",
      token: "token",
      runtime,
      cfg,
      textLimit: 2000,
      replyToId: "reply-1",
      sessionKey: "agent:main:subagent:child",
      threadBindings,
    });

    expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendWebhookMessageDiscordMock).toHaveBeenCalledWith(
      "Hello from subagent",
      expect.objectContaining({
        cfg,
        webhookId: "wh_1",
        webhookToken: "tok_1",
        accountId: "default",
        threadId: "thread-1",
        replyTo: "reply-1",
      }),
    );
    expect(sendMessageDiscordMock).not.toHaveBeenCalled();
  });

  it("touches bound-thread activity after outbound delivery", async () => {
    vi.useFakeTimers();
    try {
      vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
      const threadBindings = await createBoundThreadBindings();
      vi.setSystemTime(new Date("2026-02-20T00:02:00.000Z"));

      await deliverDiscordReply({
        replies: [{ text: "Activity ping" }],
        target: "channel:thread-1",
        token: "token",
        runtime,
        cfg,
        textLimit: 2000,
        sessionKey: "agent:main:subagent:child",
        threadBindings,
      });

      expect(threadBindings.getByThreadId("thread-1")?.lastActivityAt).toBe(
        new Date("2026-02-20T00:02:00.000Z").getTime(),
      );
    } finally {
      vi.useRealTimers();
    }
  });

  it("falls back to bot send when webhook delivery fails", async () => {
    const threadBindings = await createBoundThreadBindings();
    sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));

    await deliverDiscordReply({
      replies: [{ text: "Fallback path" }],
      target: "channel:thread-1",
      token: "token",
      accountId: "default",
      runtime,
      cfg,
      textLimit: 2000,
      sessionKey: "agent:main:subagent:child",
      threadBindings,
    });

    expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendWebhookMessageDiscordMock.mock.calls[0]?.[1]?.cfg).toBe(cfg);
    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendMessageDiscordMock).toHaveBeenCalledWith(
      "channel:thread-1",
      "Fallback path",
      expect.objectContaining({ token: "token", accountId: "default" }),
    );
  });

  it("does not use thread webhook when outbound target is not a bound thread", async () => {
    const threadBindings = await createBoundThreadBindings();

    await deliverDiscordReply({
      replies: [{ text: "Parent channel delivery" }],
      target: "channel:parent-1",
      token: "token",
      accountId: "default",
      runtime,
      cfg,
      textLimit: 2000,
      sessionKey: "agent:main:subagent:child",
      threadBindings,
    });

    expect(sendWebhookMessageDiscordMock).not.toHaveBeenCalled();
    expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
    expect(sendMessageDiscordMock).toHaveBeenCalledWith(
      "channel:parent-1",
      "Parent channel delivery",
      expect.objectContaining({ token: "token", accountId: "default" }),
    );
  });
});
