import { mock, describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test";
import { mockLogger } from "../_helpers/mock-logger.js";
import { SLACK_MENTION } from "@/core/constants.js";

// ── Module mocks must be declared BEFORE the module under test is imported ──

const mockGetSlackThread = mock(() => null as { channelId: string; threadTs: string } | null);
const mockSaveSlackThread = mock(() => {});

mock.module("@/slack/message-store.js", () => ({
  getSlackThread: mockGetSlackThread,
  saveSlackThread: mockSaveSlackThread,
}));

const { SlackNotifier } = await import("@/slack/client.js");

// ── Helpers ──────────────────────────────────────────────────────────────────

/** Creates a minimal fetch Response mock. */
function mockResponse(
  body: Record<string, unknown>,
  status = 200,
  headers: Record<string, string> = {},
): Response {
  return {
    ok: status >= 200 && status < 300,
    status,
    json: async () => body,
    headers: {
      get: (name: string) => headers[name] ?? null,
    },
  } as unknown as Response;
}

const SLACK_OK = { ok: true, ts: "1234.5678" };
const SLACK_ERR = { ok: false, error: "channel_not_found" };

function makeNotifier(
  token = "xoxb-test",
  channelId = "C01TEST",
  emittedBy = "test-bot",
  sandboxMode = false,
): InstanceType<typeof SlackNotifier> {
  return new SlackNotifier(token, channelId, emittedBy, mockLogger(), sandboxMode);
}

function makeDevNotifier(
  devChannel = "C0DEV",
  prefix = "[DEV]",
): InstanceType<typeof SlackNotifier> {
  return new SlackNotifier("xoxb-test", "C01TEST", "test-bot", mockLogger(), false, {
    channelId: devChannel,
    prefix,
  });
}

// ── notifyTicketStarted ───────────────────────────────────────────────────────

describe("SlackNotifier.notifyTicketStarted", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
    mockGetSlackThread.mockReset();
    mockSaveSlackThread.mockReset();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts to chat.postMessage with JSON body on happy path", async () => {
    const notifier = makeNotifier();
    await notifier.notifyTicketStarted(
      "SQDP-123",
      "Some bug",
      "https://jira.example.com/browse/SQDP-123",
    );

    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toBe("https://slack.com/api/chat.postMessage");
    expect((init.headers as Record<string, string>)["Content-Type"]).toContain("application/json");
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.channel).toBe("C01TEST");
    expect(typeof body.text).toBe("string");
    expect(Array.isArray(body.blocks)).toBe(true);
  });

  test("resolves without error when response is ok", async () => {
    const notifier = makeNotifier();
    await expect(
      notifier.notifyTicketStarted("SQDP-123", "Bug", "https://jira.example.com/SQDP-123"),
    ).resolves.toBeUndefined();
  });

  test("includes optional triggeredBy in footer block", async () => {
    const notifier = makeNotifier();
    await notifier.notifyTicketStarted(
      "SQDP-123",
      "Bug",
      "https://jira.example.com/SQDP-123",
      "julian",
    );
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: Array<Record<string, unknown>> };
    const bodyStr = JSON.stringify(body.blocks);
    expect(bodyStr).toContain("julian");
  });

  test("throws on HTTP error (non-2xx response)", async () => {
    fetchSpy.mockResolvedValue(mockResponse({}, 500));
    const notifier = makeNotifier();
    await expect(
      notifier.notifyTicketStarted("SQDP-123", "Bug", "https://jira.example.com/SQDP-123"),
    ).rejects.toThrow("Slack HTTP 500");
  });

  test("throws on API-level error (ok: false)", async () => {
    fetchSpy.mockResolvedValue(mockResponse(SLACK_ERR));
    const notifier = makeNotifier();
    await expect(
      notifier.notifyTicketStarted("SQDP-123", "Bug", "https://jira.example.com/SQDP-123"),
    ).rejects.toThrow("Slack API error: channel_not_found");
  });
});

// ── notifyPrCreated ───────────────────────────────────────────────────────────

