// @vitest-environment happy-dom
// ClaimDrawer wires `useClaimDetail` (TanStack Query) + a window-level
// keyboard listener. Both paths need an act-aware environment or React
// logs noisy warnings. Mirror the convention from ClaimDrawer.test.tsx.
(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, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { Claims } from "./Claims";
import { DrillStackProvider } from "@/components/drill/DrillStackProvider";
import { api } from "@/lib/api";
import { useTailStore } from "@/store/tail-store";
import type { Claim, ClaimDetail } from "@/types";

// Module-level mock — vitest hoists vi.mock above imports. We mock the
// two API methods the test path actually touches:
//
//   - `api.listClaims` is called by `useClaims` (via TanStack Query) so
//     the table has data to render rows.
//   - `api.getClaimDetail` is called by `useClaimDetail` from inside the
//     ClaimDrawer once the URL has `?claim=…`.
//
// Same shape as ClaimDrawer.test.tsx; we just add `listClaims` to the
// mock object so the page-under-test populates its table.
vi.mock("@/lib/api", () => {
  class ApiError extends Error {
    constructor(public status: number, message: string) {
      super(message);
    }
  }
  return {
    api: {
      isConfigured: true,
      listClaims: vi.fn(),
      getClaimDetail: vi.fn(),
    },
    ApiError,
  };
});

// ---------------------------------------------------------------------------
// Live-tail hook mock (sub-project 5, Phase 5 Task 22 page-level tests).
//
// We mock `useTailStream` directly (per the plan's "latter is simpler"
// guidance) so the page-level tests don't have to drive the real
// `streamTail` parser or the real AbortController-based reconnect loop.
// `useTailStream` is exercised exhaustively in its own test file
// (`useTailStream.test.ts`); the page only needs to observe that the
// returned status flows through to `<TailStatusPill>` and that items
// pushed into the tail store surface as rows via `useMergedTail`.
//
// The mock returns `status: "live"` by default — that matches what the
// real hook settles to after a `snapshot_end` event arrives, which is
// the state the page sees in production ~all of the time.
// ---------------------------------------------------------------------------
vi.mock("@/hooks/useTailStream", () => ({
  useTailStream: vi.fn(() => ({
    status: "live",
    lastEventAt: null,
    error: null,
    forceReconnect: vi.fn(),
  })),
}));

const SAMPLE_CLAIMS: Claim[] = [
  {
    id: "CLM-1",
    patientName: "Jane Doe",
    providerNpi: "1234567890",
    payerName: "Colorado Medicaid",
    cptCode: "99213",
    billedAmount: 123.45,
    receivedAmount: 0,
    status: "submitted",
    submissionDate: "2026-06-15",
  },
  {
    id: "CLM-2",
    patientName: "John Smith",
    providerNpi: "1234567890",
    payerName: "Aetna",
    cptCode: "99214",
    billedAmount: 200,
    receivedAmount: 200,
    status: "paid",
    submissionDate: "2026-06-16",
  },
];

const SAMPLE_DETAIL: ClaimDetail = {
  id: "CLM-1",
  batchId: "B-1",
  state: "submitted",
  stateLabel: "Submitted",
  billedAmount: 123.45,
  patientName: "Jane Doe",
  providerNpi: "1234567890",
  providerName: "Acme Clinic",
  payerName: "Colorado Medicaid",
  payerId: "MEDCO",
  submissionDate: "2026-06-15T00:00:00Z",
  serviceDateFrom: null,
  serviceDateTo: null,
  parsedAt: "2026-06-15T00:00:01Z",
  diagnoses: [],
  serviceLines: [],
  parties: {
    billingProvider: {
      name: "Acme Clinic",
      npi: "1234567890",
      taxId: "12-3456789",
      address: {
        line1: "123 Main St",
        line2: null,
        city: "Denver",
        state: "CO",
        zip: "80202",
      },
    },
    subscriber: {
      firstName: "Jane",
      lastName: "Doe",
      memberId: "M-1",
      dob: null,
      gender: "F",
    },
    payer: { name: "Colorado Medicaid", id: "MEDCO" },
  },
  validation: { passed: true, errors: [], warnings: [] },
  rawSegments: [],
  matchedRemittance: null,
  stateHistory: [],
};

/**
 * Point happy-dom at a known URL. happy-dom exposes `setURL` on
 * `window.happyDOM` (a non-standard helper for tests) — copying the
 * pattern from `useDrawerUrlState.test.ts` so the two files stay
 * consistent.
 */
function setLocation(url: string): void {
  (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url);
}

/**
 * Render harness. Mirrors the pattern from Acks/Remittances test files:
 * a real DOM container wrapped in `QueryClientProvider` so the page's
 * TanStack Query calls (via `useClaims`) resolve against our mocked
 * `api.listClaims`.
 *
 * Also wraps in a `MemoryRouter` so the page's `useSearchParams`
 * (added when Claims started reading `?status=` / `?sort=` off the URL
 * for Dashboard KPI drill-through) has a router context to read from.
 * The default `initialEntries` mirrors whatever `setLocation(...)` set
 * on `window.location`, so the existing tests' pre-set URLs keep
 * working without per-test changes.
 */
function renderClaims(): { unmount: () => void } {
  const initialEntries = [
    window.location.pathname + window.location.search,
  ];
  return renderClaimsAt(initialEntries);
}

/**
 * Variant of `renderClaims` that lets a test pin a specific initial
 * URL. Used by the URL-param filter tests below so each one can start
 * at exactly the URL it's asserting on.
 */
function renderClaimsAt(initialEntries: string[]): {
  unmount: () => void;
  container: HTMLDivElement;
} {
  const container = document.createElement("div");
  document.body.appendChild(container);
  const qc = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        // Default retry delay is exponential backoff — way too slow for
        // unit tests. Zero keeps the happy path fast.
        retryDelay: 0,
      },
    },
  });
  const root: Root = createRoot(container);
  act(() => {
    root.render(
      React.createElement(
        QueryClientProvider,
        { client: qc },
        React.createElement(
          MemoryRouter,
          { initialEntries },
          // SP21 Phase 5 Task 5.8/5.9: the ClaimDrawer mounted by
          // Claims now uses useDrillStack() in PartiesGrid +
          // ValidationPanel. Wrap in a DrillStackProvider so the
          // hook has a context (the provider is also mounted at
          // the App root in production).
          React.createElement(
            DrillStackProvider,
            null,
            React.createElement(Claims),
          ),
        ),
      ),
    );
  });
  return {
    container,
    unmount: () => {
      act(() => root.unmount());
      container.remove();
    },
  };
}

