import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Discord tests cover threading.auto thread plugin behavior.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { ChannelType } from "../internal/discord.js";
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
type MaybeCreateDiscordAutoThreadFn = typeof import("./threading.js").maybeCreateDiscordAutoThread;

const { generateThreadTitleMock } = vi.hoisted(() => ({
  generateThreadTitleMock: vi.fn(),
}));

vi.mock("./thread-title.js", () => ({
  generateThreadTitle: generateThreadTitleMock,
}));

let maybeCreateDiscordAutoThread: MaybeCreateDiscordAutoThreadFn;

const postMock = vi.fn();
const getMock = vi.fn();
const patchMock = vi.fn();
const mockClient = {
  rest: { post: postMock, get: getMock, patch: patchMock },
} as unknown as Parameters<MaybeCreateDiscordAutoThreadFn>[0]["client"];
const mockMessage = {
  id: "msg1",
  timestamp: "123",
} as unknown as Parameters<MaybeCreateDiscordAutoThreadFn>[0]["message"];

function createMockMessage(overrides: Record<string, unknown>) {
  return Object.assign(
    {},
    mockMessage,
    overrides,
  ) as Parameters<MaybeCreateDiscordAutoThreadFn>[0]["message"];
}

function createBaseParams(
  overrides: Partial<Parameters<MaybeCreateDiscordAutoThreadFn>[0]> = {},
): Parameters<MaybeCreateDiscordAutoThreadFn>[0] {
  return {
    client: mockClient,
    message: mockMessage,
    messageChannelId: "text1",
    channel: "discord",
    isGuildMessage: true,
    channelConfig: { allowed: true, autoThread: true },
    channelType: ChannelType.GuildText,
    baseText: "test",
    combinedBody: "test",
    cfg: EMPTY_DISCORD_TEST_CONFIG,
    ...overrides,
  };
}

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

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

function callArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
  const calls = (mock as { mock?: { calls?: Array<Array<unknown>> } }).mock?.calls ?? [];
  const call = calls.at(callIndex);
  if (!call) {
    throw new Error(`Expected ${label}`);
  }
  return call[argIndex];
}

function expectRestBodyField(mock: unknown, field: string, expected: unknown) {
  expect(callArg(mock, 0, 0, "rest path")).toBeTypeOf("string");
  const options = requireRecord(callArg(mock, 0, 1, "rest options"), "rest options");
  const body = requireRecord(options.body, "rest body");
  expect(body[field]).toBe(expected);
}

function expectGeneratedTitleField(field: string, expected: unknown) {
  const params = requireRecord(
    callArg(generateThreadTitleMock, 0, 0, "thread title params"),
    "thread title params",
  );
  expect(params[field]).toBe(expected);
}

beforeAll(async () => {
  ({ maybeCreateDiscordAutoThread } = await import("./threading.js"));
});

beforeEach(() => {
  postMock.mockReset();
  getMock.mockReset();
  patchMock.mockReset();
  generateThreadTitleMock.mockReset();
});

describe("maybeCreateDiscordAutoThread", () => {
  it("skips auto-thread if channelType is GuildForum", async () => {
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({ channelType: ChannelType.GuildForum }),
    );
    expect(result).toBeUndefined();
    expect(postMock).not.toHaveBeenCalled();
  });

  it("skips auto-thread if channelType is GuildMedia", async () => {
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({ channelType: ChannelType.GuildMedia }),
    );
    expect(result).toBeUndefined();
    expect(postMock).not.toHaveBeenCalled();
  });

  it("skips auto-thread if channelType is GuildVoice", async () => {
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({ channelType: ChannelType.GuildVoice }),
    );
    expect(result).toBeUndefined();
    expect(postMock).not.toHaveBeenCalled();
  });

  it("skips auto-thread if channelType is GuildStageVoice", async () => {
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({ channelType: ChannelType.GuildStageVoice }),
    );
    expect(result).toBeUndefined();
    expect(postMock).not.toHaveBeenCalled();
  });

  it("creates auto-thread if channelType is GuildText", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    const result = await maybeCreateDiscordAutoThread(createBaseParams());
    expect(result).toBe("thread1");
    expect(postMock).toHaveBeenCalled();
  });

  it("reuses an existing message thread before creating a new one", async () => {
    getMock.mockResolvedValueOnce({ thread: { id: "existing-thread" } });
    const result = await maybeCreateDiscordAutoThread(createBaseParams());

    expect(result).toBe("existing-thread");
    expect(postMock).not.toHaveBeenCalled();
  });

  it("reuses an existing message thread before skipping bot-authored messages", async () => {
    getMock.mockResolvedValueOnce({ thread: { id: "existing-thread" } });
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({
        message: createMockMessage({
          author: { bot: true },
        }),
      }),
    );

    expect(result).toBe("existing-thread");
    expect(postMock).not.toHaveBeenCalled();
  });

  it("skips creating new auto-threads for bot-authored messages", async () => {
    getMock.mockResolvedValueOnce({});
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({
        message: createMockMessage({
          author: { bot: true },
        }),
      }),
    );

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

  it("still creates an auto-thread when the existing-thread lookup fails", async () => {
    getMock.mockRejectedValueOnce(new Error("transient fetch failure"));
    postMock.mockResolvedValueOnce({ id: "thread1" });

    const result = await maybeCreateDiscordAutoThread(createBaseParams());

    expect(result).toBe("thread1");
    expect(postMock).toHaveBeenCalled();
  });
});

describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
  it("uses configured autoArchiveDuration", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    await maybeCreateDiscordAutoThread(
      createBaseParams({
        channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: "10080" },
      }),
    );
    expectRestBodyField(postMock, "auto_archive_duration", 10080);
  });

  it("accepts numeric autoArchiveDuration", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    await maybeCreateDiscordAutoThread(
      createBaseParams({
        channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: 4320 },
      }),
    );
    expectRestBodyField(postMock, "auto_archive_duration", 4320);
  });

  it("defaults to 60 when autoArchiveDuration not set", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    await maybeCreateDiscordAutoThread(createBaseParams());
    expectRestBodyField(postMock, "auto_archive_duration", 60);
  });
});

describe("maybeCreateDiscordAutoThread autoThreadName", () => {
  it("renames created thread when generated mode is enabled", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    patchMock.mockResolvedValueOnce({});
    generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");

    const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } } as OpenClawConfig;
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({
        baseText: "Need help with deploy rollout",
        combinedBody: "Need help with deploy rollout",
        channelName: "openclaw",
        channelDescription: "OpenClaw development coordination and release planning",
        channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
        cfg,
        agentId: "main",
      }),
    );
    expect(result).toBe("thread1");
    expectRestBodyField(postMock, "name", "Need help with deploy rollout");
    await flushAsyncWork();
    expectGeneratedTitleField("agentId", "main");
    expectGeneratedTitleField("messageText", "Need help with deploy rollout");
    expectGeneratedTitleField("channelName", "openclaw");
    expectGeneratedTitleField(
      "channelDescription",
      "OpenClaw development coordination and release planning",
    );
    expectRestBodyField(patchMock, "name", "Deploy rollout summary");
  });

  it("does not block thread creation while title summary is pending", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    patchMock.mockResolvedValueOnce({});
    let resolveTitle: ((value: string | null) => void) | undefined;
    generateThreadTitleMock.mockReturnValueOnce(
      new Promise((resolve: (value: string | null) => void) => {
        resolveTitle = resolve;
      }),
    );

    const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } } as OpenClawConfig;
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({
        channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
        cfg,
        agentId: "main",
      }),
    );
    expect(result).toBe("thread1");
    expect(patchMock).not.toHaveBeenCalled();

    resolveTitle?.("Async summary");
    await flushAsyncWork();
    expect(patchMock).toHaveBeenCalled();
  });

  it("uses channel-specific thread override for generated title model", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    patchMock.mockResolvedValueOnce({});
    generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");

    const cfg = {
      agents: {
        defaults: { model: "anthropic/claude-opus-4-6" },
      },
      channels: {
        modelByChannel: {
          discord: {
            thread1: "openai/gpt-4.1-mini",
          },
        },
      },
    } as OpenClawConfig;
    await maybeCreateDiscordAutoThread(
      createBaseParams({
        channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
        cfg,
        agentId: "main",
      }),
    );

    await flushAsyncWork();
    expectGeneratedTitleField("modelRef", "openai/gpt-4.1-mini");
  });

  it("falls back to parent channel override for generated title model", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    patchMock.mockResolvedValueOnce({});
    generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");

    const cfg = {
      agents: {
        defaults: { model: "anthropic/claude-opus-4-6" },
      },
      channels: {
        modelByChannel: {
          discord: {
            text1: "openai/gpt-4.1-mini",
          },
        },
      },
    } as OpenClawConfig;
    await maybeCreateDiscordAutoThread(
      createBaseParams({
        channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
        cfg,
        agentId: "main",
      }),
    );

    await flushAsyncWork();
    expectGeneratedTitleField("modelRef", "openai/gpt-4.1-mini");
  });

  it("skips summarization when cfg or agentId is missing", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    await maybeCreateDiscordAutoThread(
      createBaseParams({
        channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
      }),
    );
    await flushAsyncWork();
    expect(generateThreadTitleMock).not.toHaveBeenCalled();
    expect(patchMock).not.toHaveBeenCalled();
  });

  it("does not rename when autoThreadName is not set", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    await maybeCreateDiscordAutoThread(
      createBaseParams({
        channelConfig: { allowed: true, autoThread: true },
      }),
    );
    await flushAsyncWork();
    expect(generateThreadTitleMock).not.toHaveBeenCalled();
    expect(patchMock).not.toHaveBeenCalled();
  });

  it("does not rename when generated title sanitizes to fallback thread name", async () => {
    postMock.mockResolvedValueOnce({ id: "thread1" });
    getMock.mockResolvedValueOnce({});
    generateThreadTitleMock.mockResolvedValueOnce("<@123456789012345678> <#987654321098765432>");

    const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } } as OpenClawConfig;
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({
        baseText: "Need help with deploy rollout",
        combinedBody: "Need help with deploy rollout",
        channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
        cfg,
        agentId: "main",
      }),
    );

    expect(result).toBe("thread1");
    await flushAsyncWork();
    expect(patchMock).not.toHaveBeenCalled();
  });

  it("skips thread creation when autoThread is false", async () => {
    const result = await maybeCreateDiscordAutoThread(
      createBaseParams({
        channelConfig: { allowed: true, autoThread: false },
      }),
    );
    expect(result).toBeUndefined();
    expect(postMock).not.toHaveBeenCalled();
  });
});
