import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
import {
  createInboundDebouncer,
  resolveInboundDebounceMs,
} from "../../../src/auto-reply/inbound-debounce.js";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
import * as dedup from "./dedup.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
import { setFeishuRuntime } from "./runtime.js";
import type { ResolvedFeishuAccount } from "./types.js";

const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?: unknown }) => {}));
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));

let handlers: Record<string, (data: unknown) => Promise<void>> = {};

vi.mock("./client.js", () => ({
  createEventDispatcher: createEventDispatcherMock,
}));

vi.mock("./bot.js", async () => {
  const actual = await vi.importActual<typeof import("./bot.js")>("./bot.js");
  return {
    ...actual,
    handleFeishuMessage: handleFeishuMessageMock,
  };
});

vi.mock("./monitor.transport.js", () => ({
  monitorWebSocket: monitorWebSocketMock,
  monitorWebhook: monitorWebhookMock,
}));

const cfg = {} as ClawdbotConfig;

function makeReactionEvent(
  overrides: Partial<FeishuReactionCreatedEvent> = {},
): FeishuReactionCreatedEvent {
  return {
    message_id: "om_msg1",
    reaction_type: { emoji_type: "THUMBSUP" },
    operator_type: "user",
    user_id: { open_id: "ou_user1" },
    ...overrides,
  };
}

type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];

function buildDebounceConfig(): ClawdbotConfig {
  return {
    messages: {
      inbound: {
        debounceMs: 0,
        byChannel: {
          feishu: 20,
        },
      },
    },
    channels: {
      feishu: {
        enabled: true,
      },
    },
  } as ClawdbotConfig;
}

function buildDebounceAccount(): ResolvedFeishuAccount {
  return {
    accountId: "default",
    enabled: true,
    configured: true,
    appId: "cli_test",
    appSecret: "secret_test",
    domain: "feishu",
    config: {
      enabled: true,
      connectionMode: "websocket",
    },
  } as ResolvedFeishuAccount;
}

function createTextEvent(params: {
  messageId: string;
  text: string;
  senderId?: string;
  mentions?: FeishuMention[];
}): FeishuMessageEvent {
  const senderId = params.senderId ?? "ou_sender";
  return {
    sender: {
      sender_id: { open_id: senderId },
      sender_type: "user",
    },
    message: {
      message_id: params.messageId,
      chat_id: "oc_group_1",
      chat_type: "group",
      message_type: "text",
      content: JSON.stringify({ text: params.text }),
      mentions: params.mentions,
    },
  };
}

async function setupDebounceMonitor(): Promise<(data: unknown) => Promise<void>> {
  const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
    handlers = registered;
  });
  createEventDispatcherMock.mockReturnValue({ register });

  await monitorSingleAccount({
    cfg: buildDebounceConfig(),
    account: buildDebounceAccount(),
    runtime: {
      log: vi.fn(),
      error: vi.fn(),
      exit: vi.fn(),
    } as RuntimeEnv,
    botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot" },
  });

  const onMessage = handlers["im.message.receive_v1"];
  if (!onMessage) {
    throw new Error("missing im.message.receive_v1 handler");
  }
  return onMessage;
}

function getFirstDispatchedEvent(): FeishuMessageEvent {
  const firstCall = handleFeishuMessageMock.mock.calls[0];
  if (!firstCall) {
    throw new Error("missing dispatch call");
  }
  const firstParams = firstCall[0] as { event?: FeishuMessageEvent } | undefined;
  if (!firstParams?.event) {
    throw new Error("missing dispatched event payload");
  }
  return firstParams.event;
}

