// Imessage tests cover monitor.gating plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it } from "vitest";
import { resetIMessageShortIdState } from "./monitor-reply-cache.js";
import {
  buildIMessageInboundContext,
  resolveIMessageInboundDecision,
} from "./monitor/inbound-processing.js";
import { parseIMessageNotification } from "./monitor/parse-notification.js";
import type { IMessagePayload } from "./monitor/types.js";
import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js";

beforeEach(() => {
  installIMessageStateRuntimeForTest();
  resetIMessageShortIdState();
});

function baseCfg(): OpenClawConfig {
  return {
    channels: {
      imessage: {
        dmPolicy: "open",
        allowFrom: ["*"],
        groupPolicy: "open",
        groups: { "*": { requireMention: true } },
      },
    },
    session: { mainKey: "main" },
    messages: {
      groupChat: { mentionPatterns: ["@openclaw"] },
    },
  } as unknown as OpenClawConfig;
}

async function resolve(params: {
  cfg?: OpenClawConfig;
  message: IMessagePayload;
  storeAllowFrom?: string[];
}) {
  const cfg = params.cfg ?? baseCfg();
  const groupHistories = new Map();
  return resolveIMessageInboundDecision({
    cfg,
    accountId: "default",
    message: params.message,
    opts: {},
    messageText: (params.message.text ?? "").trim(),
    bodyText: (params.message.text ?? "").trim(),
    allowFrom: ["*"],
    groupAllowFrom: [],
    groupPolicy: cfg.channels?.imessage?.groupPolicy ?? "open",
    dmPolicy: cfg.channels?.imessage?.dmPolicy ?? "pairing",
    storeAllowFrom: params.storeAllowFrom ?? [],
    historyLimit: 0,
    groupHistories,
  });
}

async function resolveDispatchDecision(params: {
  cfg: OpenClawConfig;
  message: IMessagePayload;
  groupHistories?: Parameters<typeof resolveIMessageInboundDecision>[0]["groupHistories"];
  allowFrom?: string[];
  groupAllowFrom?: string[];
  allowLegacyConversationAllowFromForGroup?: boolean;
  groupPolicy?: "open" | "allowlist" | "disabled";
  dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
}) {
  const groupHistories = params.groupHistories ?? new Map();
  const decision = await resolveIMessageInboundDecision({
    cfg: params.cfg,
    accountId: "default",
    message: params.message,
    opts: {},
    messageText: params.message.text ?? "",
    bodyText: params.message.text ?? "",
    allowFrom: params.allowFrom ?? ["*"],
    groupAllowFrom: params.groupAllowFrom ?? [],
    allowLegacyConversationAllowFromForGroup: params.allowLegacyConversationAllowFromForGroup,
    groupPolicy: params.groupPolicy ?? "open",
    dmPolicy: params.dmPolicy ?? "open",
    storeAllowFrom: [],
    historyLimit: 0,
    groupHistories,
  });
  expect(decision.kind).toBe("dispatch");
  if (decision.kind !== "dispatch") {
    throw new Error("expected dispatch decision");
  }
  return { decision, groupHistories };
}

async function buildDispatchContextPayload(params: {
  cfg: OpenClawConfig;
  message: IMessagePayload;
}) {
  const { cfg, message } = params;
  const { decision, groupHistories } = await resolveDispatchDecision({ cfg, message });

  const { ctxPayload } = await buildIMessageInboundContext({
    cfg,
    decision,
    message,
    historyLimit: 0,
    groupHistories,
  });

  return ctxPayload;
}