describe("SlackNotifier.notifyPrCreated", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
    mockGetSlackThread.mockReset();
    mockSaveSlackThread.mockReset();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts PR created notification on happy path", async () => {
    const notifier = makeNotifier();
    await notifier.notifyPrCreated("SQDP-123", "my-repo", "https://gh.com/pr/1", "Fix bug", 1);

    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toBe("https://slack.com/api/chat.postMessage");
  });

  test("saves slack thread with repo#prNumber as key when ts is returned", async () => {
    const notifier = makeNotifier();
    await notifier.notifyPrCreated("SQDP-123", "my-repo", "https://gh.com/pr/42", "Fix", 42);

    expect(mockSaveSlackThread).toHaveBeenCalledTimes(1);
    const firstCall = mockSaveSlackThread.mock.calls[0] as unknown as [
      string,
      { channelId: string; threadTs: string },
    ];
    const [key, data] = firstCall;
    expect(key).toBe("my-repo#42");
    expect(data.threadTs).toBe("1234.5678");
    expect(data.channelId).toBe("C01TEST");
  });

  test("does NOT save thread when response ts is empty string", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: true, ts: "" }));
    const notifier = makeNotifier();
    await notifier.notifyPrCreated("SQDP-123", "my-repo", "https://gh.com/pr/99", "Fix", 99);
    expect(mockSaveSlackThread).not.toHaveBeenCalled();
  });

  test("appends squad mention for known issue prefix (SQDP)", async () => {
    const notifier = makeNotifier();
    await notifier.notifyPrCreated("SQDP-123", "my-repo", "https://gh.com/pr/1", "Fix bug", 1);
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: Array<Record<string, unknown>> };
    const bodyStr = JSON.stringify(body.blocks);
    // SQDP maps to S05UELH00AH in SQUAD_SLACK_MAP
    expect(bodyStr).toContain("<!subteam^S05UELH00AH>");
  });

  test("squad mention uses env override over map", async () => {
    process.env.SQUAD_SLACK_SQDP = "S_ENV_OVERRIDE";
    try {
      const notifier = makeNotifier();
      await notifier.notifyPrCreated("SQDP-123", "my-repo", "https://gh.com/pr/1", "Fix", 1);
      const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
      const body = JSON.parse(init.body as string) as { blocks: unknown[] };
      expect(JSON.stringify(body.blocks)).toContain("<!subteam^S_ENV_OVERRIDE>");
    } finally {
      delete process.env.SQUAD_SLACK_SQDP;
    }
  });

  test("no squad mention for unknown prefix (UNKNOWN-123)", async () => {
    const notifier = makeNotifier();
    await notifier.notifyPrCreated("UNKNOWN-123", "my-repo", "https://gh.com/pr/1", "Fix", 1);
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).not.toContain("<!subteam^");
  });

  test("no squad mention for empty map entry (SQDO)", async () => {
    const notifier = makeNotifier();
    await notifier.notifyPrCreated("SQDO-999", "my-repo", "https://gh.com/pr/1", "Fix", 1);
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).not.toContain("<!subteam^");
  });

  test("sandboxMode=true: suppresses squad mention (no <!subteam^) in notifyPrCreated", async () => {
    const notifier = makeNotifier("xoxb-test", "C01TEST", "test-bot", true);
    await notifier.notifyPrCreated("SQDP-123", "my-repo", "https://gh.com/pr/1", "Fix bug", 1);
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).not.toContain("<!subteam^");
  });

  test("sandboxMode=false: squad mention still present in notifyPrCreated", async () => {
    const notifier = makeNotifier("xoxb-test", "C01TEST", "test-bot", false);
    await notifier.notifyPrCreated("SQDP-123", "my-repo", "https://gh.com/pr/1", "Fix bug", 1);
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).toContain("<!subteam^");
  });

  test("escapes '<' and '>' in PR title so mrkdwn link survives", async () => {
    const notifier = makeNotifier();
    await notifier.notifyPrCreated(
      "SQGZ-1027",
      "humand-koda",
      "https://github.com/HumandDev/humand-koda/pull/282",
      "[SQGZ-1027] | Fallback to onboarding-<processId> & sanitize",
      282,
    );
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: Array<Record<string, unknown>> };
    const text = JSON.stringify(body.blocks);
    expect(text).toContain("onboarding-&lt;processId&gt;");
    expect(text).toContain("&amp; sanitize");
    // raw '<processId>' must not leak — it would break the enclosing <url|text> link
    expect(text).not.toContain("<processId>");
  });
});

// ── notifyCommentAttended ─────────────────────────────────────────────────────

describe("SlackNotifier.notifyCommentAttended", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
    mockGetSlackThread.mockReset();
    mockSaveSlackThread.mockReset();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts to channel (no thread_ts) when no stored thread", async () => {
    mockGetSlackThread.mockReturnValue(null);
    const notifier = makeNotifier();
    await notifier.notifyCommentAttended("my-repo", 5);

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.thread_ts).toBeUndefined();
  });

  test("replies in thread when thread is stored in message-store", async () => {
    mockGetSlackThread.mockReturnValue({ channelId: "C01TEST", threadTs: "9876.5432" });
    const notifier = makeNotifier();
    await notifier.notifyCommentAttended("my-repo", 10);

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.thread_ts).toBe("9876.5432");
  });

  test("includes PR URL as link when provided", async () => {
    const notifier = makeNotifier();
    await notifier.notifyCommentAttended("my-repo", 7, "https://gh.com/pr/7");
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).toContain("https://gh.com/pr/7");
  });
});

// ── notifyNeedsInfo ───────────────────────────────────────────────────────────