/**
 * Flush microtasks + react state updates until `predicate` holds (or we
 * time out). react-query's fetch resolves asynchronously, then Radix
 * portals the drawer into document.body — both need a tick before the
 * DOM reflects the new state.
 */
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));
    });
  }
}

describe("Claims page drawer wiring", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    // Reset URL to bare claims page between tests.
    setLocation("http://localhost/claims");
    (api.listClaims as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: SAMPLE_CLAIMS,
      total: SAMPLE_CLAIMS.length,
      returned: SAMPLE_CLAIMS.length,
      has_more: false,
    });
    (api.getClaimDetail as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      SAMPLE_DETAIL
    );
    // Live-tail slice is a singleton zustand store — clear it between
    // tests so the live-arrival test doesn't see rows from a previous
    // case.
    useTailStore.getState().reset("claims");
  });

  afterEach(() => {
    // Defensive: leave the URL clean for the next test file.
    setLocation("http://localhost/claims");
  });

  it("test_clicking_a_row_opens_the_drawer", async () => {
    const { unmount } = renderClaims();

    // Wait for the table to populate from the mocked api.listClaims.
    await settle(
      () => document.body.textContent?.includes("CLM-1") ?? false
    );

    // Drawer must not be open yet — no `?claim=` in URL means
    // ClaimDrawer renders null (per its early-return when claimId is
    // null).
    expect(document.body.querySelector('[data-testid="claim-drawer"]')).toBeNull();

    // Click the first row (the one showing CLM-1). The page wires
    // `onClick={() => open(c.id)}` to each row, so this should
    // (a) push `?claim=CLM-1` into the URL, and
    // (b) cause the ClaimDrawer to mount in document.body.
    //
    // Note: the id cell renders "CLM-1" plus a sibling "CPT 99213" line,
    // so the row's textContent is "CLM-1CPT 99213…". Match by substring
    // instead of exact equality.
    const row = Array.from(document.querySelectorAll("tbody tr")).find(
      (r) => r.textContent?.includes("CLM-1")
    ) as HTMLTableRowElement | undefined;
    expect(row).toBeDefined();
    await act(async () => {
      row!.click();
    });

    // URL side effect — happy-dom's pushState updates window.location,
    // so this assertion is end-to-end against the real hook.
    expect(window.location.href).toContain("?claim=CLM-1");

    // Drawer mounts in a Radix portal under document.body, so we poll
    // for it.
    await settle(
      () => document.body.querySelector('[data-testid="claim-drawer"]') !== null
    );
    expect(
      document.body.querySelector('[data-testid="claim-drawer"]')
    ).not.toBeNull();

    unmount();
  });

  it("test_deep_link_with_?claim=opens_drawer_on_mount", async () => {
    // Pre-set the URL so the hook reads CLM-1 off `window.location.search`
    // during its `useState` initializer — no click needed.
    setLocation("http://localhost/claims?claim=CLM-1");
    renderClaims();

    // Drawer should appear immediately, without user interaction.
    await settle(
      () => document.body.querySelector('[data-testid="claim-drawer"]') !== null
    );
    expect(
      document.body.querySelector('[data-testid="claim-drawer"]')
    ).not.toBeNull();

    // SP21 Phase 5 Task 5.10: the claim id is now the title of the
    // shared DrillDrawerHeader (rendered as an <h2>). Find the h2
    // inside the drawer and assert its text matches the claim id —
    // proves the id propagated end-to-end (URL → useDrawerUrlState
    // → ClaimDrawer prop → useClaimDetail → header render), not
    // just that some drawer is mounted.
    await settle(() => {
      const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
      const h2 = drawer?.querySelector("h2");
      return h2?.textContent === "CLM-1";
    });
    const drawer = document.body.querySelector('[data-testid="claim-drawer"]');
    expect(drawer?.querySelector("h2")?.textContent).toBe("CLM-1");
  });

  it("test_escape_closes_the_drawer", async () => {
    setLocation("http://localhost/claims?claim=CLM-1");
    renderClaims();

    await settle(
      () => document.body.querySelector('[data-testid="claim-drawer"]') !== null
    );

    // Press Escape on `window` — useDrawerKeyboard (called from inside
    // ClaimDrawer) listens there. Page wires onClose → useDrawerUrlState's
    // close() → pushState + setClaimIdState(null).
    await act(async () => {
      window.dispatchEvent(
        new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
      );
    });

    // Drawer should unmount (ClaimDrawer returns null when claimId is null).
    await settle(
      () => document.body.querySelector('[data-testid="claim-drawer"]') === null
    );
    expect(
      document.body.querySelector('[data-testid="claim-drawer"]')
    ).toBeNull();
    // URL must no longer carry ?claim=.
    expect(window.location.href).not.toContain("?claim=");
  });

  it("test_url_clears_after_close", async () => {
    setLocation("http://localhost/claims?claim=CLM-1");
    renderClaims();

    // Wait for the header to render — the close button only mounts once
    // `useClaimDetail` resolves with a real ClaimDetail (the skeleton /
    // error states don't render the header). SP21 Phase 5 Task 5.10:
    // the close button is now inside the shared DrillDrawerHeader;
    // find it via its accessible name.
    await settle(
      () =>
        document.body.querySelector(
          '[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
        ) !== null
    );

    // URL currently carries the claim.
    expect(window.location.href).toContain("?claim=CLM-1");

    // Click the X close button in the header. The page wires this to
    // onClose → useDrawerUrlState.close(), which pushState's a URL with
    // the ?claim= param stripped.
    const closeBtn = document.body.querySelector(
      '[data-testid="claim-drawer"] button[aria-label="Close drawer"]'
    ) as HTMLButtonElement | null;
    expect(closeBtn).not.toBeNull();
    await act(async () => {
      closeBtn!.click();
    });

    // URL must no longer carry ?claim=.
    await settle(() => !window.location.href.includes("?claim="));
    expect(window.location.href).not.toContain("?claim=");
  });

  it("test_question_mark_toggles_cheatsheet", async () => {
    // The cheatsheet keystroke is wired through `useDrawerKeyboard`
    // (which lives inside ClaimDrawer and only enables when
    // `claimId !== null`). Deep-link with `?claim=CLM-1` so the
    // drawer mounts and the keyboard listener is active.
    setLocation("http://localhost/claims?claim=CLM-1");
    const { unmount } = renderClaims();

    // Drawer must be open before we test the toggle.
    await settle(
      () => document.body.querySelector('[data-testid="claim-drawer"]') !== null
    );
    expect(
      document.body.querySelector('[data-testid="claim-drawer"]')
    ).not.toBeNull();

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

    // First `?` press: useDrawerKeyboard's handler flips `helpOpen`
    // true on the page; the cheatsheet portals into document.body.
    await act(async () => {
      window.dispatchEvent(
        new KeyboardEvent("keydown", { key: "?", bubbles: true })
      );
    });
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).not.toBeNull();

    // Second `?` press: same handler flips `helpOpen` back to false;
    // the cheatsheet unmounts. (The cheatsheet's own window-level
    // listener explicitly returns early on `?` — it never pre-empts
    // the parent's toggle, by design.)
    await act(async () => {
      window.dispatchEvent(
        new KeyboardEvent("keydown", { key: "?", bubbles: true })
      );
    });
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).toBeNull();

    // Unmount so the next test (which asserts the drawer is absent)
    // doesn't see this test's still-open drawer leaking into
    // document.body.
    unmount();
  });

  it("test_question_mark_does_nothing_when_drawer_closed", async () => {
    // The `?` keystroke is owned by `useDrawerKeyboard`, which only
    // attaches its `window` listener when `claimId !== null`. With no
    // `?claim=` in the URL, the drawer is closed and `?` is a no-op
    // — the cheatsheet must not appear.
    setLocation("http://localhost/claims");
    renderClaims();

    // Wait for the table to populate (so the page is fully mounted).
    await settle(
      () => document.body.textContent?.includes("CLM-1") ?? false
    );
    expect(
      document.body.querySelector('[data-testid="claim-drawer"]')
    ).toBeNull();

    await act(async () => {
      window.dispatchEvent(
        new KeyboardEvent("keydown", { key: "?", bubbles: true })
      );
    });

    // No cheatsheet should be present — and not just "not yet":
    // flush a tick to make sure a delayed listener couldn't have
    // snuck it in.
    await act(async () => {
      await new Promise((r) => setTimeout(r, 0));
    });
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).toBeNull();
  });

  // -------------------------------------------------------------------
  // Live-tail integration (sub-project 5, Phase 5 Task 22 page tests).
  //
  // We mock `useTailStream` at the module level (see the top of this
  // file) so the page sees a settled `status: "live"` without driving
  // the real streamTail parser. To exercise the "tail arrival triggers a
  // row" path we push a new claim directly into `useTailStore` — this
  // is exactly what the real `useTailStream` hook does on `item` events,
  // so we cover the same render path (zustand notify → useMergedTail
  // re-derive → `<TableRow>` mount).
  // -------------------------------------------------------------------

  it("test_live_tail_arrival_triggers_row_in_table", async () => {
    const { unmount } = renderClaims();

    // Base list (SAMPLE_CLAIMS, set in beforeEach) must be visible first.
    await settle(
      () => document.body.textContent?.includes("CLM-1") ?? false,
    );

    // The new claim id is brand new (not in base, not in tail store yet).
    const TAIL_ID = "CLM-TAIL-1";
    expect(
      document.body.textContent?.includes(TAIL_ID) ?? false,
    ).toBe(false);

    // Simulate a live-arriving claim — this is what `useTailStream`
    // does when it dispatches an `item` event. Wrap in `act` because
    // zustand subscribers re-render synchronously.
    await act(async () => {
      useTailStore.getState().addClaim({
        id: TAIL_ID,
        patientName: "Live Arrival",
        providerNpi: "1234567890",
        payerName: "Colorado Medicaid",
        cptCode: "99215",
        billedAmount: 999,
        receivedAmount: 0,
        status: "submitted",
        submissionDate: "2026-06-20",
      });
    });

    // The new row must appear — proves the end-to-end path
    //   zustand.addClaim → useTailStore notify → useMergedTail re-derive
    //   → <TableBody> mounts the row with the new id.
    await settle(
      () => document.body.textContent?.includes(TAIL_ID) ?? false,
    );
    expect(document.body.textContent).toContain(TAIL_ID);
    // The row's secondary cells should also be present so we know the
    // row fully rendered, not just that the id appeared somewhere
    // (e.g. in the search-input placeholder or similar).
    expect(document.body.textContent).toContain("Live Arrival");
    expect(document.body.textContent).toContain("99215");

    unmount();
  });

  it("test_status_pill_shows_live_after_stream_connects", async () => {
    const { unmount } = renderClaims();

    // Wait for the base table to mount — once it's there, the toolbar
    // (which contains the pill) has also mounted.
    await settle(
      () => document.body.textContent?.includes("CLM-1") ?? false,
    );

    // The mocked `useTailStream` returns `status: "live"`, so the
    // <TailStatusPill> should render with the human label "Live"
    // (per `STATUS_LABEL` in `TailStatusPill.tsx`). We assert on the
    // pill's textContent so this stays robust against future styling
    // changes (the test doesn't depend on Tailwind classes).
    const pill = document.body.querySelector(
      '[data-testid="tail-status-pill"]',
    );
    expect(pill).not.toBeNull();
    expect(pill?.textContent).toContain("Live");

    unmount();
  });

  // -------------------------------------------------------------------
  // URL-driven filter state (sub-project 6, Phase 1 Task 1.8 follow-up).
  //
  // Dashboard KPI tiles drill through to Claims with `?status=` and
  // `?sort=` query params. These tests pin each URL and assert that
  // the page actually filters (not just lands on a default view).
  //
  // The strongest assertion is on `api.listClaims.mock.calls` — that's
  // the exact params the page forwards to the API, so a passing test
  // proves end-to-end that URL → useSearchParams → useClaims → fetch.
  // -------------------------------------------------------------------

  it("test_url_param_status_denied_filters_and_selects_chip", async () => {
    const { unmount, container } = renderClaimsAt(["/claims?status=denied"]);

    // The first render of FilterChips is reached before useSearchParams
    // has the URL-derived `status` value committed (MemoryRouter
    // initializes synchronously but the chip's aria-checked only flips
    // to "true" on the next React commit). Wait for that commit so
    // the assertion below isn't a race against the first render.
    // We settle on `useClaims` having been called with status=denied
    // — that's the strongest invariant: it proves the URL → state →
    // API-call chain has fully propagated end-to-end.
    await settle(
      () => {
        const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
        const lastCallParams = calls[calls.length - 1]?.[0] as
          | { status?: string; sort?: string; order?: string }
          | undefined;
        return lastCallParams?.status === "denied";
      },
    );

    // Scope queries to this test's container so we can't accidentally
    // pick up a chip from a leaked render of a previous test.
    const deniedChip = Array.from(
      container.querySelectorAll('[role="radio"]'),
    ).find(
      (el) => el.textContent?.trim().toLowerCase() === "denied",
    ) as HTMLButtonElement | undefined;
    expect(deniedChip).toBeDefined();
    expect(deniedChip?.getAttribute("aria-checked")).toBe("true");

    // No other status chip should be checked.
    const checkedChips = Array.from(
      container.querySelectorAll('[role="radio"][aria-checked="true"]'),
    );
    expect(checkedChips).toHaveLength(1);
    expect(checkedChips[0]?.textContent?.trim().toLowerCase()).toBe(
      "denied",
    );

    unmount();
  });

  it("test_url_param_sort_minus_receivedAmount_sorts_by_received_desc", async () => {
    const { unmount } = renderClaimsAt(["/claims?sort=-receivedAmount"]);

    // Wait for useClaims to be called once.
    await settle(
      () => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
    );

    const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
    const lastCallParams = calls[calls.length - 1]?.[0] as
      | { status?: string; sort?: string; order?: string }
      | undefined;

    // The `-` prefix must be stripped off and order set to desc.
    expect(lastCallParams?.sort).toBe("receivedAmount");
    expect(lastCallParams?.order).toBe("desc");
    // No status filter — user only asked for a sort change.
    expect(lastCallParams?.status).toBeUndefined();

    unmount();
  });

  it("test_url_param_sort_without_prefix_orders_ascending", async () => {
    const { unmount } = renderClaimsAt(["/claims?sort=submittedDate"]);

    await settle(
      () => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
    );

    const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
    const lastCallParams = calls[calls.length - 1]?.[0] as
      | { status?: string; sort?: string; order?: string }
      | undefined;

    // No `-` prefix → order is asc.
    expect(lastCallParams?.sort).toBe("submittedDate");
    expect(lastCallParams?.order).toBe("asc");

    unmount();
  });

  it("test_no_url_params_defaults_to_billed_desc_all_statuses", async () => {
    // Default URL — matches the existing "click a row to open the
    // drawer" tests, but here we assert on the params forwarded to
    // the API to prove the no-param default matches the previous
    // hardcoded behavior (sort=billedAmount, order=desc, no status).
    const { unmount } = renderClaimsAt(["/claims"]);

    await settle(
      () => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
    );

    const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
    const lastCallParams = calls[calls.length - 1]?.[0] as
      | { status?: string; sort?: string; order?: string }
      | undefined;

    expect(lastCallParams?.status).toBeUndefined();
    expect(lastCallParams?.sort).toBe("billedAmount");
    expect(lastCallParams?.order).toBe("desc");

    unmount();
  });

  it("test_url_param_sort_dash_only_falls_back_to_default", async () => {
    // `?sort=-` is the prefix-with-no-field degenerate case. A naive
    // `slice(1)` would yield "" and the API would get `sort=""`.
    // We assert the page falls back to the default sort instead.
    const { unmount } = renderClaimsAt(["/claims?sort=-"]);

    await settle(
      () => (api.listClaims as ReturnType<typeof vi.fn>).mock.calls.length > 0,
    );

    const calls = (api.listClaims as ReturnType<typeof vi.fn>).mock.calls;
    const lastCallParams = calls[calls.length - 1]?.[0] as
      | { status?: string; sort?: string; order?: string }
      | undefined;

    expect(lastCallParams?.sort).toBe("billedAmount");
    expect(lastCallParams?.order).toBe("desc");

    unmount();
  });

  // -------------------------------------------------------------------
  // Regression for SP21 Task 2.4 — DrillableCell click bubbling.
  //
  // The provider cell renders a <button> (via DrillableCell) inside a
  // <TableRow onClick={() => open(c.id)}>. Before the fix, clicking the
  // provider button (1) navigated to /providers?provider=… and then
  // (2) bubbled to the row, where open() rebuilt the URL on top of the
  // new /providers URL and appended a phantom ?claim=CLM-1. The page
  // mounted on /providers, but the URL was a Frankenstein of both
  // nav states and the browser history was polluted with two entries
  // per click.
  //
  // We assert the URL after a provider-cell click does NOT carry
  // ?claim=. The fix lives in DrillableCell (stopPropagation), so this
  // is an end-to-end check that the component-level guard closes the
  // bubble all the way back at the row.
  //
  // Note on MemoryRouter: react-router's `navigate()` updates the
  // router's internal history but does NOT push to window.history, so
  // `window.location.href` in this test stays on `/claims`. That's
  // still the right place to assert "no phantom claim= param landed on
  // window.history" — that's where the bug manifested (buildUrl() in
  // the row's open() reads window.location and pushState's the bad
  // URL). In a real BrowserRouter the URL would advance to /providers
  // after navigate(), and the same assertion holds: ?claim= would be
  // absent.
  // -------------------------------------------------------------------
  it("test_clicking_provider_cell_navigates_without_pushing_claim_param", async () => {
    const { unmount } = renderClaims();

    // Wait for the table to populate from the mocked api.listClaims.
    await settle(
      () => document.body.textContent?.includes("CLM-1") ?? false,
    );

    // Both SAMPLE_CLAIMS use providerNpi "1234567890", which doesn't
    // match any seeded sampleProvider, so the aria-label falls through
    // to `View provider 1234567890`. Pick the first matching button.
    const btn = Array.from(document.querySelectorAll("button")).find(
      (b) =>
        b.getAttribute("aria-label")?.startsWith("View provider ") ?? false,
    ) as HTMLButtonElement | undefined;
    expect(btn).toBeDefined();

    // Sanity: row's claim-param is not in the URL before the click.
    expect(window.location.href).not.toContain("claim=");

    // Click it. With the fix, the click handler stops propagation before
    // the row's onClick can fire. Without the fix, the row's open()
    // would push a phantom ?claim=CLM-1 onto window.history.
    await act(async () => {
      btn!.click();
    });

    // The URL must not carry a ?claim= param. buildUrl() inside open()
    // would have added that param if the click had bubbled to the row.
    // (See the MemoryRouter note above for why we don't also assert
    // on /providers here.)
    expect(window.location.href).not.toContain("claim=");

    unmount();
  });
});