describe("imessage monitor gating + envelope builders", () => {
  it("parseIMessageNotification rejects malformed payloads", async () => {
    expect(
      parseIMessageNotification({
        message: { chat_id: 1, sender: { nested: "nope" } },
      }),
    ).toBeNull();
  });

  it("parseIMessageNotification preserves destination_caller_id metadata", async () => {
    expect(
      parseIMessageNotification({
        message: {
          id: 1,
          sender: "+15550001111",
          destination_caller_id: "+15550002222",
          is_from_me: true,
          text: "hello",
        },
      }),
    ).toEqual({
      id: 1,
      sender: "+15550001111",
      destination_caller_id: "+15550002222",
      is_from_me: true,
      text: "hello",
    });
  });

  it("drops group messages without mention by default", async () => {
    const decision = await resolve({
      message: {
        id: 1,
        chat_id: 99,
        sender: "+15550001111",
        is_from_me: false,
        text: "hello group",
        is_group: true,
      },
    });
    expect(decision.kind).toBe("drop");
    if (decision.kind !== "drop") {
      throw new Error("expected drop decision");
    }
    expect(decision.reason).toBe("no mention");
  });

  it("dispatches group messages with mention and builds a group envelope", async () => {
    const cfg = baseCfg();
    const message: IMessagePayload = {
      id: 3,
      chat_id: 42,
      sender: "+15550002222",
      is_from_me: false,
      text: "@openclaw ping",
      is_group: true,
      chat_name: "Lobster Squad",
      participants: ["+1555", "+1556"],
    };
    const ctxPayload = await buildDispatchContextPayload({ cfg, message });

    expect(ctxPayload.ChatType).toBe("group");
    expect(ctxPayload.SessionKey).toBe("agent:main:imessage:group:42");
    expect(ctxPayload.Body ?? "").toContain("+15550002222:");
    expect(ctxPayload.Body ?? "").not.toContain("[from:");
    expect(ctxPayload.To).toBe("chat_id:42");
  });

  it("uses short message ids in context and keeps the full guid for actions", async () => {
    const cfg = baseCfg();
    const message: IMessagePayload = {
      id: 3,
      guid: "full-message-guid",
      chat_id: 42,
      chat_guid: "iMessage;+;chat0000",
      chat_identifier: "thread-42",
      sender: "+15550002222",
      is_from_me: false,
      text: "@openclaw ping",
      is_group: true,
      chat_name: "Lobster Squad",
      participants: ["+1555", "+1556"],
    };
    const ctxPayload = await buildDispatchContextPayload({ cfg, message });

    expect(ctxPayload.MessageSid).toBe("1");
    expect(ctxPayload.MessageSidFull).toBe("full-message-guid");
  });

  it("includes reply-to context fields + suffix", async () => {
    const cfg = baseCfg();
    const message: IMessagePayload = {
      id: 5,
      chat_id: 55,
      sender: "+15550001111",
      is_from_me: false,
      text: "replying now",
      is_group: false,
      reply_to_id: 9001,
      reply_to_text: "original message",
      reply_to_sender: "+15559998888",
    };
    const ctxPayload = await buildDispatchContextPayload({ cfg, message });

    expect(ctxPayload.ReplyToId).toBe("9001");
    expect(ctxPayload.ReplyToBody).toBe("original message");
    expect(ctxPayload.ReplyToSender).toBe("+15559998888");
    expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
    expect(ctxPayload.Body ?? "").toContain("original message");
  });

  it("drops group reply context from non-allowlisted senders in allowlist mode", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";
    cfg.channels.imessage.contextVisibility = "allowlist";

    const message: IMessagePayload = {
      id: 6,
      chat_id: 55,
      sender: "+15550001111",
      is_from_me: false,
      text: "@openclaw replying now",
      is_group: true,
      reply_to_id: 9001,
      reply_to_text: "blocked quote",
      reply_to_sender: "+15559998888",
    };
    const { decision, groupHistories } = await resolveDispatchDecision({
      cfg,
      message,
      allowFrom: ["*"],
      groupAllowFrom: ["+15550001111"],
      groupPolicy: "allowlist",
    });
    const { ctxPayload } = await buildIMessageInboundContext({
      cfg,
      decision,
      message,
      historyLimit: 0,
      groupHistories,
    });

    expect(ctxPayload.ReplyToId).toBeUndefined();
    expect(ctxPayload.ReplyToBody).toBeUndefined();
    expect(ctxPayload.ReplyToSender).toBeUndefined();
    expect(ctxPayload.Body ?? "").not.toContain("[Replying to");
  });

  it("keeps group reply context when the group allowlist matches the chat target", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";
    cfg.channels.imessage.contextVisibility = "allowlist";

    const message: IMessagePayload = {
      id: 8,
      chat_id: 55,
      sender: "+15550001111",
      is_from_me: false,
      text: "@openclaw replying now",
      is_group: true,
      reply_to_id: 9001,
      reply_to_text: "quoted context",
      reply_to_sender: "+15559998888",
    };
    const { decision, groupHistories } = await resolveDispatchDecision({
      cfg,
      message,
      allowFrom: ["*"],
      groupAllowFrom: ["chat_id:55"],
      groupPolicy: "allowlist",
    });
    const { ctxPayload } = await buildIMessageInboundContext({
      cfg,
      decision,
      message,
      historyLimit: 0,
      groupHistories,
    });

    expect(ctxPayload.ReplyToId).toBe("9001");
    expect(ctxPayload.ReplyToBody).toBe("quoted context");
    expect(ctxPayload.ReplyToSender).toBe("+15559998888");
    expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
  });

  it("keeps group reply context when the group allowlist matches an access group", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";
    cfg.channels.imessage.contextVisibility = "allowlist";
    cfg.accessGroups = {
      oncall: {
        type: "message.senders",
        members: { imessage: ["+15559998888"] },
      },
    };

    const message: IMessagePayload = {
      id: 9,
      chat_id: 56,
      sender: "+15559998888",
      is_from_me: false,
      text: "@openclaw replying now",
      is_group: true,
      reply_to_id: 9002,
      reply_to_text: "own quoted context",
      reply_to_sender: "+15559998888",
    };
    const { decision, groupHistories } = await resolveDispatchDecision({
      cfg,
      message,
      allowFrom: ["*"],
      groupAllowFrom: ["accessGroup:oncall"],
      groupPolicy: "allowlist",
    });
    const { ctxPayload } = await buildIMessageInboundContext({
      cfg,
      decision,
      message,
      historyLimit: 0,
      groupHistories,
    });

    expect(ctxPayload.ReplyToId).toBe("9002");
    expect(ctxPayload.ReplyToBody).toBe("own quoted context");
    expect(ctxPayload.ReplyToSender).toBe("+15559998888");
    expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9002]");
  });

  it("keeps group reply context in allowlist_quote mode", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";
    cfg.channels.imessage.contextVisibility = "allowlist_quote";

    const message: IMessagePayload = {
      id: 7,
      chat_id: 55,
      sender: "+15550001111",
      is_from_me: false,
      text: "@openclaw replying now",
      is_group: true,
      reply_to_id: 9001,
      reply_to_text: "quoted context",
      reply_to_sender: "+15559998888",
    };
    const { decision, groupHistories } = await resolveDispatchDecision({
      cfg,
      message,
      allowFrom: ["*"],
      groupAllowFrom: ["+15550001111"],
      groupPolicy: "allowlist",
    });
    const { ctxPayload } = await buildIMessageInboundContext({
      cfg,
      decision,
      message,
      historyLimit: 0,
      groupHistories,
    });

    expect(ctxPayload.ReplyToId).toBe("9001");
    expect(ctxPayload.ReplyToBody).toBe("quoted context");
    expect(ctxPayload.ReplyToSender).toBe("+15559998888");
    expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
  });

  it("treats configured chat_id as a group session even when is_group is false", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groups = { "2": { requireMention: false } };

    const groupHistories = new Map();
    const message: IMessagePayload = {
      id: 14,
      chat_id: 2,
      sender: "+15550001111",
      is_from_me: false,
      text: "hello",
      is_group: false,
    };
    const { decision } = await resolveDispatchDecision({ cfg, message, groupHistories });
    expect(decision.isGroup).toBe(true);
    expect(decision.route.sessionKey).toBe("agent:main:imessage:group:2");
  });

  it("allows group messages when requireMention is true but no mentionPatterns exist", async () => {
    const cfg = baseCfg();
    cfg.messages ??= {};
    cfg.messages.groupChat ??= {};
    cfg.messages.groupChat.mentionPatterns = [];

    const groupHistories = new Map();
    const decision = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 12,
        chat_id: 777,
        sender: "+15550001111",
        is_from_me: false,
        text: "hello group",
        is_group: true,
      },
      opts: {},
      messageText: "hello group",
      bodyText: "hello group",
      allowFrom: ["*"],
      groupAllowFrom: [],
      groupPolicy: "open",
      dmPolicy: "open",
      storeAllowFrom: [],
      historyLimit: 0,
      groupHistories,
    });
    expect(decision.kind).toBe("dispatch");
  });

  it("blocks group messages when imessage.groups is set without a wildcard", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groups = { "99": { requireMention: false } };

    const groupHistories = new Map();
    const decision = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 13,
        chat_id: 123,
        sender: "+15550001111",
        is_from_me: false,
        text: "@openclaw hello",
        is_group: true,
      },
      opts: {},
      messageText: "@openclaw hello",
      bodyText: "@openclaw hello",
      allowFrom: ["*"],
      groupAllowFrom: [],
      groupPolicy: "open",
      dmPolicy: "open",
      storeAllowFrom: [],
      historyLimit: 0,
      groupHistories,
    });
    expect(decision.kind).toBe("drop");
  });

  it("honors group allowlist and ignores pairing-store senders in groups", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";

    const groupHistories = new Map();
    const denied = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 3,
        chat_id: 202,
        sender: "+15550003333",
        is_from_me: false,
        text: "@openclaw hi",
        is_group: true,
      },
      opts: {},
      messageText: "@openclaw hi",
      bodyText: "@openclaw hi",
      allowFrom: ["*"],
      groupAllowFrom: ["chat_id:101"],
      groupPolicy: "allowlist",
      dmPolicy: "pairing",
      storeAllowFrom: ["+15550003333"],
      historyLimit: 0,
      groupHistories,
    });
    expect(denied.kind).toBe("drop");

    const allowed = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 33,
        chat_id: 101,
        sender: "+15550003333",
        is_from_me: false,
        text: "@openclaw ok",
        is_group: true,
      },
      opts: {},
      messageText: "@openclaw ok",
      bodyText: "@openclaw ok",
      allowFrom: ["*"],
      groupAllowFrom: ["chat_id:101"],
      groupPolicy: "allowlist",
      dmPolicy: "pairing",
      storeAllowFrom: ["+15550003333"],
      historyLimit: 0,
      groupHistories,
    });
    expect(allowed.kind).toBe("dispatch");
  });

  it("uses legacy conversation allowFrom entries for group admission", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";

    const { decision } = await resolveDispatchDecision({
      cfg,
      message: {
        id: 35,
        chat_id: 101,
        sender: "+15550003333",
        is_from_me: false,
        text: "@openclaw ok",
        is_group: true,
      },
      allowFrom: ["chat_id:101"],
      groupAllowFrom: [],
      allowLegacyConversationAllowFromForGroup: true,
      groupPolicy: "allowlist",
    });

    expect(decision.kind).toBe("dispatch");
  });

  it("does not use legacy conversation allowFrom entries when groupAllowFrom is explicitly empty", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";

    const decision = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 38,
        chat_id: 101,
        sender: "+15550003333",
        is_from_me: false,
        text: "@openclaw ok",
        is_group: true,
      },
      opts: {},
      messageText: "@openclaw ok",
      bodyText: "@openclaw ok",
      allowFrom: ["chat_id:101"],
      groupAllowFrom: [],
      groupPolicy: "allowlist",
      dmPolicy: "pairing",
      storeAllowFrom: [],
      historyLimit: 0,
      groupHistories: new Map(),
    });

    expect(decision).toEqual({
      kind: "drop",
      reason: "groupPolicy allowlist (empty groupAllowFrom)",
    });
  });

  it("does not merge legacy conversation allowFrom entries when groupAllowFrom is configured", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";

    const decision = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 37,
        chat_id: 101,
        sender: "+15550003333",
        is_from_me: false,
        text: "@openclaw ok",
        is_group: true,
      },
      opts: {},
      messageText: "@openclaw ok",
      bodyText: "@openclaw ok",
      allowFrom: ["chat_id:101"],
      groupAllowFrom: ["+15550004444"],
      groupPolicy: "allowlist",
      dmPolicy: "pairing",
      storeAllowFrom: [],
      historyLimit: 0,
      groupHistories: new Map(),
    });

    expect(decision).toEqual({ kind: "drop", reason: "not in groupAllowFrom" });
  });

  it("does not authorize group control commands from conversation allowlist entries", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";

    const decision = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 34,
        chat_id: 101,
        sender: "+15550003333",
        is_from_me: false,
        text: "/status",
        is_group: true,
      },
      opts: {},
      messageText: "/status",
      bodyText: "/status",
      allowFrom: [],
      groupAllowFrom: ["chat_id:101"],
      groupPolicy: "allowlist",
      dmPolicy: "pairing",
      storeAllowFrom: [],
      historyLimit: 0,
      groupHistories: new Map(),
    });

    expect(decision).toEqual({ kind: "drop", reason: "control command (unauthorized)" });
  });

  it("does not authorize group control commands from legacy conversation allowFrom entries", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "allowlist";

    const decision = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 36,
        chat_id: 101,
        sender: "+15550003333",
        is_from_me: false,
        text: "/status",
        is_group: true,
      },
      opts: {},
      messageText: "/status",
      bodyText: "/status",
      allowFrom: ["chat_id:101"],
      groupAllowFrom: [],
      allowLegacyConversationAllowFromForGroup: true,
      groupPolicy: "allowlist",
      dmPolicy: "pairing",
      storeAllowFrom: [],
      historyLimit: 0,
      groupHistories: new Map(),
    });

    expect(decision).toEqual({ kind: "drop", reason: "control command (unauthorized)" });
  });

  it("blocks group messages when groupPolicy is disabled", async () => {
    const cfg = baseCfg();
    cfg.channels ??= {};
    cfg.channels.imessage ??= {};
    cfg.channels.imessage.groupPolicy = "disabled";

    const groupHistories = new Map();
    const decision = await resolveIMessageInboundDecision({
      cfg,
      accountId: "default",
      message: {
        id: 10,
        chat_id: 303,
        sender: "+15550003333",
        is_from_me: false,
        text: "@openclaw hi",
        is_group: true,
      },
      opts: {},
      messageText: "@openclaw hi",
      bodyText: "@openclaw hi",
      allowFrom: ["*"],
      groupAllowFrom: [],
      groupPolicy: "disabled",
      dmPolicy: "open",
      storeAllowFrom: [],
      historyLimit: 0,
      groupHistories,
    });
    expect(decision.kind).toBe("drop");
  });
});
