// @vitest-environment happy-dom
// Tell React this is an `act`-aware test environment so react-query's
// internal state updates flush through without noisy console warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
  true;

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

// "Match selected" is wrapped in RoleGate, which reads the auth
// context. Stub useAuth to return an admin user so the gate renders
// the button synchronously on first render — no AuthProvider + /me
// probe needed in the test harness.
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(),
  }),
}));

// Module-level mock: vitest hoists `vi.mock` calls above imports. Only the
// methods this page touches need to be stubbed; ApiError is re-exported so
// the page can branch on .status in its catch handler.
vi.mock("@/lib/api", () => ({
  api: {
    listUnmatched: vi.fn(),
    matchRemit: vi.fn(),
    unmatchClaim: vi.fn(),
    getRemittance: vi.fn(),
  },
  ApiError: class ApiError extends Error {
    constructor(public status: number, message: string) {
      super(message);
    }
  },
}));

/**
 * Tracks the current MemoryRouter location so tests can assert on
 * navigation without poking at window.location (MemoryRouter doesn't
 * touch it). Mounted as a sibling under the same router.
 */
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;
}

/**
 * Minimal `render` helper using react-dom/client + act(). Mirrors the
 * `renderHook` helper in `useReconciliation.test.ts` — see that file's
 * comment for the rationale (project doesn't ship a DOM test library
 * yet, and adding one just for these tests would inflate the dev-deps
 * tree). Returns the rendered container so tests can assert against
 * the live DOM via `container.textContent`.
 *
 * SP21 Phase 5 Task 5.5: optionally wraps with a MemoryRouter so the
 * `useNavigate()` call (claim drill to /claims?claim=ID) has a router
 * context. Without this, `useNavigate()` would throw in tests that
 * trigger a claim-card click. Tests that don't need a router can keep
 * using the default (no router) path.
 */
function renderIntoContainer(
  element: React.ReactElement,
  options: { withRouter?: boolean; initialEntries?: string[] } = {},
): {
  container: HTMLDivElement;
  unmount: () => void;
  tracker?: { pathname: string; search: string };
} {
  const container = document.createElement("div");
  document.body.appendChild(container);
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });

  let tracker: { pathname: string; search: string } | undefined;
  const track = (pathname: string, search: string) => {
    if (!tracker) tracker = { pathname, search };
    tracker.pathname = pathname;
    tracker.search = search;
  };

  const inner = options.withRouter
    ? React.createElement(
        MemoryRouter,
        {
          initialEntries: options.initialEntries ?? ["/reconciliation"],
        },
        React.createElement(LocationTracker, { on: track }),
        element,
      )
    : element;

  const root: Root = createRoot(container);
  act(() => {
    root.render(
      React.createElement(
        QueryClientProvider,
        { client: qc },
        inner
      )
    );
  });

  return {
    container,
    unmount: () => {
      act(() => root.unmount());
      container.remove();
    },
    tracker,
  };
}

/**
 * Flush microtasks + react state updates until the predicate holds or we
 * time out. react-query's fetch resolves asynchronously, so we have to
 * await a tick before the rendered DOM reflects the mocked data.
 */
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();
    });
  }
}