describe("SlackNotifier.notifyNeedsInfo", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
    mockGetSlackThread.mockReset();
    mockSaveSlackThread.mockReset();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts needs-info notification on happy path", async () => {
    const notifier = makeNotifier();
    await notifier.notifyNeedsInfo("SQDP-123", "https://jira.example.com/SQDP-123", "The summary");
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toBe("https://slack.com/api/chat.postMessage");
  });

  test("includes questions when provided", async () => {
    const notifier = makeNotifier();
    await notifier.notifyNeedsInfo(
      "SQDP-123",
      "https://jira.example.com/SQDP-123",
      "Summary text",
      "What exact steps reproduce?",
    );
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).toContain("What exact steps reproduce?");
  });

  test("truncates questions longer than 500 chars", async () => {
    const longQuestion = "Q".repeat(600);
    const notifier = makeNotifier();
    await notifier.notifyNeedsInfo(
      "SQDP-123",
      "https://jira.example.com/SQDP-123",
      "Summary",
      longQuestion,
    );
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    const bodyStr = JSON.stringify(body.blocks);
    expect(bodyStr).toContain("Q".repeat(500));
    expect(bodyStr).not.toContain("Q".repeat(501));
  });
});

// ── notifyJobFailed ───────────────────────────────────────────────────────────

describe("SlackNotifier.notifyJobFailed", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts job-failed notification on happy path", async () => {
    const notifier = makeNotifier();
    await notifier.notifyJobFailed("SQDP-123", "https://jira.example.com/SQDP-123", "Build failed");
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });

  test("truncates reason longer than 300 chars and appends ellipsis", async () => {
    const longReason = "R".repeat(400);
    const notifier = makeNotifier();
    await notifier.notifyJobFailed("SQDP-123", "https://jira.example.com/SQDP-123", longReason);
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    const bodyStr = JSON.stringify(body.blocks);
    expect(bodyStr).toContain("R".repeat(300) + "…");
  });

  test("does not truncate reason of exactly 300 chars", async () => {
    const reason = "X".repeat(300);
    const notifier = makeNotifier();
    await notifier.notifyJobFailed("SQDP-123", "https://jira.example.com/SQDP-123", reason);
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).not.toContain("…");
  });
});

// ── notifyNoChangesNeeded ─────────────────────────────────────────────────────

describe("SlackNotifier.notifyNoChangesNeeded", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
    mockGetSlackThread.mockReset();
    mockSaveSlackThread.mockReset();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts no-changes-needed notification on happy path", async () => {
    const notifier = makeNotifier();
    await notifier.notifyNoChangesNeeded(
      "SQDP-123",
      "https://jira.example.com/SQDP-123",
      "My ticket summary",
    );
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toBe("https://slack.com/api/chat.postMessage");
  });

  test("includes handshake emoji and ticket link in body", async () => {
    const notifier = makeNotifier();
    await notifier.notifyNoChangesNeeded(
      "SQDP-123",
      "https://jira.example.com/SQDP-123",
      "My ticket summary",
    );
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    const bodyStr = JSON.stringify(body.blocks);
    expect(bodyStr).toContain(":handshake:");
    expect(bodyStr).toContain("SQDP-123");
    expect(bodyStr).toContain("No changes needed");
  });

  test("includes reason when provided", async () => {
    const notifier = makeNotifier();
    await notifier.notifyNoChangesNeeded(
      "SQDP-123",
      "https://jira.example.com/SQDP-123",
      "Summary",
      "Validation already exists at infra level",
    );
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).toContain("Validation already exists at infra level");
  });

  test("truncates reason longer than 300 chars", async () => {
    const longReason = "R".repeat(400);
    const notifier = makeNotifier();
    await notifier.notifyNoChangesNeeded(
      "SQDP-123",
      "https://jira.example.com/SQDP-123",
      "Summary",
      longReason,
    );
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    const bodyStr = JSON.stringify(body.blocks);
    expect(bodyStr).toContain("R".repeat(300));
    expect(bodyStr).not.toContain("R".repeat(301));
  });
});

// ── notifyHighLoad ────────────────────────────────────────────────────────────

describe("SlackNotifier.notifyHighLoad", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts to the provided channelId override", async () => {
    const notifier = makeNotifier();
    await notifier.notifyHighLoad("SQDP-123", "fix", 3, ["SQDP-456"], "C_ALERT");

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.channel).toBe("C_ALERT");
  });

  test("shows dash for null currentJobKey", async () => {
    const notifier = makeNotifier();
    await notifier.notifyHighLoad(null, null, 0, [], "C_ALERT");
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).toContain("—");
  });

  test("includes pending job keys in block text", async () => {
    const notifier = makeNotifier();
    await notifier.notifyHighLoad("JOB-1", "fix", 2, ["JOB-2", "JOB-3"], "C_ALERT");
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    const bodyStr = JSON.stringify(body.blocks);
    expect(bodyStr).toContain("JOB-2");
    expect(bodyStr).toContain("JOB-3");
  });
});

// ── notifyDailyReport ─────────────────────────────────────────────────────────