describe("resolveReactionSyntheticEvent", () => {
  it("filters app self-reactions", async () => {
    const event = makeReactionEvent({ operator_type: "app" });
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
    });
    expect(result).toBeNull();
  });

  it("filters Typing reactions", async () => {
    const event = makeReactionEvent({ reaction_type: { emoji_type: "Typing" } });
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
    });
    expect(result).toBeNull();
  });

  it("fails closed when bot open_id is unavailable", async () => {
    const event = makeReactionEvent();
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "default",
      event,
    });
    expect(result).toBeNull();
  });

  it("drops reactions when reactionNotifications is off", async () => {
    const event = makeReactionEvent();
    const result = await resolveReactionSyntheticEvent({
      cfg: {
        channels: {
          feishu: {
            reactionNotifications: "off",
          },
        },
      } as ClawdbotConfig,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
      fetchMessage: async () => ({
        messageId: "om_msg1",
        chatId: "oc_group",
        senderOpenId: "ou_bot",
        senderType: "app",
        content: "hello",
        contentType: "text",
      }),
    });
    expect(result).toBeNull();
  });

  it("filters reactions on non-bot messages", async () => {
    const event = makeReactionEvent();
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
      fetchMessage: async () => ({
        messageId: "om_msg1",
        chatId: "oc_group",
        senderOpenId: "ou_other",
        senderType: "user",
        content: "hello",
        contentType: "text",
      }),
    });
    expect(result).toBeNull();
  });

  it("allows non-bot reactions when reactionNotifications is all", async () => {
    const event = makeReactionEvent();
    const result = await resolveReactionSyntheticEvent({
      cfg: {
        channels: {
          feishu: {
            reactionNotifications: "all",
          },
        },
      } as ClawdbotConfig,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
      fetchMessage: async () => ({
        messageId: "om_msg1",
        chatId: "oc_group",
        senderOpenId: "ou_other",
        senderType: "user",
        content: "hello",
        contentType: "text",
      }),
      uuid: () => "fixed-uuid",
    });
    expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid");
  });

  it("drops unverified reactions when sender verification times out", async () => {
    const event = makeReactionEvent();
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
      verificationTimeoutMs: 1,
      fetchMessage: async () =>
        await new Promise<never>(() => {
          // Never resolves
        }),
    });
    expect(result).toBeNull();
  });

  it("uses event chat context when provided", async () => {
    const event = makeReactionEvent({
      chat_id: "oc_group_from_event",
      chat_type: "group",
    });
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
      fetchMessage: async () => ({
        messageId: "om_msg1",
        chatId: "oc_group_from_lookup",
        senderOpenId: "ou_bot",
        content: "hello",
        contentType: "text",
      }),
      uuid: () => "fixed-uuid",
    });

    expect(result).toEqual({
      sender: {
        sender_id: { open_id: "ou_user1" },
        sender_type: "user",
      },
      message: {
        message_id: "om_msg1:reaction:THUMBSUP:fixed-uuid",
        chat_id: "oc_group_from_event",
        chat_type: "group",
        message_type: "text",
        content: JSON.stringify({
          text: "[reacted with THUMBSUP to message om_msg1]",
        }),
      },
    });
  });

  it("falls back to reacted message chat_id when event chat_id is absent", async () => {
    const event = makeReactionEvent();
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
      fetchMessage: async () => ({
        messageId: "om_msg1",
        chatId: "oc_group_from_lookup",
        senderOpenId: "ou_bot",
        content: "hello",
        contentType: "text",
      }),
      uuid: () => "fixed-uuid",
    });

    expect(result?.message.chat_id).toBe("oc_group_from_lookup");
    expect(result?.message.chat_type).toBe("p2p");
  });

  it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
    const event = makeReactionEvent();
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "default",
      event,
      botOpenId: "ou_bot",
      fetchMessage: async () => ({
        messageId: "om_msg1",
        chatId: "",
        senderOpenId: "ou_bot",
        content: "hello",
        contentType: "text",
      }),
      uuid: () => "fixed-uuid",
    });

    expect(result?.message.chat_id).toBe("p2p:ou_user1");
    expect(result?.message.chat_type).toBe("p2p");
  });

  it("logs and drops reactions when lookup throws", async () => {
    const log = vi.fn();
    const event = makeReactionEvent();
    const result = await resolveReactionSyntheticEvent({
      cfg,
      accountId: "acct1",
      event,
      botOpenId: "ou_bot",
      fetchMessage: async () => {
        throw new Error("boom");
      },
      logger: log,
    });
    expect(result).toBeNull();
    expect(log).toHaveBeenCalledWith(
      expect.stringContaining("ignoring reaction on non-bot/unverified message om_msg1"),
    );
  });
});

