import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSentMessageCache } from "./echo-cache.js";
import { resolveIMessageInboundDecision } from "./inbound-processing.js";
import { createSelfChatCache } from "./self-chat-cache.js";

/**
 * Self-chat dedupe regression tests for #47830.
 *
 * PR #38440 introduced a SentMessageCache to suppress echo messages when the
 * agent replies in iMessage. In self-chat (user messaging themselves), the
 * sender == target so the echo scope collides, causing legitimate user
 * messages to be silently dropped when text happens to match recent agent
 * output.
 *
 * These tests verify:
 *  1. User messages in self-chat are NOT dropped (even if text matches agent output)
 *  2. Genuine agent echo reflections ARE still dropped
 *  3. Different-text messages pass through unaffected
 *  4. Chunked replies don't cause false drops of user messages matching a chunk
 */

type InboundDecisionParams = Parameters<typeof resolveIMessageInboundDecision>[0];

const cfg = {} as OpenClawConfig;

function createParams(
  overrides: Omit<Partial<InboundDecisionParams>, "message"> & {
    message?: Partial<InboundDecisionParams["message"]>;
  } = {},
): InboundDecisionParams {
  const { message: msgOverrides, ...restOverrides } = overrides;
  const message = {
    id: 100,
    sender: "+15551234567",
    text: "Hello",
    is_from_me: false,
    is_group: false,
    ...msgOverrides,
  };
  const messageText = restOverrides.messageText ?? message.text ?? "";
  const bodyText = restOverrides.bodyText ?? messageText;
  return {
    cfg,
    accountId: "default",
    opts: undefined,
    allowFrom: [],
    groupAllowFrom: [],
    groupPolicy: "open",
    dmPolicy: "open",
    storeAllowFrom: [],
    historyLimit: 0,
    groupHistories: new Map(),
    echoCache: undefined,
    selfChatCache: undefined,
    logVerbose: undefined,
    ...restOverrides,
    message,
    messageText,
    bodyText,
  };
}

describe("echo cache — message ID type canary (#47830)", () => {
  // Tests the implicit contract that outbound GUIDs (e.g. "p:0/abc-def-123")
  // never match inbound SQLite row IDs (e.g. "200"). If iMessage ever changes
  // ID schemes, this test should break loudly.
  it("outbound GUID format and inbound SQLite row ID format never collide", () => {
    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    // Outbound messageId is a GUID format string
    echoCache.remember(scope, { text: "test", messageId: "p:0/abc-def-123" });

    // An inbound SQLite row ID (numeric string) should NOT match the GUID
    expect(echoCache.has(scope, { text: "different", messageId: "200" })).toBe(false);

    // The original GUID should still match
    expect(echoCache.has(scope, { text: "different", messageId: "p:0/abc-def-123" })).toBe(true);
  });

  it('falls back to text when outbound messageId was junk ("ok")', () => {
    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    // "ok" is normalized out and should not populate the ID cache.
    echoCache.remember(scope, { text: "text-only fallback", messageId: "ok" });

    // Inbound has a numeric SQLite ID that does not exist in cache. Since this
    // scope has no real cached IDs, has() must still fall through to text match.
    expect(echoCache.has(scope, { text: "text-only fallback", messageId: "200" })).toBe(true);
  });

  it("keeps ID short-circuit when scope has real outbound GUID IDs", () => {
    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    echoCache.remember(scope, { text: "guid-backed", messageId: "p:0/abc-def-123" });

    // Different inbound numeric ID should still short-circuit to false.
    expect(echoCache.has(scope, { text: "guid-backed", messageId: "200" })).toBe(false);
  });
});