describe("SlackNotifier.notifyDailyReport", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts daily report with minimal payload", async () => {
    const notifier = makeNotifier();
    await notifier.notifyDailyReport({
      ticketsByTeam: {
        Talent: [
          {
            key: "SQDP-1",
            summary: "Some bug",
            jiraStatus: "In Progress",
            jiraUrl: "https://jira.example.com/SQDP-1",
            prs: [],
            lastStatusChangeAt: null,
          },
        ],
      },
    });
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toBe("https://slack.com/api/chat.postMessage");
  });

  test("uses channelOverride when provided", async () => {
    const notifier = makeNotifier();
    await notifier.notifyDailyReport({ ticketsByTeam: {} }, "C_REPORT_CHANNEL");
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.channel).toBe("C_REPORT_CHANNEL");
  });

  test("includes stale ticket warning for old tickets", async () => {
    const notifier = makeNotifier();
    const staleDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
    await notifier.notifyDailyReport({
      ticketsByTeam: {
        Engineering: [
          {
            key: "SQEG-42",
            summary: "Stale bug",
            jiraStatus: "To Do",
            jiraUrl: "https://jira.example.com/SQEG-42",
            prs: [],
            lastStatusChangeAt: staleDate,
          },
        ],
      },
    });
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).toContain("sin cambio de estado");
  });

  test("includes closed PRs section when closedPrs provided", async () => {
    const notifier = makeNotifier();
    await notifier.notifyDailyReport({
      ticketsByTeam: {},
      closedPrs: [
        { repo: "my-repo", number: 5, url: "https://gh.com/pr/5", title: "Fix something" },
      ],
    });
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).toContain("cerradas");
  });

  test("includes PR links in ticket lines", async () => {
    const notifier = makeNotifier();
    await notifier.notifyDailyReport({
      ticketsByTeam: {
        Talent: [
          {
            key: "SQDP-99",
            summary: "Bug with PR",
            jiraStatus: "In Review",
            jiraUrl: "https://jira.example.com/SQDP-99",
            prs: [{ url: "https://gh.com/pr/99", title: "Fix PR", repoName: "my-repo" }],
            lastStatusChangeAt: null,
          },
        ],
      },
    });
    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { blocks: unknown[] };
    expect(JSON.stringify(body.blocks)).toContain("https://gh.com/pr/99");
  });
});

// ── listBotChannels ───────────────────────────────────────────────────────────

describe("SlackNotifier.listBotChannels", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch");
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns channels from single-page response", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        channels: [
          { id: "C001", name: "general" },
          { id: "C002", name: "alerts" },
        ],
        response_metadata: { next_cursor: "" },
      }),
    );
    const notifier = makeNotifier();
    const channels = await notifier.listBotChannels();
    expect(channels).toEqual([
      { id: "C001", name: "general" },
      { id: "C002", name: "alerts" },
    ]);
  });

  test("paginates across multiple pages", async () => {
    fetchSpy
      .mockResolvedValueOnce(
        mockResponse({
          ok: true,
          channels: [{ id: "C001", name: "page1" }],
          response_metadata: { next_cursor: "cursor_page2" },
        }),
      )
      .mockResolvedValueOnce(
        mockResponse({
          ok: true,
          channels: [{ id: "C002", name: "page2" }],
          response_metadata: { next_cursor: "" },
        }),
      );
    const notifier = makeNotifier();
    const channels = await notifier.listBotChannels();
    expect(channels).toHaveLength(2);
    expect(channels[0]?.name).toBe("page1");
    expect(channels[1]?.name).toBe("page2");
    expect(fetchSpy).toHaveBeenCalledTimes(2);
    // Second call should include cursor param
    const [secondUrl] = fetchSpy.mock.calls[1] as [string];
    expect(secondUrl).toContain("cursor=cursor_page2");
  });

  test("throws on HTTP error", async () => {
    fetchSpy.mockResolvedValue(mockResponse({}, 403));
    const notifier = makeNotifier();
    await expect(notifier.listBotChannels()).rejects.toThrow("Slack HTTP 403");
  });

  test("throws on Slack API error (ok: false)", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "not_authed" }));
    const notifier = makeNotifier();
    await expect(notifier.listBotChannels()).rejects.toThrow("Slack API error: not_authed");
  });
});

// ── fetchChannelHistory ───────────────────────────────────────────────────────

describe("SlackNotifier.fetchChannelHistory", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch");
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns non-subtype messages", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        messages: [
          { ts: "1000.0", text: "hello" },
          { ts: "1001.0", text: "system", subtype: "channel_join" },
          { ts: "1002.0", text: "world" },
        ],
      }),
    );
    const notifier = makeNotifier();
    const msgs = await notifier.fetchChannelHistory("C001");
    expect(msgs).toHaveLength(2);
    expect(msgs[0]?.ts).toBe("1000.0");
    expect(msgs[1]?.ts).toBe("1002.0");
  });

  test("includes oldest param when provided", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: true, messages: [] }));
    const notifier = makeNotifier();
    await notifier.fetchChannelHistory("C001", "9999.0");
    const [url] = fetchSpy.mock.calls[0] as [string];
    expect(url).toContain("oldest=9999.0");
  });

  test("throws on HTTP error", async () => {
    fetchSpy.mockResolvedValue(mockResponse({}, 500));
    const notifier = makeNotifier();
    await expect(notifier.fetchChannelHistory("C001")).rejects.toThrow("Slack HTTP 500");
  });

  test("throws on API error (ok: false)", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "channel_not_found" }));
    const notifier = makeNotifier();
    await expect(notifier.fetchChannelHistory("C001")).rejects.toThrow(
      "Slack API error: channel_not_found",
    );
  });
});