describe("Feishu inbound debounce regressions", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    handlers = {};
    handleFeishuMessageMock.mockClear();
    setFeishuRuntime(
      createPluginRuntimeMock({
        channel: {
          debounce: {
            createInboundDebouncer,
            resolveInboundDebounceMs,
          },
          text: {
            hasControlCommand,
          },
        },
      }),
    );
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.restoreAllMocks();
  });

  it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
    vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
    vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
    vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
    vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
    const onMessage = await setupDebounceMonitor();

    await onMessage(
      createTextEvent({
        messageId: "om_1",
        text: "first",
        mentions: [
          {
            key: "@_user_1",
            id: { open_id: "ou_user_a" },
            name: "user-a",
          },
        ],
      }),
    );
    await Promise.resolve();
    await Promise.resolve();
    await onMessage(
      createTextEvent({
        messageId: "om_2",
        text: "@bot second",
        mentions: [
          {
            key: "@_user_1",
            id: { open_id: "ou_bot" },
            name: "bot",
          },
        ],
      }),
    );
    await Promise.resolve();
    await Promise.resolve();
    await vi.advanceTimersByTimeAsync(25);

    expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
    const dispatched = getFirstDispatchedEvent();
    const mergedMentions = dispatched.message.mentions ?? [];
    expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
    expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
  });

  it("does not synthesize mention-forward intent across separate messages", async () => {
    vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
    vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
    vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
    vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
    const onMessage = await setupDebounceMonitor();

    await onMessage(
      createTextEvent({
        messageId: "om_user_mention",
        text: "@alice first",
        mentions: [
          {
            key: "@_user_1",
            id: { open_id: "ou_alice" },
            name: "alice",
          },
        ],
      }),
    );
    await Promise.resolve();
    await Promise.resolve();
    await onMessage(
      createTextEvent({
        messageId: "om_bot_mention",
        text: "@bot second",
        mentions: [
          {
            key: "@_user_1",
            id: { open_id: "ou_bot" },
            name: "bot",
          },
        ],
      }),
    );
    await Promise.resolve();
    await Promise.resolve();
    await vi.advanceTimersByTimeAsync(25);

    expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
    const dispatched = getFirstDispatchedEvent();
    const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
    expect(parsed.mentionedBot).toBe(true);
    expect(parsed.mentionTargets).toBeUndefined();
    const mergedMentions = dispatched.message.mentions ?? [];
    expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true);
  });

  it("preserves bot mention signal when the latest merged message has no mentions", async () => {
    vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
    vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
    vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
    vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
    const onMessage = await setupDebounceMonitor();

    await onMessage(
      createTextEvent({
        messageId: "om_bot_first",
        text: "@bot first",
        mentions: [
          {
            key: "@_user_1",
            id: { open_id: "ou_bot" },
            name: "bot",
          },
        ],
      }),
    );
    await Promise.resolve();
    await Promise.resolve();
    await onMessage(
      createTextEvent({
        messageId: "om_plain_second",
        text: "plain follow-up",
      }),
    );
    await Promise.resolve();
    await Promise.resolve();
    await vi.advanceTimersByTimeAsync(25);

    expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
    const dispatched = getFirstDispatchedEvent();
    const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
    expect(parsed.mentionedBot).toBe(true);
  });

  it("excludes previously processed retries from combined debounce text", async () => {
    vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
    vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
    vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
    vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
      async (messageId) => messageId === "om_old",
    );
    const onMessage = await setupDebounceMonitor();

    await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
    await Promise.resolve();
    await Promise.resolve();
    await onMessage(createTextEvent({ messageId: "om_new_1", text: "first" }));
    await Promise.resolve();
    await Promise.resolve();
    await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
    await Promise.resolve();
    await Promise.resolve();
    await onMessage(createTextEvent({ messageId: "om_new_2", text: "second" }));
    await Promise.resolve();
    await Promise.resolve();
    await vi.advanceTimersByTimeAsync(25);

    expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
    const dispatched = getFirstDispatchedEvent();
    expect(dispatched.message.message_id).toBe("om_new_2");
    const combined = JSON.parse(dispatched.message.content) as { text?: string };
    expect(combined.text).toBe("first\nsecond");
  });

  it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
    const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
    vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
    vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
    vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
      async (messageId) => messageId === "om_old",
    );
    const onMessage = await setupDebounceMonitor();

    await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
    await Promise.resolve();
    await Promise.resolve();
    await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
    await Promise.resolve();
    await Promise.resolve();
    await vi.advanceTimersByTimeAsync(25);

    expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
    const dispatched = getFirstDispatchedEvent();
    expect(dispatched.message.message_id).toBe("om_new");
    const combined = JSON.parse(dispatched.message.content) as { text?: string };
    expect(combined.text).toBe("fresh");
    expect(recordSpy).toHaveBeenCalledWith("default:om_old");
    expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
  });
});