describe("echo cache — backward compat for channels without messageId", () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  // Proves text-fallback echo detection still works when no messageId is present
  // on either side. Critical for backward compat with channels that don't
  // populate messageId.
  it("text-only remember/has works within TTL", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    echoCache.remember(scope, { text: "no id message" });

    vi.advanceTimersByTime(2000);
    expect(echoCache.has(scope, { text: "no id message" })).toBe(true);
  });

  it("text-only has returns false after TTL expiry", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    echoCache.remember(scope, { text: "no id message" });

    vi.advanceTimersByTime(5000);
    expect(echoCache.has(scope, { text: "no id message" })).toBe(false);
  });

  it("text-only has returns false for different text", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    echoCache.remember(scope, { text: "no id message" });

    vi.advanceTimersByTime(1000);
    expect(echoCache.has(scope, { text: "totally different text" })).toBe(false);
  });
});

describe("self-chat dedupe — #47830", () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it("does NOT drop a user message that matches recently-sent agent text (self-chat scope collision)", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const selfChatCache = createSelfChatCache();

    // Agent sends "Hello" to self-chat target +15551234567
    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });

    // 2 seconds later, user sends "Hello" to themselves (different message id)
    vi.advanceTimersByTime(2000);

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 200,
          sender: "+15551234567",
          text: "Hello",
          is_from_me: false,
        },
        messageText: "Hello",
        bodyText: "Hello",
        echoCache,
        selfChatCache,
      }),
    );

    // BUG: Before fix, this was "drop" reason "echo" — user message silently lost.
    // After fix: message-id mismatch means this is NOT an echo.
    // The echo cache should only match when message IDs match OR when text
    // matches and no message ID is available on inbound.
    expect(decision.kind).toBe("dispatch");
  });

  it("DOES drop genuine agent echo (same message id reflected back)", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();

    // Agent sends "Hello" to target
    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });

    // 1 second later, iMessage reflects it back with same message id
    vi.advanceTimersByTime(1000);

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: "agent-msg-1" as unknown as number,
          sender: "+15551234567",
          text: "Hello",
          is_from_me: false,
        },
        messageText: "Hello",
        bodyText: "Hello",
        echoCache,
      }),
    );

    expect(decision).toEqual({ kind: "drop", reason: "echo" });
  });

  it("does NOT drop different-text messages even within TTL", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();

    // Agent sends "Hello"
    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });

    vi.advanceTimersByTime(1000);

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 201,
          sender: "+15551234567",
          text: "Goodbye",
          is_from_me: false,
        },
        messageText: "Goodbye",
        bodyText: "Goodbye",
        echoCache,
      }),
    );

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

  it("does NOT drop user messages that match a chunk of a multi-chunk agent reply", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15551234567";

    // Agent sends a multi-chunk reply: "Part one", "Part two", "Part three"
    echoCache.remember(scope, { text: "Part one", messageId: "agent-chunk-1" });
    echoCache.remember(scope, { text: "Part two", messageId: "agent-chunk-2" });
    echoCache.remember(scope, { text: "Part three", messageId: "agent-chunk-3" });

    vi.advanceTimersByTime(2000);

    // User sends "Part two" (matches chunk 2 text, but different message id)
    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 300,
          sender: "+15551234567",
          text: "Part two",
          is_from_me: false,
        },
        messageText: "Part two",
        bodyText: "Part two",
        echoCache,
      }),
    );

    // Should NOT be dropped — different message id means not an echo
    expect(decision.kind).toBe("dispatch");
  });

  it("drops echo after text TTL expiry (4s TTL: expired at 5s)", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    // Agent sends text (no message id available)
    echoCache.remember(scope, { text: "Hello there" });

    // After 5 seconds — beyond the 4s TTL, should NOT match
    vi.advanceTimersByTime(5000);

    const result = echoCache.has(scope, { text: "Hello there" });
    expect(result).toBe(false);
  });

  // Safe failure mode: TTL expiry causes duplicate delivery (noisy), never message loss (lossy)
  it("does NOT catch echo after TTL expiry — safe failure mode is duplicate delivery", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15551234567";

    // Agent sends "Delayed echo test"
    echoCache.remember(scope, { text: "Delayed echo test", messageId: "agent-msg-delayed" });

    // 4.5 seconds later — beyond 4s TTL
    vi.advanceTimersByTime(4500);

    // Echo arrives with no messageId (text-only fallback path)
    const result = echoCache.has(scope, { text: "Delayed echo test" });

    // TTL expired → not caught → duplicate delivery (noisy but safe, not lossy)
    expect(result).toBe(false);
  });

  it("still drops text echo within 4s TTL window", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    echoCache.remember(scope, { text: "Hello there" });

    // After 3 seconds — within the 4s TTL, should still match
    vi.advanceTimersByTime(3000);

    const result = echoCache.has(scope, { text: "Hello there" });
    expect(result).toBe(true);
  });
});

describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
  afterEach(() => {
    vi.useRealTimers();
  });

  it("processes real user self-chat message (is_from_me=true, no echo cache match)", () => {
    // User sends "Hello" to themselves — is_from_me=true, sender==chat_identifier
    const echoCache = createSentMessageCache();
    const selfChatCache = createSelfChatCache();

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 123703,
          sender: "+15551234567",
          chat_identifier: "+15551234567",
          text: "Hello this is a test message",
          is_from_me: true,
          is_group: false,
        },
        messageText: "Hello this is a test message",
        bodyText: "Hello this is a test message",
        echoCache,
        selfChatCache,
      }),
    );

    // Real user message — should be dispatched, not dropped
    expect(decision.kind).toBe("dispatch");
  });

  it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const selfChatCache = createSelfChatCache();

    // Agent sends "Hi there!" to self-chat
    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "Hi there!", messageId: "p:0/GUID-abc-def" });

    // 1 second later, iMessage delivers the agent reply as is_from_me=true
    // with a SQLite row ID (never matches the GUID)
    vi.advanceTimersByTime(1000);

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 123706,
          guid: "p:0/GUID-abc-def",
          sender: "+15551234567",
          chat_identifier: "+15551234567",
          text: "Hi there!",
          is_from_me: true,
          is_group: false,
        },
        messageText: "Hi there!",
        bodyText: "Hi there!",
        echoCache,
        selfChatCache,
      }),
    );

    // Agent echo — should be dropped
    expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
  });

  it("drops attachment-only agent echo in self-chat via bodyText placeholder", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const selfChatCache = createSelfChatCache();

    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "<media:image>", messageId: "p:0/GUID-media" });

    vi.advanceTimersByTime(1000);

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 123707,
          guid: "p:0/GUID-media",
          sender: "+15551234567",
          chat_identifier: "+15551234567",
          text: "",
          is_from_me: true,
          is_group: false,
        },
        messageText: "",
        bodyText: "<media:image>",
        echoCache,
        selfChatCache,
      }),
    );

    expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
  });

  it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const selfChatCache = createSelfChatCache();

    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "Numeric id echo", messageId: "123709" });

    vi.advanceTimersByTime(1000);

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 123709,
          guid: "p:0/GUID-different-shape",
          sender: "+15551234567",
          chat_identifier: "+15551234567",
          text: "Numeric id echo",
          is_from_me: true,
          is_group: false,
        },
        messageText: "Numeric id echo",
        bodyText: "Numeric id echo",
        echoCache,
        selfChatCache,
      }),
    );

    expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
  });

  it("does not drop a real self-chat image just because a recent agent image used the same placeholder", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const selfChatCache = createSelfChatCache();

    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "<media:image>", messageId: "p:0/GUID-agent-image" });

    vi.advanceTimersByTime(1000);

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 123708,
          guid: "p:0/GUID-user-image",
          sender: "+15551234567",
          chat_identifier: "+15551234567",
          text: "",
          is_from_me: true,
          is_group: false,
        },
        messageText: "",
        bodyText: "<media:image>",
        echoCache,
        selfChatCache,
      }),
    );

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

  it("drops is_from_me=false reflection via selfChatCache (existing behavior preserved)", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const selfChatCache = createSelfChatCache();
    const createdAt = "2026-03-24T12:00:00.000Z";

    // Step 1: is_from_me=true copy arrives (real user message) → processed, selfChatCache populated
    const first = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 123703,
          sender: "+15551234567",
          chat_identifier: "+15551234567",
          text: "Hello",
          created_at: createdAt,
          is_from_me: true,
          is_group: false,
        },
        messageText: "Hello",
        bodyText: "Hello",
        selfChatCache,
      }),
    );
    expect(first.kind).toBe("dispatch");

    // Step 2: is_from_me=false reflection arrives 2s later with same text+createdAt
    vi.advanceTimersByTime(2200);
    const second = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 123704,
          sender: "+15551234567",
          chat_identifier: "+15551234567",
          text: "Hello",
          created_at: createdAt,
          is_from_me: false,
          is_group: false,
        },
        messageText: "Hello",
        bodyText: "Hello",
        selfChatCache,
      }),
    );
    // Reflection correctly dropped
    expect(second).toEqual({ kind: "drop", reason: "self-chat echo" });
  });

  it("normal DM is_from_me=true is still dropped (regression test)", () => {
    const selfChatCache = createSelfChatCache();

    // Normal DM with is_from_me=true: in iMessage, sender is the local user's
    // handle and chat_identifier is the OTHER person's handle. They differ,
    // so this is NOT self-chat.
    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: 9999,
          sender: "+15551234567", // local user sent this
          chat_identifier: "+15555550123", // sent TO this other person
          text: "Hello",
          is_from_me: true,
          is_group: false,
        },
        messageText: "Hello",
        bodyText: "Hello",
        selfChatCache,
      }),
    );

    // sender != chat_identifier → not self-chat → dropped as "from me"
    expect(decision).toEqual({ kind: "drop", reason: "from me" });
  });

  it("echo cache text matching works with skipIdShortCircuit=true", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "Cached reply", messageId: "p:0/some-guid" });

    vi.advanceTimersByTime(1000);

    // Text matches but ID is a SQLite row (format mismatch). With skipIdShortCircuit=true,
    // text matching should still fire.
    expect(echoCache.has(scope, { text: "Cached reply", messageId: "123799" }, true)).toBe(true);

    // With skipIdShortCircuit=false (default), ID mismatch causes early return false.
    expect(echoCache.has(scope, { text: "Cached reply", messageId: "123799" }, false)).toBe(false);
  });
});