// ── fetchThreadReplies ────────────────────────────────────────────────────────

describe("SlackNotifier.fetchThreadReplies", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch");
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns only non-bot messages", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        messages: [
          { ts: "1000.0", user: "U123", text: "human reply" },
          { ts: "1001.0", bot_id: "B123", text: "bot reply" },
          { ts: "1002.0", user: "U456", text: "another human" },
        ],
      }),
    );
    const notifier = makeNotifier();
    const replies = await notifier.fetchThreadReplies("C001", "999.0");
    expect(replies).toHaveLength(2);
    expect(replies.some((m: { ts: string }) => m.ts === "1001.0")).toBe(false);
  });

  test("throws on API error", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "thread_not_found" }));
    const notifier = makeNotifier();
    await expect(notifier.fetchThreadReplies("C001", "999.0")).rejects.toThrow(
      "Slack API error: thread_not_found",
    );
  });

  test("with includeBots=true returns bot messages too", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        messages: [
          { ts: "1000.0", user: "U123", text: "human reply" },
          { ts: "1001.0", bot_id: "B123", text: "bot reply" },
        ],
      }),
    );
    const notifier = makeNotifier();
    const replies = await notifier.fetchThreadReplies("C001", "999.0", { includeBots: true });
    expect(replies).toHaveLength(2);
    const bot = replies.find((m: { ts: string }) => m.ts === "1001.0");
    expect(bot).toBeDefined();
    expect(bot?.bot_id).toBe("B123");
  });

  test("with includeBots=false (default) preserves filtering", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        messages: [
          { ts: "1000.0", user: "U123", text: "human reply" },
          { ts: "1001.0", bot_id: "B123", text: "bot reply" },
        ],
      }),
    );
    const notifier = makeNotifier();
    const replies = await notifier.fetchThreadReplies("C001", "999.0", { includeBots: false });
    expect(replies).toHaveLength(1);
    expect(replies[0]?.ts).toBe("1000.0");
    expect(replies.some((m: { ts: string }) => m.ts === "1001.0")).toBe(false);
  });
});

// ── hasBotRepliedAfter ────────────────────────────────────────────────────────

describe("SlackNotifier.hasBotRepliedAfter", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch");
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("returns true when bot message ts > afterTs", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        messages: [{ ts: "2000.0", bot_id: "B123", text: "bot reply" }],
      }),
    );
    const notifier = makeNotifier();
    const result = await notifier.hasBotRepliedAfter("C001", "1000.0", "1500.0");
    expect(result).toBe(true);
  });

  test("returns false when bot message ts <= afterTs", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        messages: [{ ts: "1000.0", bot_id: "B123", text: "bot reply" }],
      }),
    );
    const notifier = makeNotifier();
    const result = await notifier.hasBotRepliedAfter("C001", "500.0", "1500.0");
    expect(result).toBe(false);
  });

  test("returns false when no bot messages", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        messages: [{ ts: "2000.0", user: "U123", text: "human reply" }],
      }),
    );
    const notifier = makeNotifier();
    const result = await notifier.hasBotRepliedAfter("C001", "1000.0", "1500.0");
    expect(result).toBe(false);
  });
});

// ── replyInThread ─────────────────────────────────────────────────────────────

describe("SlackNotifier.replyInThread", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("posts to chat.postMessage with thread_ts in body", async () => {
    const notifier = makeNotifier();
    await notifier.replyInThread("C001", "1234.5678", "Hello thread");

    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toBe("https://slack.com/api/chat.postMessage");
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.channel).toBe("C001");
    expect(body.thread_ts).toBe("1234.5678");
    expect(body.text).toBe("Hello thread");
  });

  test("throws on HTTP error", async () => {
    fetchSpy.mockResolvedValue(mockResponse({}, 503));
    const notifier = makeNotifier();
    await expect(notifier.replyInThread("C001", "1234.5678", "text")).rejects.toThrow(
      "Slack HTTP 503",
    );
  });

  test("throws on API error (ok: false)", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "is_archived" }));
    const notifier = makeNotifier();
    await expect(notifier.replyInThread("C001", "1234.5678", "text")).rejects.toThrow(
      "Slack API error: is_archived",
    );
  });
});

// ── Dev override (redirect + [DEV] prefix) ────────────────────────────────────

