// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { useEffect, useState } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Inbox from "./Inbox";
import * as inboxApi from "@/lib/inbox-api";
import * as apiModule from "@/lib/api";
import * as downloadModule from "@/lib/download";

// The Inbox's write-affordance BulkBars (resubmit / acknowledge /
// dismiss) are wrapped in RoleGate, which reads the auth context to
// decide whether the current user has write permission. We don't want
// the tests to spin up the full AuthProvider + /api/auth/me probe —
// instead we stub useAuth to return an admin user so RoleGate renders
// the BulkBars synchronously on first render.
vi.mock("@/auth/useAuth", () => ({
  useAuth: () => ({
    status: "authenticated" as const,
    user: {
      id: "test-admin",
      username: "test-admin",
      role: "admin" as const,
      created_at: "2026-01-01T00:00:00Z",
    },
    login: vi.fn(),
    logout: vi.fn(),
    refresh: vi.fn(),
  }),
}));

// SP21 Phase 4 Task 4.4: Inbox now uses `useNavigate` (for unmatched
// claim drilldown to /claims?claim=ID), so the render harness needs
// a Router context. We use a fresh QueryClient per test so the
// drawer's per-remit query doesn't leak cache between cases, and a
// MemoryRouter so `useNavigate` has a router to push into.
//
// Phase 5 Task 5.4: MemoryRouter doesn't update window.location, so
// the navigation assertion uses a small LocationTracker component
// mounted under the same router that records the current pathname +
// search after each render. Tests then read `tracker.last` instead
// of `window.location`.
function LocationTracker({ on }: { on: (pathname: string, search: string) => void }) {
  const loc = useLocation();
  useEffect(() => {
    on(loc.pathname, loc.search);
  }, [loc.pathname, loc.search, on]);
  return null;
}

function renderInbox() {
  const qc = new QueryClient({
    defaultOptions: { queries: { retry: false, retryDelay: 0 } },
  });
  // Capture the last location seen so tests can assert on navigation
  // without poking at window.location (MemoryRouter doesn't touch it).
  const captured = { pathname: "/inbox", search: "" };
  const view = render(
    <MemoryRouter initialEntries={["/inbox"]}>
      <QueryClientProvider client={qc}>
        <LocationTracker
          on={(pathname, search) => {
            captured.pathname = pathname;
            captured.search = search;
          }}
        />
        <Inbox />
      </QueryClientProvider>
    </MemoryRouter>,
  );
  // Attach the tracker so individual tests can read it. Note: the
  // captured value updates asynchronously after navigation, but since
  // LocationTracker runs in the same render pass as the navigate,
  // tests should `waitFor` it.
  (view as unknown as { tracker: typeof captured }).tracker = captured;
  return view as ReturnType<typeof render> & { tracker: typeof captured };
}

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

