import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
import * as conversationBinding from "./conversation-binding.js";
import type {
  DiscordInteractiveDispatchContext,
  SlackInteractiveDispatchContext,
  TelegramInteractiveDispatchContext,
} from "./interactive-dispatch-adapters.js";
import {
  clearPluginInteractiveHandlers,
  dispatchPluginInteractiveHandler,
  registerPluginInteractiveHandler,
} from "./interactive.js";
import type {
  PluginInteractiveDiscordHandlerContext,
  PluginInteractiveSlackHandlerContext,
  PluginInteractiveTelegramHandlerContext,
} from "./types.js";

let requestPluginConversationBindingMock: MockInstance<
  typeof conversationBinding.requestPluginConversationBinding
>;
let detachPluginConversationBindingMock: MockInstance<
  typeof conversationBinding.detachPluginConversationBinding
>;
let getCurrentPluginConversationBindingMock: MockInstance<
  typeof conversationBinding.getCurrentPluginConversationBinding
>;

type InteractiveDispatchParams =
  | {
      channel: "telegram";
      data: string;
      callbackId: string;
      ctx: TelegramInteractiveDispatchContext;
      respond: PluginInteractiveTelegramHandlerContext["respond"];
    }
  | {
      channel: "discord";
      data: string;
      interactionId: string;
      ctx: DiscordInteractiveDispatchContext;
      respond: PluginInteractiveDiscordHandlerContext["respond"];
    }
  | {
      channel: "slack";
      data: string;
      interactionId: string;
      ctx: SlackInteractiveDispatchContext;
      respond: PluginInteractiveSlackHandlerContext["respond"];
    };

type InteractiveModule = typeof import("./interactive.js");

const interactiveModuleUrl = new URL("./interactive.ts", import.meta.url).href;

async function importInteractiveModule(cacheBust: string): Promise<InteractiveModule> {
  return (await import(`${interactiveModuleUrl}?t=${cacheBust}`)) as InteractiveModule;
}

async function expectDedupedInteractiveDispatch(params: {
  baseParams: InteractiveDispatchParams;
  handler: ReturnType<typeof vi.fn>;
  expectedCall: unknown;
}) {
  const dispatch = async (baseParams: InteractiveDispatchParams) => {
    if (baseParams.channel === "telegram") {
      return await dispatchPluginInteractiveHandler(baseParams);
    }
    if (baseParams.channel === "discord") {
      return await dispatchPluginInteractiveHandler(baseParams);
    }
    return await dispatchPluginInteractiveHandler(baseParams);
  };

  const first = await dispatch(params.baseParams);
  const duplicate = await dispatch(params.baseParams);

  expect(first).toEqual({ matched: true, handled: true, duplicate: false });
  expect(duplicate).toEqual({ matched: true, handled: true, duplicate: true });
  expect(params.handler).toHaveBeenCalledTimes(1);
  expect(params.handler).toHaveBeenCalledWith(expect.objectContaining(params.expectedCall));
}

