// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
  true;

import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { Acks } from "./Acks";
import { api } from "@/lib/api";

vi.mock("@/lib/api", () => {
  class ApiError extends Error {
    constructor(public status: number, message: string) {
      super(message);
    }
  }
  return {
    api: {
      isConfigured: true,
      listAcks: vi.fn(),
      getAck: vi.fn(),
    },
    ApiError,
  };
});

// SP25: the Acks page now opens two live-tail streams (one per ack
// flavor) via `useTailStream`. The hook has its own dedicated test
// suite that covers the full lifecycle; here we just want the page to
// render against a stubbed hook so the test never opens a real fetch.
// The mock matches the production-default state (`live`) so the pill
// renders the "Live" label without surfacing a misleading error.
vi.mock("@/hooks/useTailStream", () => ({
  useTailStream: vi.fn(() => ({
    status: "live",
    lastEventAt: null,
    error: null,
    forceReconnect: vi.fn(),
  })),
}));

function renderIntoContainer(element: React.ReactElement): {
  container: HTMLDivElement;
  unmount: () => void;
} {
  const container = document.createElement("div");
  document.body.appendChild(container);
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  const root: Root = createRoot(container);
  act(() => {
    // SP28: the AckDrawer mounts the new <MatchedClaim /> panel
    // which calls `useNavigate()` for cross-page navigation to
    // /claims?claim=<id>. The hook requires a router context, so
    // the test harness wraps the page in <MemoryRouter>.
    root.render(
      React.createElement(
        QueryClientProvider,
        { client: qc },
        React.createElement(
          MemoryRouter,
          null,
          element,
        ),
      ),
    );
  });
  return {
    container,
    unmount: () => {
      act(() => root.unmount());
      container.remove();
    },
  };
}

async function waitForText(text: string, timeoutMs = 2000): Promise<void> {
  const start = Date.now();
  while (!document.body.textContent?.includes(text)) {
    if (Date.now() - start > timeoutMs) {
      throw new Error(`waitForText: "${text}" did not appear within ${timeoutMs}ms`);
    }
    await act(async () => {
      await Promise.resolve();
    });
  }
}

/**
 * Poll a predicate until it holds or we time out. react-query's fetch
 * resolves asynchronously, and Radix portals content into
 * `document.body`, so the DOM doesn't reflect new state synchronously
 * after a keypress — we have to await a tick before asserting.
 */
async function settle(
  predicate: () => boolean,
  timeoutMs = 2000
): Promise<void> {
  const start = Date.now();
  while (!predicate()) {
    if (Date.now() - start > timeoutMs) {
      throw new Error("settle: predicate did not hold within timeout");
    }
    await act(async () => {
      await new Promise((r) => setTimeout(r, 0));
    });
  }
}

/**
 * Dispatch a synthetic keydown on `window`. Matches the dispatching
 * pattern from `useDrawerKeyboard.test.ts` and `Claims.test.tsx` —
 * `bubbles: true` so the event propagates like a real keypress, and
 * `cancelable: true` so the hook's `preventDefault` has something to
 * act on.
 */
function pressKey(key: string): void {
  act(() => {
    window.dispatchEvent(
      new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
    );
  });
}

/** The row at `idx` (per `data-row-index={idx}` on the row). */
function rowAt(idx: number): HTMLTableRowElement | null {
  return document.querySelector(
    `tbody tr[data-row-index="${idx}"]`
  ) as HTMLTableRowElement | null;
}

/** True iff exactly one row carries `data-state="selected"`. */
function hasExactlyOneSelectedRow(): boolean {
  const selected = document.querySelectorAll(
    'tbody tr[data-state="selected"]'
  );
  return selected.length === 1;
}