describe("ReconciliationPage", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    // Default for the per-remit detail fetch — the drawer fetches
    // this whenever `?remit=` is in the URL. Return a never-resolving
    // promise so the drawer stays in the loading state; the smoke
    // test only asserts the drawer mounts.
    (
      api.getRemittance as unknown as ReturnType<typeof vi.fn>
    ).mockReturnValue(new Promise(() => {}));
    // SP21 Phase 5 Task 5.5: reset window.location to a clean
    // /reconciliation URL so `useRemitDrawerUrlState`'s mount-time
    // read doesn't see a `?remit=REM-DRILL` from the previous
    // test's `open()` history push. Without this, a test that
    // asserts the drawer isn't open still finds the drawer
    // mounted on initial render (driven by history state, not by
    // a click).
    (window as unknown as { happyDOM: { setURL: (u: string) => void } })
      .happyDOM.setURL("http://localhost/reconciliation");
    // SP21 Phase 5 Task 5.5: the previous test may have left a Radix
    // portal in document.body. Clear them before each test so a
    // "drawer not in DOM" assertion isn't polluted by a stale portal.
    document.body
      .querySelectorAll('[role="dialog"]')
      .forEach((node) => node.remove());
  });

  afterEach(() => {
    // SP21 Phase 5 Task 5.5: the RemitDrawer portals into
    // document.body. Each test renders + unmounts, but happy-dom's
    // document.body persists across tests within the file, so we
    // explicitly remove any lingering Radix portals here. Without
    // this, a test that asserts `drawer not in DOM` would still
    // find a leftover portal from the previous test.
    document.body
      .querySelectorAll('[role="dialog"]')
      .forEach((node) => node.remove());
  });

  it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
    // Non-empty unmatched payload so the page renders the two-column
    // matching surface (the "Pair them." branch). The pre-existing
    // empty-state branch has a flaky happy-dom/race that's unrelated
    // to Phase 4 — using non-empty data sidesteps that bug and still
    // proves the deep-link → drawer mount works. We pre-set
    // `window.location` (which `useRemitDrawerUrlState` reads on
    // mount) so `?remit=REM-7` resolves to a truthy `remitId`.
    (
      api.listUnmatched as unknown as ReturnType<typeof vi.fn>
    ).mockResolvedValue({
      claims: [
        {
          id: "CLM-1",
          patientName: "Patient A",
          billedAmount: 100,
          providerNpi: "1234567890",
          serviceDate: "2026-06-01",
          payerId: "P1",
          state: "submitted",
        },
      ],
      remittances: [
        {
          id: "REM-7",
          payerClaimControlNumber: "PCN-A",
          status: "received",
          paidAmount: 100,
          adjustmentAmount: 0,
          receivedDate: "2026-06-01",
          isReversal: false,
          totalCharge: 100,
          serviceDate: "2026-06-01",
          batchId: "b1",
        },
      ],
    });
    (window as unknown as { happyDOM: { setURL: (u: string) => void } })
      .happyDOM.setURL("http://localhost/reconciliation?remit=REM-7");

    const { unmount } = renderIntoContainer(
      React.createElement(ReconciliationPage),
      { withRouter: true },
    );

    // Wait for the loaded two-column view (the "Pair them." headline
    // is the clearest signal that listUnmatched has resolved and the
    // page is past the loading + empty branches), then assert the
    // drawer is mounted.
    await waitForText("Pair them.");
    expect(
      document.body.querySelector('[data-testid="remit-drawer"]'),
    ).not.toBeNull();

    unmount();

    // Reset URL so the next test sees a clean /reconciliation URL.
    (window as unknown as { happyDOM: { setURL: (u: string) => void } })
      .happyDOM.setURL("http://localhost/reconciliation");
  });

  it("renders both columns with unmatched rows when api returns data", async () => {
    (
      api.listUnmatched as unknown as ReturnType<typeof vi.fn>
    ).mockResolvedValue({
      claims: [
        {
          id: "CLM-1",
          patientName: "Patient A",
          billedAmount: 100,
          providerNpi: "1234567890",
          serviceDate: "2026-06-01",
          payerId: "P1",
          state: "submitted",
        },
      ],
      remittances: [
        {
          id: "CLP-1:b1",
          payerClaimControlNumber: "PCN-A",
          status: "received",
          paidAmount: 100,
          adjustmentAmount: 0,
          receivedDate: "2026-06-01",
          isReversal: false,
          totalCharge: 100,
          serviceDate: "2026-06-01",
          batchId: "b1",
        },
      ],
    });

    const { unmount } = renderIntoContainer(
      React.createElement(ReconciliationPage),
      { withRouter: true },
    );
    await waitForText("CLM-1");

    // Both column headings + both rows should be present.
    expect(document.body.textContent).toContain("CLM-1");
    expect(document.body.textContent).toContain("PCN-A");
    expect(document.body.textContent).toContain("Unmatched claims");
    expect(document.body.textContent).toContain("Unmatched remits");
    unmount();
  });

  it("renders the empty state when nothing is unmatched", async () => {
    (
      api.listUnmatched as unknown as ReturnType<typeof vi.fn>
    ).mockResolvedValue({ claims: [], remittances: [] });

    const { unmount } = renderIntoContainer(
      React.createElement(ReconciliationPage),
      { withRouter: true },
    );
    await waitForText("nothing pending");

    expect(document.body.textContent).toContain("Reconciliation · nothing pending");
    expect(document.body.textContent).toContain("Every claim and remittance is paired.");
    // No two-column selection chrome should render.
    expect(document.body.textContent).not.toContain("Unmatched claims (");
    unmount();
  });

  it("SP21 Task 5.5: clicking the claim card body drills to /claims?claim=ID", async () => {
    // Task 5.5 splits the gesture: clicking the card body drills into
    // the ClaimDrawer via /claims?claim=ID, while a separate
    // "Select for match" button toggles the row's selection. The
    // card is no longer a single <button>; the body region is a
    // button with its own onClick that calls navigate().
    (
      api.listUnmatched as unknown as ReturnType<typeof vi.fn>
    ).mockResolvedValue({
      claims: [
        {
          id: "CLM-DRILL",
          patientName: "Patient Drill",
          billedAmount: 250,
          providerNpi: "1234567890",
          serviceDate: "2026-06-19",
          payerId: "P1",
          state: "submitted",
        },
      ],
      remittances: [],
    });

    const { unmount, tracker } = renderIntoContainer(
      React.createElement(ReconciliationPage),
      { withRouter: true, initialEntries: ["/reconciliation"] },
    );
    await waitForText("CLM-DRILL");

    // Click the claim card body (the body region is the inner <button>
    // that contains the id). It's not the "Select for match" toggle
    // — the body region has aria-label="View claim CLM-DRILL in detail".
    const bodyBtn = document.body.querySelector(
      'button[aria-label="View claim CLM-DRILL in detail"]',
    ) as HTMLButtonElement | null;
    expect(bodyBtn).not.toBeNull();
    await act(async () => {
      bodyBtn!.click();
      await Promise.resolve();
    });

    expect(tracker?.pathname).toBe("/claims");
    expect(tracker?.search).toBe("?claim=CLM-DRILL");
    unmount();
  });

  it("SP21 Task 5.5: clicking the 'Select for match' button toggles selection without drilling", async () => {
    // The select button calls e.stopPropagation() before setSelectedClaim,
    // so the row body click handler doesn't fire. Verify the URL stays
    // on /reconciliation and the "Match selected" button becomes enabled
    // (or at least that selection state advances).
    (
      api.listUnmatched as unknown as ReturnType<typeof vi.fn>
    ).mockResolvedValue({
      claims: [
        {
          id: "CLM-SEL",
          patientName: "Patient Select",
          billedAmount: 175,
          providerNpi: "1234567890",
          serviceDate: "2026-06-19",
          payerId: "P1",
          state: "submitted",
        },
      ],
      remittances: [
        {
          id: "REM-SEL",
          payerClaimControlNumber: "PCN-SEL",
          status: "received",
          paidAmount: 175,
          adjustmentAmount: 0,
          receivedDate: "2026-06-19",
          isReversal: false,
          totalCharge: 175,
          serviceDate: "2026-06-19",
          batchId: "b1",
        },
      ],
    });

    const { unmount, tracker } = renderIntoContainer(
      React.createElement(ReconciliationPage),
      { withRouter: true },
    );
    await waitForText("CLM-SEL");

    // Click the claim "Select for match" toggle. It has
    // data-testid="recon-claim-select-CLM-SEL" and should NOT drill.
    const selectBtn = document.body.querySelector(
      '[data-testid="recon-claim-select-CLM-SEL"]',
    ) as HTMLButtonElement | null;
    expect(selectBtn).not.toBeNull();
    expect(selectBtn!.textContent).toContain("Select for match");
    await act(async () => {
      selectBtn!.click();
      await Promise.resolve();
    });

    // URL stays on /reconciliation — selection didn't drill.
    expect(tracker?.pathname).toBe("/reconciliation");
    // The toggle button now reads "Unselect" (clicked once).
    const updatedBtn = document.body.querySelector(
      '[data-testid="recon-claim-select-CLM-SEL"]',
    ) as HTMLButtonElement | null;
    expect(updatedBtn?.textContent).toContain("Unselect");
    unmount();
  });

  it("SP21 Task 5.5: clicking the remit card body opens the RemitDrawer", async () => {
    // Task 5.5 also restructures the remits column — the body is no
    // longer role="button" with a select-onClick handler. The body
    // drills into the RemitDrawer via open(r.id); selection moves to
    // a dedicated "Select for match" toggle.
    (
      api.listUnmatched as unknown as ReturnType<typeof vi.fn>
    ).mockResolvedValue({
      claims: [],
      remittances: [
        {
          id: "REM-DRILL",
          payerClaimControlNumber: "PCN-DRILL",
          status: "received",
          paidAmount: 100,
          adjustmentAmount: 0,
          receivedDate: "2026-06-19",
          isReversal: false,
          totalCharge: 100,
          serviceDate: "2026-06-19",
          batchId: "b1",
        },
      ],
    });

    const { unmount } = renderIntoContainer(
      React.createElement(ReconciliationPage),
      { withRouter: true },
    );
    await waitForText("PCN-DRILL");

    // Click the remit card body (inner <button> with aria-label).
    const bodyBtn = document.body.querySelector(
      'button[aria-label="View remittance PCN-DRILL in detail"]',
    ) as HTMLButtonElement | null;
    expect(bodyBtn).not.toBeNull();
    await act(async () => {
      bodyBtn!.click();
      await Promise.resolve();
    });

    // RemitDrawer should be mounted in document.body.
    expect(
      document.body.querySelector('[data-testid="remit-drawer"]'),
    ).not.toBeNull();
    unmount();
  });

  it("SP21 Task 5.5: clicking the remit 'Select for match' button toggles selection without opening drawer", async () => {
    // The remit select toggle has e.stopPropagation() — clicking it
    // shouldn't open the RemitDrawer (which used to be a side effect
    // when the outer card was role="button" with onClick=select).
    (
      api.listUnmatched as unknown as ReturnType<typeof vi.fn>
    ).mockResolvedValue({
      claims: [],
      remittances: [
        {
          id: "REM-SEL",
          payerClaimControlNumber: "PCN-SEL2",
          status: "received",
          paidAmount: 100,
          adjustmentAmount: 0,
          receivedDate: "2026-06-19",
          isReversal: false,
          totalCharge: 100,
          serviceDate: "2026-06-19",
          batchId: "b1",
        },
      ],
    });

    const { unmount } = renderIntoContainer(
      React.createElement(ReconciliationPage),
      { withRouter: true },
    );
    await waitForText("PCN-SEL2");

    const selectBtn = document.body.querySelector(
      '[data-testid="recon-remit-select-REM-SEL"]',
    ) as HTMLButtonElement | null;
    expect(selectBtn).not.toBeNull();
    expect(selectBtn!.textContent).toContain("Select for match");
    await act(async () => {
      selectBtn!.click();
      await Promise.resolve();
    });

    // No drawer.
    expect(
      document.body.querySelector('[data-testid="remit-drawer"]'),
    ).toBeNull();
    // Toggle flipped.
    const updated = document.body.querySelector(
      '[data-testid="recon-remit-select-REM-SEL"]',
    ) as HTMLButtonElement | null;
    expect(updated?.textContent).toContain("Unselect");
    unmount();
  });
});