describe("echo cache — text fallback for null-id inbound messages", () => {
  it("still identifies echo via text when inbound message has id: null", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

    const echoCache = createSentMessageCache();
    const selfChatCache = createSelfChatCache();

    // Agent sends "Sounds good" — no messageId available (edge case)
    const scope = "default:imessage:+15551234567";
    echoCache.remember(scope, { text: "Sounds good" });

    // 1 second later, inbound reflection arrives with id: null
    vi.advanceTimersByTime(1000);

    const decision = resolveIMessageInboundDecision(
      createParams({
        message: {
          id: null as unknown as number,
          sender: "+15551234567",
          text: "Sounds good",
          is_from_me: false,
        },
        messageText: "Sounds good",
        bodyText: "Sounds good",
        echoCache,
        selfChatCache,
      }),
    );

    // With id: null, the text-based fallback path is still active and should
    // correctly identify this as an echo.
    expect(decision).toEqual({ kind: "drop", reason: "echo" });
  });
});

describe("echo cache — mixed GUID and text-only scopes", () => {
  it("still falls back to text for the latest text-only send in a scope with older GUID-backed sends", () => {
    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    echoCache.remember(scope, { text: "older guid-backed", messageId: "p:0/GUID-older" });
    echoCache.remember(scope, { text: "latest text-only", messageId: "unknown" });

    expect(echoCache.has(scope, { text: "latest text-only", messageId: "200" })).toBe(true);
  });

  it("still short-circuits when the latest copy of a text was GUID-backed", () => {
    const echoCache = createSentMessageCache();
    const scope = "default:imessage:+15555550123";

    echoCache.remember(scope, { text: "same text", messageId: "unknown" });
    echoCache.remember(scope, { text: "same text", messageId: "p:0/GUID-newer" });

    expect(echoCache.has(scope, { text: "same text", messageId: "200" })).toBe(false);
  });
});