describe("Acks", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    // Reset URL state between tests so a previous `?ack=` doesn't leak.
    (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
      "http://localhost/acks"
    );
  });

  it("renders a single ack row with counts and ack code", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 42,
          sourceBatchId: "b-uuid-1",
          acceptedCount: 3,
          rejectedCount: 1,
          receivedCount: 4,
          ackCode: "P",
          parsedAt: "2026-06-20T12:00:00Z",
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("42");

    // The row's ack code badge must be visible.
    expect(document.body.textContent).toContain("P");
    // Counts must be visible (3 accepted, 1 rejected, 4 received).
    expect(document.body.textContent).toContain("3");
    expect(document.body.textContent).toContain("1");
    expect(document.body.textContent).toContain("4");
    // The source batch id must be visible.
    expect(document.body.textContent).toContain("b-uuid-1");
    unmount();
  });

  // -------------------------------------------------------------------------
  // 2026-07-02: rejections were getting buried inside the accepted acks
  // (1,156 accepted + 5 rejected) — the operator had to scroll the
  // entire table to find the 5 they cared about. Fix: client-side sort
  // bubbles rejected rows to the top of page 1. Within each group
  // (rejected vs accepted) newest-id-first is preserved.
  // -------------------------------------------------------------------------
  it("test_rejected_acks_float_to_top_of_table", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 100,
          sourceBatchId: "b-newest-accepted",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-07-01T12:00:00Z",
        },
        {
          id: 99,
          sourceBatchId: "b-rejected",
          acceptedCount: 0,
          rejectedCount: 1,
          receivedCount: 1,
          ackCode: "R",
          parsedAt: "2026-07-01T11:00:00Z",
        },
        {
          id: 98,
          sourceBatchId: "b-old-accepted",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-25T12:00:00Z",
        },
      ],
      total: 3,
      returned: 3,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("100");

    // The first table row must be the rejected one (id=99), even though
    // id=100 has a higher id. After that, accepted rows sort newest-first
    // (id=100 before id=98).
    const tableRows = Array.from(
      document.querySelectorAll("table tbody tr"),
    ).filter((row) => {
      // Exclude any TA1-section rows by requiring the sourceBatchId
      // pattern (TA1 rows have a different layout).
      return row.textContent?.includes("b-");
    });
    expect(tableRows.length).toBe(3);
    expect(tableRows[0]?.textContent).toContain("b-rejected");
    expect(tableRows[1]?.textContent).toContain("b-newest-accepted");
    expect(tableRows[2]?.textContent).toContain("b-old-accepted");
    unmount();
  });

  it("test_initial_render_shows_no_selection_highlight", async () => {
    // Three rows in the fixture so there's plenty to select; the test
    // asserts the *initial* state has no row marked selected.
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
        {
          id: 2,
          sourceBatchId: "b-2",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:01:00Z",
        },
        {
          id: 3,
          sourceBatchId: "b-3",
          acceptedCount: 3,
          rejectedCount: 0,
          receivedCount: 3,
          ackCode: "A",
          parsedAt: "2026-06-20T12:02:00Z",
        },
      ],
      total: 3,
      returned: 3,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-3");

    // None of the three rows should be marked selected — the spec
    // requires the initial render shows no selection highlight.
    expect(
      document.querySelectorAll('tbody tr[data-state="selected"]').length
    ).toBe(0);

    unmount();
  });

  it("test_pressing_j_increments_selection", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
        {
          id: 2,
          sourceBatchId: "b-2",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:01:00Z",
        },
        {
          id: 3,
          sourceBatchId: "b-3",
          acceptedCount: 3,
          rejectedCount: 0,
          receivedCount: 3,
          ackCode: "A",
          parsedAt: "2026-06-20T12:02:00Z",
        },
      ],
      total: 3,
      returned: 3,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-3");

    // From null, j lands on row 0.
    pressKey("j");
    await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
    expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
    expect(rowAt(1)?.getAttribute("data-state")).not.toBe("selected");
    expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");

    // j again → row 1.
    pressKey("j");
    await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
    expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
    expect(rowAt(0)?.getAttribute("data-state")).not.toBe("selected");

    // j again → row 2.
    pressKey("j");
    await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
    expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");

    // j from last row wraps to row 0 (per the wrap-or-clamp policy
    // documented in Acks.tsx).
    pressKey("j");
    await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
    expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");

    unmount();
  });

  it("test_pressing_k_decrements_selection_with_wrap", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
        {
          id: 2,
          sourceBatchId: "b-2",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:01:00Z",
        },
        {
          id: 3,
          sourceBatchId: "b-3",
          acceptedCount: 3,
          rejectedCount: 0,
          receivedCount: 3,
          ackCode: "A",
          parsedAt: "2026-06-20T12:02:00Z",
        },
      ],
      total: 3,
      returned: 3,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-3");

    // From null, k jumps to the last row — a one-keystroke "bottom
    // of list" gesture.
    pressKey("k");
    await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
    expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");

    // k again → row 1.
    pressKey("k");
    await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
    expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
    expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");

    // k twice more lands on row 0.
    pressKey("k");
    await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
    expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");

    // k from row 0 wraps to row 2 (last).
    pressKey("k");
    await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
    expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");

    unmount();
  });

  it("test_question_mark_toggles_cheatsheet_and_escape_closes", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-1");

    // No cheatsheet initially.
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).toBeNull();

    // `?` opens it (Radix portals into document.body — query there).
    pressKey("?");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).not.toBeNull();

    // Escape closes it. The page wires onClose via useRowKeyboard
    // (Escape → onClose → setHelpOpen(false)).
    pressKey("Escape");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).toBeNull();

    // `?` again re-opens (proving the toggle works in both directions).
    pressKey("?");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).not.toBeNull();

    unmount();
  });

  it("test_j_does_not_navigate_while_cheatsheet_is_open", async () => {
    // `enabled: !helpOpen` is the recommended pairing with
    // useRowKeyboard: j/k nav yields to the cheatsheet while it's
    // open. The cheatsheet's own dismiss listener still closes it on
    // any non-`?` keypress (per KeyboardCheatsheet.tsx), so pressing
    // j will close the cheatsheet but the row should NOT have moved.
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
        {
          id: 2,
          sourceBatchId: "b-2",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:01:00Z",
        },
      ],
      total: 2,
      returned: 2,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-2");

    // Move selection to row 0 first (so we can later verify it didn't
    // jump to row 1 while the cheatsheet is open).
    pressKey("j");
    await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");

    // Open cheatsheet.
    pressKey("?");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
        null
    );

    // Press j while cheatsheet is open. Cheatsheet closes (its own
    // listener handles any non-? key), but selection should NOT have
    // moved — row 0 is still selected.
    pressKey("j");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).toBeNull();
    // Row 0 is still the selected row.
    expect(hasExactlyOneSelectedRow()).toBe(true);
    expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");

    unmount();
  });

  it("test_clicking_a_row_opens_the_ack_drawer", async () => {
    // SP21 Phase 5 Task 5.3: clicking an acks row drills into the
    // matching ack via `?ack=ID` URL state. The AckDrawer mounts
    // but the actual content depends on `useAckDetail` — we don't
    // need to verify drawer internals here, just that the URL got
    // pushed and the drawer portal opens.
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 42,
          sourceBatchId: "b-uuid-1",
          acceptedCount: 3,
          rejectedCount: 1,
          receivedCount: 4,
          ackCode: "P",
          parsedAt: "2026-06-20T12:00:00Z",
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });
    // Stub the per-ack fetch so `useAckDetail` resolves cleanly
    // (avoids TanStack Query's "Query data cannot be undefined"
    // warning). We only assert on the drawer's presence, so the
    // shape doesn't need to be precise.
    (api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      id: 42,
      sourceBatchId: "b-uuid-1",
      acceptedCount: 3,
      rejectedCount: 1,
      receivedCount: 4,
      ackCode: "P",
      parsedAt: "2026-06-20T12:00:00Z",
      raw_999_text: "ISA*~\n",
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-uuid-1");

    // No drawer yet.
    expect(
      document.body.querySelector('[data-testid="ack-drawer"]')
    ).toBeNull();

    // Click the row.
    const row = rowAt(0);
    expect(row).not.toBeNull();
    await act(async () => {
      row!.click();
      await Promise.resolve();
    });
    await settle(
      () => document.body.querySelector('[data-testid="ack-drawer"]') !== null
    );

    // The drawer is now in the DOM.
    expect(
      document.body.querySelector('[data-testid="ack-drawer"]')
    ).not.toBeNull();

    unmount();
  });

  it("test_deep_link_with_ack_param_opens_drawer_on_mount", async () => {
    // /acks?ack=42 deep link → drawer opens on mount without a click.
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 42,
          sourceBatchId: "b-uuid-1",
          acceptedCount: 3,
          rejectedCount: 1,
          receivedCount: 4,
          ackCode: "P",
          parsedAt: "2026-06-20T12:00:00Z",
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });
    (api.getAck as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      id: 42,
      sourceBatchId: "b-uuid-1",
      acceptedCount: 3,
      rejectedCount: 1,
      receivedCount: 4,
      ackCode: "P",
      parsedAt: "2026-06-20T12:00:00Z",
      raw_999_text: "ISA*~\n",
    });

    (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
      "http://localhost/acks?ack=42"
    );

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-uuid-1");
    await settle(
      () => document.body.querySelector('[data-testid="ack-drawer"]') !== null
    );

    // The drawer is in the DOM on first render.
    expect(
      document.body.querySelector('[data-testid="ack-drawer"]')
    ).not.toBeNull();

    unmount();
  });

  // -------------------------------------------------------------------------
  // SP25: the page now mounts two live-tail streams (one per ack
  // flavor) via useTailStream. The mocked hook returns status: "live",
  // so the page should render TWO TailStatusPills — one in the 999
  // hero and one in the TA1 section — both showing the human label
  // "Live" (per `STATUS_LABEL` in `TailStatusPill.tsx`).
  // -------------------------------------------------------------------------
  it("test_renders_two_tail_status_pills_live", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [],
      total: 0,
      returned: 0,
      has_more: false,
      aggregates: { accepted_count: 0, rejected_count: 0, received_count: 0 },
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));

    // Wait for the page to settle — both pills need the query to
    // resolve (and even the TA1 section renders a tail pill even
    // when items is empty).
    await settle(
      () => document.body.textContent?.includes("Live") ?? false,
    );

    // Two "Live" labels: one for the 999 stream, one for the TA1
    // stream. (Plus the description text on the page may say "live"
    // in another context — check via the surrounding pill markup.)
    const pills = Array.from(
      document.querySelectorAll('[data-testid="tail-status-pill"]'),
    );
    expect(pills.length).toBe(2);

    unmount();
  });

  // -------------------------------------------------------------------------
  // SP28: the "Claims" column on the 999 register. The badge carries
  // the distinct-claim link count (per-AK2 granularity, D1). Empty
  // link count → "Orphan" amber pill. Single link → muted "1 claim"
  // text. Multi link → success "{count} claims" badge.
  // -------------------------------------------------------------------------
  it("test_claims_badge_shows_orphan_when_no_linked_claims", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 42,
          sourceBatchId: "b-orphan",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
          linkedClaimIds: [],
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-orphan");

    // An orphan ack renders the warning "Orphan" badge — the
    // operator's cue that the auto-link didn't resolve and they
    // may need to drill in to run a manual match.
    const orphanBadge = document.body.querySelector(
      '[data-testid="claims-badge-orphan"]',
    );
    expect(orphanBadge).not.toBeNull();
    expect(orphanBadge?.textContent).toContain("Orphan");

    unmount();
  });

  it("test_claims_badge_shows_single_link_text_for_one_claim", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 43,
          sourceBatchId: "b-single",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
          linkedClaimIds: ["CLM-1"],
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-single");

    // Single link → muted mono text "1 claim" (not a colored pill —
    // a single resolved link is the common case and doesn't warrant
    // visual emphasis).
    const singleBadge = document.body.querySelector(
      '[data-testid="claims-badge-single"]',
    );
    expect(singleBadge).not.toBeNull();
    expect(singleBadge?.textContent).toContain("1 claim");
    // Not the orphan variant.
    expect(
      document.body.querySelector('[data-testid="claims-badge-orphan"]'),
    ).toBeNull();

    unmount();
  });

  it("test_claims_badge_shows_count_for_multiple_linked_claims", async () => {
    // A 999 with three AK2 set-responses, each linked to a different
    // claim batch, gets a multi-link badge.
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 44,
          sourceBatchId: "b-multi",
          acceptedCount: 3,
          rejectedCount: 0,
          receivedCount: 3,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
          linkedClaimIds: ["CLM-1", "CLM-2", "CLM-3"],
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-multi");

    // Multi link → success "{N} claims" badge.
    const manyBadge = document.body.querySelector(
      '[data-testid="claims-badge-many"]',
    );
    expect(manyBadge).not.toBeNull();
    expect(manyBadge?.textContent).toContain("3 claims");
    expect(manyBadge?.getAttribute("data-claims-count")).toBe("3");

    unmount();
  });

  it("test_claims_badge_treats_undefined_linked_claim_ids_as_orphan", async () => {
    // Defensive: older ack rows (pre-SP28) might not carry
    // `linkedClaimIds` at all. The page must render them as
    // orphan (count == 0) rather than crashing on undefined.length.
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 45,
          sourceBatchId: "b-legacy",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
          // linkedClaimIds intentionally omitted (legacy row)
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-legacy");

    const orphanBadge = document.body.querySelector(
      '[data-testid="claims-badge-orphan"]',
    );
    expect(orphanBadge).not.toBeNull();

    unmount();
  });
});