describe("SlackNotifier — dev override", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse(SLACK_OK));
    mockGetSlackThread.mockReset();
    mockSaveSlackThread.mockReset();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("post() redirects channel to dev channel regardless of caller override", async () => {
    const notifier = makeDevNotifier("C0DEV_CHANNEL");
    // notifyDailyReport accepts a channelOverride; dev override must win over it.
    await notifier.notifyDailyReport({ ticketsByTeam: {} }, "C_REPORT_CHANNEL");

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.channel).toBe("C0DEV_CHANNEL");
  });

  test("post() prefixes text with [DEV]", async () => {
    const notifier = makeDevNotifier();
    await notifier.notifyTicketStarted("SQDP-123", "Some bug", "https://jira.example.com/SQDP-123");

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as { text: string };
    expect(body.text.startsWith("[DEV] ")).toBe(true);
  });

  test("post() prefixes the first mrkdwn block text with [DEV]", async () => {
    const notifier = makeDevNotifier();
    await notifier.notifyTicketStarted("SQDP-123", "Some bug", "https://jira.example.com/SQDP-123");

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as {
      blocks: Array<{ text?: { text?: string } }>;
    };
    const firstMrkdwn = body.blocks.find((b) => typeof b.text?.text === "string");
    expect(firstMrkdwn?.text?.text?.startsWith("[DEV] ")).toBe(true);
  });

  test("post() drops thread_ts when dev override is active", async () => {
    // notifyCommentAttended posts with thread_ts when message-store has a stored thread.
    mockGetSlackThread.mockReturnValue({ channelId: "C01TEST", threadTs: "9876.5432" });
    const notifier = makeDevNotifier();
    await notifier.notifyCommentAttended("my-repo", 10);

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.thread_ts).toBeUndefined();
    expect(body.channel).toBe("C0DEV");
  });

  test("replyInThread() redirects to dev channel and drops thread_ts", async () => {
    const notifier = makeDevNotifier("C0DEV_RT");
    await notifier.replyInThread("C_REAL", "1234.5678", "Hello thread");

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.channel).toBe("C0DEV_RT");
    expect(body.thread_ts).toBeUndefined();
    expect(typeof body.text).toBe("string");
    const text = body.text as string;
    expect(text.startsWith("[DEV] Hello thread")).toBe(true);
    // Original channel + thread are preserved as a context line for traceability.
    expect(text).toContain("C_REAL");
    expect(text).toContain("1234.5678");
  });

  test("without devOverride, post() preserves original channel and thread", async () => {
    mockGetSlackThread.mockReturnValue({ channelId: "C01TEST", threadTs: "9876.5432" });
    const notifier = makeNotifier(); // no override
    await notifier.notifyCommentAttended("my-repo", 10);

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.channel).toBe("C01TEST");
    expect(body.thread_ts).toBe("9876.5432");
    expect((body.text as string).startsWith("[DEV]")).toBe(false);
  });

  test("without devOverride, replyInThread() keeps caller channel and thread_ts", async () => {
    const notifier = makeNotifier();
    await notifier.replyInThread("C_REAL", "1234.5678", "Hello");

    const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
    const body = JSON.parse(init.body as string) as Record<string, unknown>;
    expect(body.channel).toBe("C_REAL");
    expect(body.thread_ts).toBe("1234.5678");
    expect(body.text).toBe("Hello");
  });
});

// ── fetchWithRateLimit (429 retry) ────────────────────────────────────────────

describe("fetchWithRateLimit — 429 retry behavior", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch");
    mockGetSlackThread.mockReset();
    mockSaveSlackThread.mockReset();
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  test("retries after 429 and succeeds on second attempt", async () => {
    const rateLimitResponse = {
      ok: false,
      status: 429,
      json: async () => ({ ok: false }),
      headers: { get: (name: string) => (name === "Retry-After" ? "0" : null) },
    } as unknown as Response;

    fetchSpy.mockResolvedValueOnce(rateLimitResponse).mockResolvedValueOnce(mockResponse(SLACK_OK));

    const notifier = makeNotifier();
    await expect(
      notifier.notifyTicketStarted("SQDP-1", "Bug", "https://jira.example.com/SQDP-1"),
    ).resolves.toBeUndefined();

    expect(fetchSpy).toHaveBeenCalledTimes(2);
  });

  test("stops retrying after maxRetries (3) and returns last response", async () => {
    // After 3 retries (4 total calls with attempt 0,1,2,3 — attempt >= maxRetries=3 stops),
    // the last response should be returned and processed. Since ok:false on Slack API level it throws.
    const rateLimitResponse = {
      ok: false,
      status: 429,
      json: async () => ({ ok: false }),
      headers: { get: (name: string) => (name === "Retry-After" ? "0" : null) },
    } as unknown as Response;

    fetchSpy
      .mockResolvedValueOnce(rateLimitResponse)
      .mockResolvedValueOnce(rateLimitResponse)
      .mockResolvedValueOnce(rateLimitResponse)
      .mockResolvedValueOnce(rateLimitResponse); // 4th call: attempt=3 >= maxRetries=3, returns this

    const notifier = makeNotifier();
    // The 4th response is a 429 returned as-is; response.ok is false so post() throws HTTP 429
    await expect(
      notifier.notifyTicketStarted("SQDP-1", "Bug", "https://jira.example.com/SQDP-1"),
    ).rejects.toThrow("Slack HTTP 429");

    expect(fetchSpy).toHaveBeenCalledTimes(4);
  });
});

// ── resolveUsers ──────────────────────────────────────────────────────────────