describe("Inbox page", () => {
  it("renders the five lane headers", async () => {
    // SP14: the Payer-Rejected lane was added (277CA STC A4/A6/A7).
    // The Inbox now renders 5 lanes: REJECTED, PAYER REJECTED,
    // CANDIDATES, UNMATCHED, DONE.
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          rejected: [],
          payer_rejected: [],
          candidates: [],
          unmatched: [],
          done_today: [],
        }),
      }),
    );
    const { container } = renderInbox();
    await waitFor(() => {
      expect(container.textContent).toContain("REJECTED");
      expect(container.textContent).toContain("PAYER REJECTED");
      expect(container.textContent).toContain("CANDIDATES");
      expect(container.textContent).toContain("UNMATCHED");
      expect(container.textContent).toContain("DONE");
    });
  });

  it("SP14: payer-rejected row count rolls up into the need-eyes header", async () => {
    // The "need eyes" total in the header is the operator's
    // anchor for the day. Payer-rejected claims are actionable
    // (operator must acknowledge or follow up), so they count
    // toward the need-eyes total alongside 999 rejected, candidates,
    // and unmatched.
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          rejected: [],
          payer_rejected: [
            { id: "PR1", kind: "claim", patient_control_number: "PR1",
              charge_amount: 250, payer_id: "CO_TXIX", provider_npi: "1881068062",
              state: "submitted", rejection_reason: "A7 invalid dx",
              service_date_from: null, payer_rejected_status_code: "A7",
              payer_rejected_reason: "invalid diagnosis",
              payer_rejected_at: "2026-06-20T10:00:00Z" },
          ],
          candidates: [],
          unmatched: [],
          done_today: [],
        }),
      }),
    );
    const { container } = renderInbox();
    await waitFor(() => {
      expect(container.textContent).toContain("1");
      expect(container.textContent).toContain("items need eyes");
    });
  });

  it("SP14: acknowledging selected payer-rejected claims calls the API and refetches", async () => {
    // The user selects a payer-rejected claim, hits Acknowledge, and
    // the inbox calls /api/inbox/payer-rejected/acknowledge with the
    // claim id, then refetches the lane payload.
    const fetchMock = vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("/api/inbox/lanes")) {
        return {
          ok: true,
          json: async () => ({
            rejected: [],
            payer_rejected: [
              { id: "PR1", kind: "claim", patient_control_number: "PR1",
                charge_amount: 250, payer_id: "CO_TXIX", provider_npi: "1881068062",
                state: "submitted", rejection_reason: "A7 invalid dx",
                service_date_from: null, payer_rejected_status_code: "A7",
                payer_rejected_reason: "invalid diagnosis",
                payer_rejected_at: "2026-06-20T10:00:00Z" },
            ],
            candidates: [],
            unmatched: [],
            done_today: [],
          }),
        };
      }
      if (url.includes("/api/inbox/payer-rejected/acknowledge")) {
        return {
          ok: true,
          json: async () => ({
            ok: true,
            transitioned: 1,
            already_acked: 0,
            not_found: 0,
            not_rejected: 0,
          }),
        };
      }
      return { ok: true, json: async () => ({}) };
    });
    vi.stubGlobal("fetch", fetchMock);

    const { container, getByTestId } = renderInbox();
    await waitFor(() => {
      expect(container.textContent).toContain("PR1");
    });

    // Select the PR1 claim via its checkbox.
    const checkbox = container.querySelector(
      'input[type="checkbox"][aria-label="Select PR1"]',
    ) as HTMLInputElement;
    expect(checkbox).toBeTruthy();
    await act(async () => {
      checkbox.click();
    });

    // Hit Acknowledge. The bulk action bar button is wired to the
    // /api/inbox/payer-rejected/acknowledge endpoint.
    const ackButton = getByTestId("payer-rejected-acknowledge");
    await act(async () => {
      ackButton.click();
    });

    // The mock was called with the acknowledge URL.
    const ackCall = fetchMock.mock.calls.find(
      (c) => typeof c[0] === "string" && c[0].includes("/api/inbox/payer-rejected/acknowledge"),
    );
    expect(ackCall).toBeTruthy();
    expect(ackCall).toBeDefined();
    const ackBody = JSON.parse(ackCall![1].body);
    expect(ackBody.claim_ids).toEqual(["PR1"]);
  });

  it("SP8: multi-select resubmit opens bundle modal; Resubmit + Download triggers api + downloadBlob", async () => {
    // Two rejected claims → user selects both → Re-submit → modal appears
    // → user clicks Resubmit + Download → backend is hit with
    // ?download=true and the returned blob is handed to downloadBlob.
    const fetchMock = vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("/api/inbox/lanes")) {
        return {
          ok: true,
          json: async () => ({
            rejected: [
              { id: "C1", kind: "claim", payer_claim_control_number: "C1",
                charge_amount: 100, payer_id: "P1", provider_npi: "N1",
                state: "rejected", rejection_reason: "old", service_date: null,
                score: null },
              { id: "C2", kind: "claim", payer_claim_control_number: "C2",
                charge_amount: 200, payer_id: "P1", provider_npi: "N1",
                state: "rejected", rejection_reason: "old", service_date: null,
                score: null },
            ],
            payer_rejected: [],
            candidates: [],
            unmatched: [],
            done_today: [],
          }),
        };
      }
      if (url.includes("/api/inbox/rejected/resubmit")) {
        return {
          ok: true,
          blob: async () => new Blob(["PK\u0003\u0004fake-zip-bytes"], { type: "application/zip" }),
          headers: {
            get: (name: string) =>
              name.toLowerCase() === "content-disposition"
                ? 'attachment; filename="resubmit-2-claims.zip"'
                : name.toLowerCase() === "x-cyclone-serialize-errors"
                  ? null
                  : null,
          },
        };
      }
      return { ok: true, json: async () => ({}) };
    });
    vi.stubGlobal("fetch", fetchMock);

    // Spy on the two side-effects we care about: the api call + the
    // browser download. Both are module-scope so we install spies and
    // reset between tests (afterEach already calls vi.restoreAllMocks
    // but we mockReset to clear call history from other tests).
    const resubmitDownloadSpy = vi
      .spyOn(inboxApi, "resubmitRejectedWithDownload")
      .mockResolvedValue({
        blob: new Blob(["PK\u0003\u0004fake-zip-bytes"], { type: "application/zip" }),
        filename: "resubmit-2-claims.zip",
        serializeErrors: [],
      });
    const downloadSpy = vi
      .spyOn(downloadModule, "downloadBlob")
      .mockImplementation(() => {});

    const { container, getByText } = renderInbox();
    await waitFor(() => {
      expect(container.textContent).toContain("C1");
      expect(container.textContent).toContain("C2");
    });

    // Select both rejected rows (the checkbox is the only <input> per row).
    const checkboxes = container.querySelectorAll('input[type="checkbox"]');
    expect(checkboxes.length).toBeGreaterThanOrEqual(2);
    await act(async () => {
      fireEvent.click(checkboxes[0]);
      fireEvent.click(checkboxes[1]);
    });

    // BulkBar shows a Re-submit button for the rejected lane.
    const resubmitBtn = getByText(/Re-submit \(2\)/);
    await act(async () => {
      fireEvent.click(resubmitBtn);
    });

    // Modal appears with both options.
    const modal = container.querySelector(
      '[data-testid="resubmit-bundle-modal"]',
    );
    expect(modal).not.toBeNull();

    const downloadBtn = container.querySelector(
      '[data-testid="resubmit-modal-download"]',
    ) as HTMLButtonElement | null;
    expect(downloadBtn).not.toBeNull();
    await act(async () => {
      fireEvent.click(downloadBtn!);
    });

    // The api was called with the selected ids, and the blob was
    // handed to downloadBlob with the right filename.
    expect(resubmitDownloadSpy).toHaveBeenCalledTimes(1);
    expect(resubmitDownloadSpy).toHaveBeenCalledWith(["C1", "C2"]);
    expect(downloadSpy).toHaveBeenCalledTimes(1);
    const [filename, blob] = downloadSpy.mock.calls[0];
    expect(filename).toBe("resubmit-2-claims.zip");
    expect(blob).toBeInstanceOf(Blob);
  });

  it("SP21 Task 4.4: clicking a candidate row opens the RemitDrawer", async () => {
    // Candidates are remits (payer_claim_control_number-keyed) — the
    // row's `id` is the remit id, so clicking drills into the
    // RemitDrawer via `?remit=ID`.
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          rejected: [],
          payer_rejected: [],
          candidates: [
            {
              id: "REM-7",
              kind: "remit",
              payer_claim_control_number: "REM-7",
              charge_amount: 200,
              payer_id: "P1",
              rendering_provider_npi: "1234567890",
              service_date: "2026-06-19",
              candidates: [],
            },
          ],
          unmatched: [],
          done_today: [],
        }),
      }),
    );

    const { container } = renderInbox();
    await waitFor(() => {
      expect(container.textContent).toContain("REM-7");
    });

    // No drawer yet — the URL is `/inbox` with no `?remit=`.
    expect(
      container.querySelector('[data-testid="remit-drawer"]')
    ).toBeNull();

    // Click the candidate row. The InboxRow renders as a <tr> with
    // the remit id in its first cell; clicking that row bubbles to
    // the Lane's onRowClick handler we wired in Task 4.4.
    const cell = Array.from(container.querySelectorAll("td")).find(
      (td) => td.textContent === "REM-7",
    );
    const row = cell?.closest("tr");
    expect(row).toBeTruthy();
    await act(async () => {
      fireEvent.click(row as HTMLElement);
    });

    // Drawer portals into document.body — check there, not container.
    await waitFor(() => {
      expect(
        document.body.querySelector('[data-testid="remit-drawer"]'),
      ).not.toBeNull();
    });
  });

  it("SP21 Task 4.4: deep-link ?remit=ID opens the drawer on mount", async () => {
    // Pre-set the URL so the hook reads REM-7 off `window.location.search`
    // during its `useState` initializer — no click needed. The inbox
    // already mounts the drawer, so a deep link to /inbox?remit=REM-7
    // should land with the drawer open.
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          rejected: [],
          payer_rejected: [],
          candidates: [],
          unmatched: [],
          done_today: [],
        }),
      }),
    );
    (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(
      "http://localhost/inbox?remit=REM-7",
    );

    renderInbox();

    await waitFor(() => {
      expect(
        document.body.querySelector('[data-testid="remit-drawer"]'),
      ).not.toBeNull();
    });
  });

  it("SP21 Task 5.4: clicking a rejected claim row navigates to /claims?claim=ID", async () => {
    // Task 5.4 wires the rejected lane's onRowClick to navigate to
    // the ClaimDrawer via `?claim=ID` on the /claims route. The
    // MemoryRouter (initial /inbox) lets us observe the URL change
    // via the LocationTracker helper mounted in the render harness.
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          rejected: [
            {
              id: "REJ1",
              kind: "claim",
              payer_claim_control_number: "REJ1",
              charge_amount: 175,
              payer_id: "P1",
              provider_npi: "1234567890",
              state: "rejected",
              rejection_reason: "999 reject",
              service_date: null,
              score: null,
            },
          ],
          payer_rejected: [],
          candidates: [],
          unmatched: [],
          done_today: [],
        }),
      }),
    );

    const view = renderInbox();
    await waitFor(() => {
      expect(view.container.textContent).toContain("REJ1");
    });

    // Click the rejected row. The InboxRow renders the claim id as
    // its primary text cell; clicking that row bubbles up to the
    // Lane's onRowClick handler we wired in Task 5.4.
    const cell = Array.from(view.container.querySelectorAll("td")).find(
      (td) => td.textContent === "REJ1",
    );
    const row = cell?.closest("tr");
    expect(row).toBeTruthy();
    await act(async () => {
      fireEvent.click(row as HTMLElement);
    });

    // MemoryRouter navigates to /claims?claim=REJ1 — verify the
    // tracker observed the new pathname + search.
    await waitFor(() => {
      expect(view.tracker.pathname).toBe("/claims");
      expect(view.tracker.search).toBe("?claim=REJ1");
    });
  });

  it("SP21 Task 5.4: clicking a done_today claim row navigates to /claims?claim=ID", async () => {
    // done_today rows are claim-shaped — same drill pattern as the
    // rejected lane. Verifies that the wiring covers the trailing
    // "shipped today" lane too, not just the error lanes.
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          rejected: [],
          payer_rejected: [],
          candidates: [],
          unmatched: [],
          done_today: [
            {
              id: "DONE1",
              kind: "claim",
              patient_control_number: "DONE1",
              charge_amount: 88,
              payer_id: "P1",
              provider_npi: "1234567890",
              state: "submitted",
              service_date_from: "2026-06-21",
            },
          ],
        }),
      }),
    );

    const view = renderInbox();
    await waitFor(() => {
      expect(view.container.textContent).toContain("DONE1");
    });

    const cell = Array.from(view.container.querySelectorAll("td")).find(
      (td) => td.textContent === "DONE1",
    );
    const row = cell?.closest("tr");
    expect(row).toBeTruthy();
    await act(async () => {
      fireEvent.click(row as HTMLElement);
    });

    await waitFor(() => {
      expect(view.tracker.pathname).toBe("/claims");
      expect(view.tracker.search).toBe("?claim=DONE1");
    });
  });

  it("SP21 Task 5.4: clicking the row checkbox does not bubble to onRowClick", async () => {
    // Per the plan's §self-review #5, the Lane's RowCheckbox already
    // calls e.stopPropagation() — verify that's still the case by
    // clicking the checkbox and confirming the URL didn't change to
    // /claims. (If stopPropagation regressed, the row click handler
    // would fire and navigate.)
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        ok: true,
        json: async () => ({
          rejected: [
            {
              id: "REJ1",
              kind: "claim",
              payer_claim_control_number: "REJ1",
              charge_amount: 175,
              payer_id: "P1",
              provider_npi: "1234567890",
              state: "rejected",
              rejection_reason: "999 reject",
              service_date: null,
              score: null,
            },
          ],
          payer_rejected: [],
          candidates: [],
          unmatched: [],
          done_today: [],
        }),
      }),
    );

    const view = renderInbox();
    await waitFor(() => {
      expect(view.container.textContent).toContain("REJ1");
    });

    const checkbox = view.container.querySelector(
      'input[type="checkbox"][aria-label="Select REJ1"]',
    ) as HTMLInputElement;
    expect(checkbox).toBeTruthy();
    await act(async () => {
      checkbox.click();
    });

    // URL should still be /inbox — clicking the checkbox selected
    // the row but did not drill into the claim drawer.
    expect(view.tracker.pathname).toBe("/inbox");
  });

  // -------------------------------------------------------------------------
  // SP28: the new "ACK ORPHANS" lane (D7). Mirrors the payer-rejected
  // lane shape; per-row "Dismiss" + "Link to…" actions; empty state
  // when no orphans; live-tail integration drops a row that just got
  // linked via the manual-match endpoint.
  // -------------------------------------------------------------------------

  /**
   * URL-aware fetch mock for the inbox page. Routes `/api/inbox/lanes`
   * to the supplied `lanes`, and `/api/inbox/ack-orphans?kind=<k>` to
   * the matching subset of `orphans`. The hook fires three parallel
   * requests (one per kind: 999 / 277ca / ta1), so the mock must
   * filter by `kind` to avoid returning the same payload three times.
   * Default orphan response is empty so tests that don't care about
   * the new lane just get a clean "No orphan acks." placeholder.
   */
  function makeInboxFetch(
    lanes: Record<string, unknown>,
    orphans: Array<{ kind: string } & Record<string, unknown>> = [],
  ) {
    return vi.fn().mockImplementation(async (url: string) => {
      if (url.includes("/api/inbox/ack-orphans")) {
        // Pull the `kind` query param out of the URL so the mock
        // returns only the orphans for the requested kind.
        const m = /[?&]kind=([^&]+)/.exec(url);
        const requestedKind = m?.[1] ?? "";
        const filtered = orphans.filter((o) => o.kind === requestedKind);
        return {
          ok: true,
          json: async () => ({ items: filtered, total: filtered.length }),
        };
      }
      return { ok: true, json: async () => lanes };
    });
  }

  it("test_ack_orphans_lane_renders_empty_state_when_no_orphans", async () => {
    vi.stubGlobal("fetch", makeInboxFetch({
      rejected: [], payer_rejected: [], candidates: [], unmatched: [], done_today: [],
    }));
    const view = renderInbox();
    await waitFor(() => {
      expect(view.container.textContent).toContain("ACK ORPHANS");
    });
    // Empty-state copy must surface — the lane's friendly "nothing
    // here" treatment rather than a quiet section.
    expect(view.container.textContent).toContain("No orphan acks.");
    // No rows rendered.
    expect(
      view.container.querySelectorAll('[data-testid="ack-orphan-row"]').length
    ).toBe(0);
  });

  it("test_ack_orphans_lane_renders_one_row_per_orphan", async () => {
    vi.stubGlobal(
      "fetch",
      makeInboxFetch(
        {
          rejected: [],
          payer_rejected: [],
          candidates: [],
          unmatched: [],
          done_today: [],
        },
        [
          {
            id: 100,
            kind: "999",
            pcn: "991102989",
            sourceBatchId: "b-orphan-1",
            parsedAt: "2026-07-01T12:00:00Z",
            candidates: [{ claimId: "CLM-1", score: 0.92, tier: "strong" }],
          },
          {
            id: 200,
            kind: "ta1",
            pcn: "000000001",
            sourceBatchId: "b-orphan-2",
            parsedAt: "2026-07-01T11:00:00Z",
            candidates: [],
          },
        ],
      ),
    );
    const view = renderInbox();
    await waitFor(() => {
      // Wait until both rows are in the DOM.
      expect(
        view.container.querySelectorAll('[data-testid="ack-orphan-row"]')
          .length,
      ).toBe(2);
    });
    // PCN is rendered verbatim — the operator's primary identifier.
    expect(view.container.textContent).toContain("991102989");
    expect(view.container.textContent).toContain("000000001");
    // The kind badge is visible per row.
    const badges = view.container.querySelectorAll(
      '[data-testid="ack-orphan-kind-badge"]',
    );
    expect(badges.length).toBe(2);
    // The header count is 2 (matches the row count).
    expect(
      view.container.querySelector('[data-testid="ack-orphans-count"]')
        ?.textContent,
    ).toBe("2");
  });

  it("test_ack_orphans_lane_dismiss_button_drops_row_optimistically", async () => {
    const orphans = [
      {
        id: 300,
        kind: "277ca",
        pcn: "X-1",
        sourceBatchId: "b-orphan-3",
        parsedAt: "2026-07-01T12:00:00Z",
        candidates: [],
      },
    ];
    vi.stubGlobal(
      "fetch",
      makeInboxFetch(
        {
          rejected: [],
          payer_rejected: [],
          candidates: [],
          unmatched: [],
          done_today: [],
        },
        orphans,
      ),
    );
    const view = renderInbox();
    await waitFor(() => {
      expect(
        view.container.querySelector('[data-testid="ack-orphan-row"]'),
      ).not.toBeNull();
    });
    // Click the dismiss button — the row drops optimistically.
    const dismissBtn = view.container.querySelector(
      '[data-testid="ack-orphan-dismiss"]',
    ) as HTMLButtonElement | null;
    expect(dismissBtn).not.toBeNull();
    await act(async () => {
      dismissBtn!.click();
    });
    // Empty-state copy surfaces now.
    await waitFor(() => {
      expect(view.container.textContent).toContain("No orphan acks.");
    });
  });

  it("test_ack_orphans_lane_link_to_dropdown_lists_candidates", async () => {
    const orphans = [
      {
        id: 400,
        kind: "999",
        pcn: "Y-1",
        sourceBatchId: "b-orphan-4",
        parsedAt: "2026-07-01T12:00:00Z",
        candidates: [
          { claimId: "CLM-A", score: 0.95, tier: "strong" },
          { claimId: "CLM-B", score: 0.62, tier: "weak" },
        ],
      },
    ];
    vi.stubGlobal(
      "fetch",
      makeInboxFetch(
        {
          rejected: [],
          payer_rejected: [],
          candidates: [],
          unmatched: [],
          done_today: [],
        },
        orphans,
      ),
    );
    const view = renderInbox();
    await waitFor(() => {
      expect(
        view.container.querySelector('[data-testid="ack-orphan-row"]'),
      ).not.toBeNull();
    });
    // Open the candidate list.
    const toggle = view.container.querySelector(
      '[data-testid="ack-orphan-link-toggle"]',
    ) as HTMLButtonElement | null;
    expect(toggle).not.toBeNull();
    await act(async () => {
      toggle!.click();
    });
    await waitFor(() => {
      expect(
        view.container.querySelector('[data-testid="ack-orphan-candidate-list"]'),
      ).not.toBeNull();
    });
    // Two candidate options render — one per backend-supplied match.
    const options = view.container.querySelectorAll(
      '[data-testid="ack-orphan-candidate-option"]',
    );
    expect(options.length).toBe(2);
    expect(options[0]?.getAttribute("data-claim-id")).toBe("CLM-A");
    expect(options[1]?.getAttribute("data-claim-id")).toBe("CLM-B");
  });

  // -------------------------------------------------------------------------
  // SP29: per-row Resubmit button on rejected-lane claim rows downloads
  // the single-claim corrected 837. The bulk resubmit path (multi-select)
  // is exercised above in the "SP8 multi-select resubmit" test; this
  // covers the dedicated single-claim gesture and the operator's
  // most-click flow ("I see one reject in the lane, I click Resubmit
  // next to it").
  // -------------------------------------------------------------------------
  it("SP29: per-row Resubmit button downloads the single-claim 837", async () => {
    // Lane returns one rejected claim. The page is responsible for
    // calling api.serializeClaim837(id) and handing the returned
    // text to downloadTextFile as <id>.x12.
    vi.stubGlobal(
      "fetch",
      vi.fn().mockImplementation(async (url: string) => {
        if (url.includes("/api/inbox/lanes")) {
          return {
            ok: true,
            json: async () => ({
              rejected: [
                {
                  id: "REJ1",
                  kind: "claim",
                  patient_control_number: "REJ1",
                  charge_amount: 175,
                  payer_id: "P1",
                  provider_npi: "1234567890",
                  state: "rejected",
                  rejection_reason: "999 AK9 R",
                  rejected_at: "2026-07-02T09:00:00Z",
                  service_date_from: null,
                  claim_acks: {
                    total: 2,
                    rejected: 1,
                    items: [
                      {
                        ack_id: 50,
                        set_control_number: "991102989",
                        set_accept_reject_code: "R",
                        ak2_index: 0,
                        linked_at: "2026-07-02T09:00:00Z",
                      },
                      {
                        ack_id: 51,
                        set_control_number: "991102989",
                        set_accept_reject_code: "A",
                        ak2_index: 1,
                        linked_at: "2026-07-02T08:59:00Z",
                      },
                    ],
                  },
                },
              ],
              payer_rejected: [],
              candidates: [],
              unmatched: [],
              done_today: [],
            }),
          };
        }
        return { ok: true, json: async () => ({}) };
      }),
    );

    // Spy on the api call AND the download sink. The page calls
    // `api.serializeClaim837(id)` (namespace import from @/lib/api),
    // and downloadTextFile is on the download module.
    const serializeSpy = vi
      .spyOn(apiModule.api, "serializeClaim837")
      .mockResolvedValue({
        text: "ISA*00*~...",
        filename: "claim-REJ1.x12",
      });
    const downloadSpy = vi
      .spyOn(downloadModule, "downloadTextFile")
      .mockImplementation(() => {});

    const view = renderInbox();
    await waitFor(() => {
      expect(view.container.textContent).toContain("REJ1");
    });

    // Click the per-row Resubmit button — its testid includes the
    // claim id so the operator can target one row at a time.
    const btn = view.container.querySelector(
      '[data-testid="resubmit-REJ1"]',
    ) as HTMLButtonElement | null;
    expect(btn).not.toBeNull();
    expect(btn!.textContent).toBe("Resubmit");

    await act(async () => {
      fireEvent.click(btn!);
    });

    // The api was called with the row's claim id, and the resulting
    // .x12 was handed to downloadTextFile with text/x12 mime so the
    // browser saves it as a download.
    await waitFor(() => {
      expect(serializeSpy).toHaveBeenCalledTimes(1);
      expect(serializeSpy).toHaveBeenCalledWith("REJ1");
    });
    expect(downloadSpy).toHaveBeenCalledTimes(1);
    const [filename, mime, text] = downloadSpy.mock.calls[0];
    expect(filename).toBe("claim-REJ1.x12");
    expect(mime).toBe("text/x12");
    expect(text).toBe("ISA*00*~...");
  });
});