describe("plugin interactive handlers", () => {
  beforeEach(() => {
    clearPluginInteractiveHandlers();
    requestPluginConversationBindingMock = vi
      .spyOn(conversationBinding, "requestPluginConversationBinding")
      .mockResolvedValue({
        status: "bound",
        binding: {
          bindingId: "binding-1",
          pluginId: "codex-plugin",
          pluginName: "Codex",
          pluginRoot: "/plugins/codex",
          channel: "telegram",
          accountId: "default",
          conversationId: "-10099:topic:77",
          parentConversationId: "-10099",
          threadId: 77,
          boundAt: 1,
        },
      });
    detachPluginConversationBindingMock = vi
      .spyOn(conversationBinding, "detachPluginConversationBinding")
      .mockResolvedValue({ removed: true });
    getCurrentPluginConversationBindingMock = vi
      .spyOn(conversationBinding, "getCurrentPluginConversationBinding")
      .mockResolvedValue({
        bindingId: "binding-1",
        pluginId: "codex-plugin",
        pluginName: "Codex",
        pluginRoot: "/plugins/codex",
        channel: "telegram",
        accountId: "default",
        conversationId: "-10099:topic:77",
        parentConversationId: "-10099",
        threadId: 77,
        boundAt: 1,
      });
  });

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

  it("routes Telegram callbacks by namespace and dedupes callback ids", async () => {
    const handler = vi.fn(async () => ({ handled: true }));
    expect(
      registerPluginInteractiveHandler("codex-plugin", {
        channel: "telegram",
        namespace: "codex",
        handler,
      }),
    ).toEqual({ ok: true });

    const baseParams = {
      channel: "telegram" as const,
      data: "codex:resume:thread-1",
      callbackId: "cb-1",
      ctx: {
        accountId: "default",
        callbackId: "cb-1",
        conversationId: "-10099:topic:77",
        parentConversationId: "-10099",
        senderId: "user-1",
        senderUsername: "ada",
        threadId: 77,
        isGroup: true,
        isForum: true,
        auth: { isAuthorizedSender: true },
        callbackMessage: {
          messageId: 55,
          chatId: "-10099",
          messageText: "Pick a thread",
        },
      },
      respond: {
        reply: vi.fn(async () => {}),
        editMessage: vi.fn(async () => {}),
        editButtons: vi.fn(async () => {}),
        clearButtons: vi.fn(async () => {}),
        deleteMessage: vi.fn(async () => {}),
      },
    };

    await expectDedupedInteractiveDispatch({
      baseParams,
      handler,
      expectedCall: {
        channel: "telegram",
        conversationId: "-10099:topic:77",
        callback: expect.objectContaining({
          namespace: "codex",
          payload: "resume:thread-1",
          chatId: "-10099",
          messageId: 55,
        }),
      },
    });
  });

  it("shares interactive handlers across duplicate module instances", async () => {
    const first = await importInteractiveModule(`first-${Date.now()}`);
    const second = await importInteractiveModule(`second-${Date.now()}`);
    const handler = vi.fn(async () => ({ handled: true }));

    first.clearPluginInteractiveHandlers();

    expect(
      first.registerPluginInteractiveHandler("codex-plugin", {
        channel: "telegram",
        namespace: "codexapp",
        handler,
      }),
    ).toEqual({ ok: true });

    await expect(
      second.dispatchPluginInteractiveHandler({
        channel: "telegram",
        data: "codexapp:resume:thread-1",
        callbackId: "cb-shared-1",
        ctx: {
          accountId: "default",
          callbackId: "cb-shared-1",
          conversationId: "-10099:topic:77",
          parentConversationId: "-10099",
          senderId: "user-1",
          senderUsername: "ada",
          threadId: 77,
          isGroup: true,
          isForum: true,
          auth: { isAuthorizedSender: true },
          callbackMessage: {
            messageId: 55,
            chatId: "-10099",
            messageText: "Pick a thread",
          },
        },
        respond: {
          reply: vi.fn(async () => {}),
          editMessage: vi.fn(async () => {}),
          editButtons: vi.fn(async () => {}),
          clearButtons: vi.fn(async () => {}),
          deleteMessage: vi.fn(async () => {}),
        },
      }),
    ).resolves.toEqual({ matched: true, handled: true, duplicate: false });

    expect(handler).toHaveBeenCalledWith(
      expect.objectContaining({
        channel: "telegram",
        callback: expect.objectContaining({
          namespace: "codexapp",
          payload: "resume:thread-1",
        }),
      }),
    );

    second.clearPluginInteractiveHandlers();
  });

  it("rejects duplicate namespace registrations", () => {
    const first = registerPluginInteractiveHandler("plugin-a", {
      channel: "telegram",
      namespace: "codex",
      handler: async () => ({ handled: true }),
    });
    const second = registerPluginInteractiveHandler("plugin-b", {
      channel: "telegram",
      namespace: "codex",
      handler: async () => ({ handled: true }),
    });

    expect(first).toEqual({ ok: true });
    expect(second).toEqual({
      ok: false,
      error: 'Interactive handler namespace "codex" already registered by plugin "plugin-a"',
    });
  });

  it("routes Discord interactions by namespace and dedupes interaction ids", async () => {
    const handler = vi.fn(async () => ({ handled: true }));
    expect(
      registerPluginInteractiveHandler("codex-plugin", {
        channel: "discord",
        namespace: "codex",
        handler,
      }),
    ).toEqual({ ok: true });

    const baseParams = {
      channel: "discord" as const,
      data: "codex:approve:thread-1",
      interactionId: "ix-1",
      ctx: {
        accountId: "default",
        interactionId: "ix-1",
        conversationId: "channel-1",
        parentConversationId: "parent-1",
        guildId: "guild-1",
        senderId: "user-1",
        senderUsername: "ada",
        auth: { isAuthorizedSender: true },
        interaction: {
          kind: "button" as const,
          messageId: "message-1",
          values: ["allow"],
        },
      },
      respond: {
        acknowledge: vi.fn(async () => {}),
        reply: vi.fn(async () => {}),
        followUp: vi.fn(async () => {}),
        editMessage: vi.fn(async () => {}),
        clearComponents: vi.fn(async () => {}),
      },
    };

    await expectDedupedInteractiveDispatch({
      baseParams,
      handler,
      expectedCall: {
        channel: "discord",
        conversationId: "channel-1",
        interaction: expect.objectContaining({
          namespace: "codex",
          payload: "approve:thread-1",
          messageId: "message-1",
          values: ["allow"],
        }),
      },
    });
  });

  it("acknowledges matched Discord interactions before awaiting plugin handlers", async () => {
    const callOrder: string[] = [];
    const handler = vi.fn(async () => {
      callOrder.push("handler");
      expect(callOrder).toEqual(["ack", "handler"]);
      return { handled: true };
    });
    expect(
      registerPluginInteractiveHandler("codex-plugin", {
        channel: "discord",
        namespace: "codex",
        handler,
      }),
    ).toEqual({ ok: true });

    await expect(
      dispatchPluginInteractiveHandler({
        channel: "discord",
        data: "codex:approve:thread-1",
        interactionId: "ix-ack-1",
        ctx: {
          accountId: "default",
          interactionId: "ix-ack-1",
          conversationId: "channel-1",
          parentConversationId: "parent-1",
          guildId: "guild-1",
          senderId: "user-1",
          senderUsername: "ada",
          auth: { isAuthorizedSender: true },
          interaction: {
            kind: "button",
            messageId: "message-1",
          },
        },
        respond: {
          acknowledge: vi.fn(async () => {}),
          reply: vi.fn(async () => {}),
          followUp: vi.fn(async () => {}),
          editMessage: vi.fn(async () => {}),
          clearComponents: vi.fn(async () => {}),
        },
        onMatched: async () => {
          callOrder.push("ack");
        },
      }),
    ).resolves.toEqual({
      matched: true,
      handled: true,
      duplicate: false,
    });
  });

  it("routes Slack interactions by namespace and dedupes interaction ids", async () => {
    const handler = vi.fn(async () => ({ handled: true }));
    expect(
      registerPluginInteractiveHandler("codex-plugin", {
        channel: "slack",
        namespace: "codex",
        handler,
      }),
    ).toEqual({ ok: true });

    const baseParams = {
      channel: "slack" as const,
      data: "codex:approve:thread-1",
      interactionId: "slack-ix-1",
      ctx: {
        channel: "slack" as const,
        accountId: "default",
        interactionId: "slack-ix-1",
        conversationId: "C123",
        parentConversationId: "C123",
        threadId: "1710000000.000100",
        senderId: "U123",
        senderUsername: "ada",
        auth: { isAuthorizedSender: true },
        interaction: {
          kind: "button" as const,
          actionId: "codex",
          blockId: "codex_actions",
          messageTs: "1710000000.000200",
          threadTs: "1710000000.000100",
          value: "approve:thread-1",
          selectedValues: ["approve:thread-1"],
          selectedLabels: ["Approve"],
          triggerId: "trigger-1",
          responseUrl: "https://hooks.slack.test/response",
        },
      },
      respond: {
        acknowledge: vi.fn(async () => {}),
        reply: vi.fn(async () => {}),
        followUp: vi.fn(async () => {}),
        editMessage: vi.fn(async () => {}),
      },
    };

    await expectDedupedInteractiveDispatch({
      baseParams,
      handler,
      expectedCall: {
        channel: "slack",
        conversationId: "C123",
        threadId: "1710000000.000100",
        interaction: expect.objectContaining({
          namespace: "codex",
          payload: "approve:thread-1",
          actionId: "codex",
          messageTs: "1710000000.000200",
        }),
      },
    });
  });

  it("wires Telegram conversation binding helpers with topic context", async () => {
    const requestResult = {
      status: "bound" as const,
      binding: {
        bindingId: "binding-telegram",
        pluginId: "codex-plugin",
        pluginName: "Codex",
        pluginRoot: "/plugins/codex",
        channel: "telegram",
        accountId: "default",
        conversationId: "-10099:topic:77",
        parentConversationId: "-10099",
        threadId: 77,
        boundAt: 1,
      },
    };
    const currentBinding = {
      ...requestResult.binding,
      boundAt: 2,
    };
    requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
    getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);

    const handler = vi.fn(async (ctx) => {
      await expect(
        ctx.requestConversationBinding({
          summary: "Bind this topic",
          detachHint: "Use /new to detach",
        }),
      ).resolves.toEqual(requestResult);
      await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
      await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
      return { handled: true };
    });
    expect(
      registerPluginInteractiveHandler(
        "codex-plugin",
        {
          channel: "telegram",
          namespace: "codex",
          handler,
        },
        { pluginName: "Codex", pluginRoot: "/plugins/codex" },
      ),
    ).toEqual({ ok: true });

    await expect(
      dispatchPluginInteractiveHandler({
        channel: "telegram",
        data: "codex:bind",
        callbackId: "cb-bind",
        ctx: {
          accountId: "default",
          callbackId: "cb-bind",
          conversationId: "-10099:topic:77",
          parentConversationId: "-10099",
          senderId: "user-1",
          senderUsername: "ada",
          threadId: 77,
          isGroup: true,
          isForum: true,
          auth: { isAuthorizedSender: true },
          callbackMessage: {
            messageId: 55,
            chatId: "-10099",
            messageText: "Pick a thread",
          },
        },
        respond: {
          reply: vi.fn(async () => {}),
          editMessage: vi.fn(async () => {}),
          editButtons: vi.fn(async () => {}),
          clearButtons: vi.fn(async () => {}),
          deleteMessage: vi.fn(async () => {}),
        },
      }),
    ).resolves.toEqual({
      matched: true,
      handled: true,
      duplicate: false,
    });

    expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginId: "codex-plugin",
      pluginName: "Codex",
      pluginRoot: "/plugins/codex",
      requestedBySenderId: "user-1",
      conversation: {
        channel: "telegram",
        accountId: "default",
        conversationId: "-10099:topic:77",
        parentConversationId: "-10099",
        threadId: 77,
      },
      binding: {
        summary: "Bind this topic",
        detachHint: "Use /new to detach",
      },
    });
    expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginRoot: "/plugins/codex",
      conversation: {
        channel: "telegram",
        accountId: "default",
        conversationId: "-10099:topic:77",
        parentConversationId: "-10099",
        threadId: 77,
      },
    });
    expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginRoot: "/plugins/codex",
      conversation: {
        channel: "telegram",
        accountId: "default",
        conversationId: "-10099:topic:77",
        parentConversationId: "-10099",
        threadId: 77,
      },
    });
  });

  it("wires Discord conversation binding helpers with parent channel context", async () => {
    const requestResult = {
      status: "bound" as const,
      binding: {
        bindingId: "binding-discord",
        pluginId: "codex-plugin",
        pluginName: "Codex",
        pluginRoot: "/plugins/codex",
        channel: "discord",
        accountId: "default",
        conversationId: "channel-1",
        parentConversationId: "parent-1",
        boundAt: 1,
      },
    };
    const currentBinding = {
      ...requestResult.binding,
      boundAt: 2,
    };
    requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
    getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);

    const handler = vi.fn(async (ctx) => {
      await expect(ctx.requestConversationBinding({ summary: "Bind Discord" })).resolves.toEqual(
        requestResult,
      );
      await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
      await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
      return { handled: true };
    });
    expect(
      registerPluginInteractiveHandler(
        "codex-plugin",
        {
          channel: "discord",
          namespace: "codex",
          handler,
        },
        { pluginName: "Codex", pluginRoot: "/plugins/codex" },
      ),
    ).toEqual({ ok: true });

    await expect(
      dispatchPluginInteractiveHandler({
        channel: "discord",
        data: "codex:bind",
        interactionId: "ix-bind",
        ctx: {
          accountId: "default",
          interactionId: "ix-bind",
          conversationId: "channel-1",
          parentConversationId: "parent-1",
          guildId: "guild-1",
          senderId: "user-1",
          senderUsername: "ada",
          auth: { isAuthorizedSender: true },
          interaction: {
            kind: "button",
            messageId: "message-1",
            values: ["allow"],
          },
        },
        respond: {
          acknowledge: vi.fn(async () => {}),
          reply: vi.fn(async () => {}),
          followUp: vi.fn(async () => {}),
          editMessage: vi.fn(async () => {}),
          clearComponents: vi.fn(async () => {}),
        },
      }),
    ).resolves.toEqual({
      matched: true,
      handled: true,
      duplicate: false,
    });

    expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginId: "codex-plugin",
      pluginName: "Codex",
      pluginRoot: "/plugins/codex",
      requestedBySenderId: "user-1",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "channel-1",
        parentConversationId: "parent-1",
      },
      binding: {
        summary: "Bind Discord",
      },
    });
    expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginRoot: "/plugins/codex",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "channel-1",
        parentConversationId: "parent-1",
      },
    });
    expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginRoot: "/plugins/codex",
      conversation: {
        channel: "discord",
        accountId: "default",
        conversationId: "channel-1",
        parentConversationId: "parent-1",
      },
    });
  });

  it("wires Slack conversation binding helpers with thread context", async () => {
    const requestResult = {
      status: "bound" as const,
      binding: {
        bindingId: "binding-slack",
        pluginId: "codex-plugin",
        pluginName: "Codex",
        pluginRoot: "/plugins/codex",
        channel: "slack",
        accountId: "default",
        conversationId: "C123",
        parentConversationId: "C123",
        threadId: "1710000000.000100",
        boundAt: 1,
      },
    };
    const currentBinding = {
      ...requestResult.binding,
      boundAt: 2,
    };
    requestPluginConversationBindingMock.mockResolvedValueOnce(requestResult);
    getCurrentPluginConversationBindingMock.mockResolvedValueOnce(currentBinding);

    const handler = vi.fn(async (ctx) => {
      await expect(ctx.requestConversationBinding({ summary: "Bind Slack" })).resolves.toEqual(
        requestResult,
      );
      await expect(ctx.detachConversationBinding()).resolves.toEqual({ removed: true });
      await expect(ctx.getCurrentConversationBinding()).resolves.toEqual(currentBinding);
      return { handled: true };
    });
    expect(
      registerPluginInteractiveHandler(
        "codex-plugin",
        {
          channel: "slack",
          namespace: "codex",
          handler,
        },
        { pluginName: "Codex", pluginRoot: "/plugins/codex" },
      ),
    ).toEqual({ ok: true });

    await expect(
      dispatchPluginInteractiveHandler({
        channel: "slack",
        data: "codex:bind",
        interactionId: "slack-bind",
        ctx: {
          accountId: "default",
          interactionId: "slack-bind",
          conversationId: "C123",
          parentConversationId: "C123",
          threadId: "1710000000.000100",
          senderId: "user-1",
          senderUsername: "ada",
          auth: { isAuthorizedSender: true },
          interaction: {
            kind: "button",
            actionId: "codex",
            blockId: "codex_actions",
            messageTs: "1710000000.000200",
            threadTs: "1710000000.000100",
            value: "bind",
            selectedValues: ["bind"],
            selectedLabels: ["Bind"],
            triggerId: "trigger-1",
            responseUrl: "https://hooks.slack.test/response",
          },
        },
        respond: {
          acknowledge: vi.fn(async () => {}),
          reply: vi.fn(async () => {}),
          followUp: vi.fn(async () => {}),
          editMessage: vi.fn(async () => {}),
        },
      }),
    ).resolves.toEqual({
      matched: true,
      handled: true,
      duplicate: false,
    });

    expect(requestPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginId: "codex-plugin",
      pluginName: "Codex",
      pluginRoot: "/plugins/codex",
      requestedBySenderId: "user-1",
      conversation: {
        channel: "slack",
        accountId: "default",
        conversationId: "C123",
        parentConversationId: "C123",
        threadId: "1710000000.000100",
      },
      binding: {
        summary: "Bind Slack",
      },
    });
    expect(detachPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginRoot: "/plugins/codex",
      conversation: {
        channel: "slack",
        accountId: "default",
        conversationId: "C123",
        parentConversationId: "C123",
        threadId: "1710000000.000100",
      },
    });
    expect(getCurrentPluginConversationBindingMock).toHaveBeenCalledWith({
      pluginRoot: "/plugins/codex",
      conversation: {
        channel: "slack",
        accountId: "default",
        conversationId: "C123",
        parentConversationId: "C123",
        threadId: "1710000000.000100",
      },
    });
  });

  it("does not consume dedupe keys when a handler throws", async () => {
    const handler = vi
      .fn(async () => ({ handled: true }))
      .mockRejectedValueOnce(new Error("boom"))
      .mockResolvedValueOnce({ handled: true });
    expect(
      registerPluginInteractiveHandler("codex-plugin", {
        channel: "telegram",
        namespace: "codex",
        handler,
      }),
    ).toEqual({ ok: true });

    const baseParams = {
      channel: "telegram" as const,
      data: "codex:resume:thread-1",
      callbackId: "cb-throw",
      ctx: {
        accountId: "default",
        callbackId: "cb-throw",
        conversationId: "-10099:topic:77",
        parentConversationId: "-10099",
        senderId: "user-1",
        senderUsername: "ada",
        threadId: 77,
        isGroup: true,
        isForum: true,
        auth: { isAuthorizedSender: true },
        callbackMessage: {
          messageId: 55,
          chatId: "-10099",
          messageText: "Pick a thread",
        },
      },
      respond: {
        reply: vi.fn(async () => {}),
        editMessage: vi.fn(async () => {}),
        editButtons: vi.fn(async () => {}),
        clearButtons: vi.fn(async () => {}),
        deleteMessage: vi.fn(async () => {}),
      },
    };

    await expect(dispatchPluginInteractiveHandler(baseParams)).rejects.toThrow("boom");
    await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({
      matched: true,
      handled: true,
      duplicate: false,
    });
    expect(handler).toHaveBeenCalledTimes(2);
  });
});