describe("SlackNotifier.resolveUsers", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch");
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  /** Build a fake users.info response body for a single user. */
  function userInfoBody(
    user: {
      id?: string;
      name?: string;
      profile?: {
        real_name?: string;
        display_name?: string;
        display_name_normalized?: string;
      };
    },
    ok = true,
    error?: string,
  ): Record<string, unknown> {
    return ok ? { ok: true, user } : { ok: false, error: error ?? "unknown_error" };
  }

  /** Build a notifier alongside the logger so tests can assert on logger.warn. */
  function makeNotifierWithLogger(): {
    notifier: InstanceType<typeof SlackNotifier>;
    logger: ReturnType<typeof mockLogger>;
  } {
    const logger = mockLogger();
    const notifier = new SlackNotifier("xoxb-test", "C01TEST", "test-bot", logger, false);
    return { notifier, logger };
  }

  test("empty input returns empty map without calling fetch", async () => {
    const notifier = makeNotifier();
    const result = await notifier.resolveUsers([]);
    expect(result.size).toBe(0);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  test("resolves a single user via users.info", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse(
        userInfoBody({
          id: "U07ABCXYZ",
          name: "sebas",
          profile: { display_name: "sebas", real_name: "Sebastián Ramírez" },
        }),
      ),
    );
    const notifier = makeNotifier();
    const result = await notifier.resolveUsers(["U07ABCXYZ"]);
    expect(result.get("U07ABCXYZ")).toBe("sebas");
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url] = fetchSpy.mock.calls[0] as [string, RequestInit];
    expect(url).toContain("https://slack.com/api/users.info");
    expect(url).toContain("user=U07ABCXYZ");
  });

  test("prefers profile.display_name over real_name and name", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse(
        userInfoBody({
          id: "U1",
          name: "name_value",
          profile: { display_name: "display_value", real_name: "real_value" },
        }),
      ),
    );
    const notifier = makeNotifier();
    const result = await notifier.resolveUsers(["U1"]);
    expect(result.get("U1")).toBe("display_value");
  });

  test("falls back to real_name when display_name is empty", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse(
        userInfoBody({
          id: "U1",
          name: "name_value",
          profile: { display_name: "", real_name: "Foo Bar" },
        }),
      ),
    );
    const notifier = makeNotifier();
    const result = await notifier.resolveUsers(["U1"]);
    expect(result.get("U1")).toBe("Foo Bar");
  });

  test("falls back to name when both display_name and real_name are missing", async () => {
    fetchSpy.mockResolvedValue(mockResponse(userInfoBody({ id: "U1", name: "foo" })));
    const notifier = makeNotifier();
    const result = await notifier.resolveUsers(["U1"]);
    expect(result.get("U1")).toBe("foo");
  });

  test("deduplicates input — ['U1', 'U1', 'U2'] makes 2 fetch calls", async () => {
    fetchSpy.mockImplementation(async (url: string | URL | Request) => {
      const u = typeof url === "string" ? url : url.toString();
      if (u.includes("user=U1")) {
        return mockResponse(userInfoBody({ id: "U1", profile: { display_name: "alice" } }));
      }
      if (u.includes("user=U2")) {
        return mockResponse(userInfoBody({ id: "U2", profile: { display_name: "bob" } }));
      }
      return mockResponse({ ok: false, error: "unknown" });
    });
    const notifier = makeNotifier();
    const result = await notifier.resolveUsers(["U1", "U1", "U2"]);
    expect(fetchSpy).toHaveBeenCalledTimes(2);
    expect(result.size).toBe(2);
    expect(result.get("U1")).toBe("alice");
    expect(result.get("U2")).toBe("bob");
  });

  test("caches results — second call within TTL does not hit fetch again", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse(userInfoBody({ id: "U1", profile: { display_name: "alice" } })),
    );
    const notifier = makeNotifier();
    const first = await notifier.resolveUsers(["U1"]);
    expect(first.get("U1")).toBe("alice");
    expect(fetchSpy).toHaveBeenCalledTimes(1);

    const second = await notifier.resolveUsers(["U1"]);
    expect(second.get("U1")).toBe("alice");
    // No second call: the result was cached.
    expect(fetchSpy).toHaveBeenCalledTimes(1);
  });

  test("expires cache after TTL", async () => {
    const dateSpy = spyOn(Date, "now").mockReturnValue(1_000_000);
    try {
      fetchSpy.mockResolvedValue(
        mockResponse(userInfoBody({ id: "U1", profile: { display_name: "alice" } })),
      );
      const notifier = makeNotifier();
      await notifier.resolveUsers(["U1"]);
      expect(fetchSpy).toHaveBeenCalledTimes(1);

      // Advance the clock past the TTL (1 hour + 1 minute).
      dateSpy.mockReturnValue(1_000_000 + 60 * 60 * 1000 + 60_000);
      await notifier.resolveUsers(["U1"]);
      expect(fetchSpy).toHaveBeenCalledTimes(2);
    } finally {
      dateSpy.mockRestore();
    }
  });

  test("falls back to the raw id and logs a warning on Slack error", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "user_not_found" }));
    const { notifier, logger } = makeNotifierWithLogger();
    const result = await notifier.resolveUsers(["U_BAD"]);
    expect(result.get("U_BAD")).toBe("U_BAD");
    const warn = logger.warn as unknown as ReturnType<typeof mock>;
    expect(warn).toHaveBeenCalled();
    const call = warn.mock.calls[0] as [Record<string, unknown>, string];
    expect(call[0].userId).toBe("U_BAD");
    expect(call[1]).toBe("slack_resolve_user_failed");
  });
});

// ── reactions (addReaction / removeReaction / getBotReactions) ─────────────────

describe("SlackNotifier reactions", () => {
  let fetchSpy: ReturnType<typeof spyOn>;

  beforeEach(() => {
    fetchSpy = spyOn(globalThis, "fetch");
  });

  afterEach(() => {
    fetchSpy.mockRestore();
  });

  const BOT_UID = "UBOT";

  test("getBotReactions reports markers the bot added", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        message: {
          reactions: [{ name: SLACK_MENTION.REACTION_CLAIMED, count: 1, users: [BOT_UID] }],
        },
      }),
    );
    const notifier = makeNotifier();
    const r = await notifier.getBotReactions("C1", "1.1", BOT_UID);
    expect(r.claimed).toBe(true);
    expect(r.done).toBe(false);
    expect(r.failed).toBe(false);
  });

  test("getBotReactions ignores reactions the bot did not add (human reactions)", async () => {
    fetchSpy.mockResolvedValue(
      mockResponse({
        ok: true,
        message: {
          reactions: [
            { name: SLACK_MENTION.REACTION_DONE, count: 1, users: [BOT_UID] },
            { name: SLACK_MENTION.REACTION_FAILED, count: 1, users: ["UHUMAN"] },
          ],
        },
      }),
    );
    const notifier = makeNotifier();
    const r = await notifier.getBotReactions("C1", "1.1", BOT_UID);
    expect(r.done).toBe(true);
    expect(r.failed).toBe(false);
  });

  test("getBotReactions throws on Slack API error", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "message_not_found" }));
    const notifier = makeNotifier();
    await expect(notifier.getBotReactions("C1", "1.1", BOT_UID)).rejects.toThrow();
  });

  test("addReaction swallows already_reacted", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "already_reacted" }));
    const notifier = makeNotifier();
    await expect(
      notifier.addReaction("C1", "1.1", SLACK_MENTION.REACTION_CLAIMED),
    ).resolves.toBeUndefined();
  });

  test("addReaction warns on other non-ok errors but does not throw", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "channel_not_found" }));
    const notifier = makeNotifier();
    await expect(
      notifier.addReaction("C1", "1.1", SLACK_MENTION.REACTION_CLAIMED),
    ).resolves.toBeUndefined();
  });

  test("addReaction warns but does not throw on a transport (non-2xx) error", async () => {
    fetchSpy.mockResolvedValue(mockResponse({}, 500));
    const notifier = makeNotifier();
    await expect(
      notifier.addReaction("C1", "1.1", SLACK_MENTION.REACTION_CLAIMED),
    ).resolves.toBeUndefined();
  });

  test("removeReaction swallows no_reaction", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "no_reaction" }));
    const notifier = makeNotifier();
    await expect(
      notifier.removeReaction("C1", "1.1", SLACK_MENTION.REACTION_CLAIMED),
    ).resolves.toBeUndefined();
  });

  test("removeReaction warns on other non-ok errors but does not throw", async () => {
    fetchSpy.mockResolvedValue(mockResponse({ ok: false, error: "channel_not_found" }));
    const notifier = makeNotifier();
    await expect(
      notifier.removeReaction("C1", "1.1", SLACK_MENTION.REACTION_CLAIMED),
    ).resolves.toBeUndefined();
  });

  test("removeReaction warns but does not throw on a transport (non-2xx) error", async () => {
    fetchSpy.mockResolvedValue(mockResponse({}, 500));
    const notifier = makeNotifier();
    await expect(
      notifier.removeReaction("C1", "1.1", SLACK_MENTION.REACTION_CLAIMED),
    ).resolves.toBeUndefined();
  });

  test("getBotReactions reports no markers in dev mode (does not call fetch)", async () => {
    const notifier = makeDevNotifier();
    const r = await notifier.getBotReactions("C1", "1.1", "UBOT");
    expect(r).toEqual({ claimed: false, done: false, failed: false });
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  test("addReaction is a no-op in dev mode (does not call fetch)", async () => {
    const notifier = makeDevNotifier();
    await notifier.addReaction("C1", "1.1", SLACK_MENTION.REACTION_CLAIMED);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  test("removeReaction is a no-op in dev mode (does not call fetch)", async () => {
    const notifier = makeDevNotifier();
    await notifier.removeReaction("C1", "1.1", SLACK_MENTION.REACTION_CLAIMED);
    expect(fetchSpy).not.toHaveBeenCalled();
  });
});
