// @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.
// Mirrors the convention used by Batches.test.tsx and Acks.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 { MemoryRouter } from "react-router-dom";
import {
  describe,
  expect,
  it,
  vi,
  beforeEach,
  afterEach,
} from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BatchDiff } from "./BatchDiff";
import { api, ApiError } from "@/lib/api";
import type { BatchDiff as BatchDiffResponse } from "@/types";

// Mock the api module. Only the surface the page actually touches is
// stubbed; everything else is left undefined so an accidental call
// blows up loudly.
vi.mock("@/lib/api", () => ({
  api: {
    isConfigured: true,
    listBatches: vi.fn(),
    getBatchDiff: vi.fn(),
    getRemittance: vi.fn(),
  },
  ApiError: class ApiError extends Error {
    constructor(public status: number, message: string) {
      super(message);
    }
  },
}));

/**
 * Mount the page into a container wrapped in MemoryRouter +
 * QueryClientProvider. Uses a 10ms retryDelay so non-404 errors don't
 * blow past the test timeout — same pattern used by Batches.test.tsx.
 */
function renderIntoContainer(
  element: React.ReactElement,
  initialEntries: string[] = ["/batch-diff"],
): { container: HTMLDivElement; unmount: () => void } {
  const container = document.createElement("div");
  document.body.appendChild(container);
  const qc = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        retryDelay: 10,
      },
    },
  });
  const root: Root = createRoot(container);
  act(() => {
    root.render(
      <MemoryRouter initialEntries={initialEntries}>
        <QueryClientProvider client={qc}>{element}</QueryClientProvider>
      </MemoryRouter>,
    );
  });
  return {
    container,
    unmount: () => {
      act(() => root.unmount());
      container.remove();
    },
  };
}

/**
 * Flush microtasks + react state updates until the predicate holds or
 * the deadline expires. react-query's fetch resolves asynchronously
 * so we have to await a tick before the rendered DOM reflects the
 * mocked data.
 */
async function waitFor(
  predicate: () => boolean,
  description: string,
  timeoutMs = 2000,
): Promise<void> {
  const start = Date.now();
  while (!predicate()) {
    if (Date.now() - start > timeoutMs) {
      throw new Error(`waitFor: ${description} did not become true within ${timeoutMs}ms`);
    }
    await act(async () => {
      await Promise.resolve();
    });
  }
}

// Fixtures used by the data-driven tests.
const BATCH_A = {
  id: "B-A",
  kind: "837p" as const,
  inputFilename: "original.837",
  parsedAt: "2026-06-15T12:00:00Z",
  claimCount: 2,
};
const BATCH_B = {
  id: "B-B",
  kind: "837p" as const,
  inputFilename: "corrected.837",
  parsedAt: "2026-06-16T12:00:00Z",
  claimCount: 3,
};

function makeDiffPayload(): BatchDiffResponse {
  return {
    a: {
      id: BATCH_A.id,
      kind: "837p",
      parsedAt: BATCH_A.parsedAt,
      inputFilename: BATCH_A.inputFilename,
      claimCount: 2,
    },
    b: {
      id: BATCH_B.id,
      kind: "837p",
      parsedAt: BATCH_B.parsedAt,
      inputFilename: BATCH_B.inputFilename,
      claimCount: 3,
    },
    added: [
      {
        claim_id: "CLM-3",
        patient_name: "Sam Roe",
        total_charge: 200,
        service_date: "2026-06-02",
        status: "submitted",
        cpt_codes: ["99213"],
      },
    ],
    removed: [
      {
        claim_id: "CLM-2",
        patient_name: "Jane Doe",
        total_charge: 75,
        service_date: "2026-06-01",
        status: "submitted",
        cpt_codes: ["99213"],
      },
    ],
    changed: [
      {
        a: {
          claim_id: "CLM-1",
          patient_name: "Pat Lee",
          total_charge: 100,
          service_date: "2026-06-01",
          status: "submitted",
          cpt_codes: ["99213"],
        },
        b: {
          claim_id: "CLM-1",
          patient_name: "Pat Lee",
          total_charge: 150,
          service_date: "2026-06-01",
          status: "submitted",
          cpt_codes: ["99213"],
        },
        diff: [{ field: "total_charge", a: 100, b: 150 }],
      },
    ],
    summary: {
      addedCount: 1,
      removedCount: 1,
      changedCount: 1,
      unchangedCount: 1,
    },
  };
}

describe("BatchDiff page", () => {
  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, not the loaded data.
    (
      api.getRemittance as unknown as ReturnType<typeof vi.fn>
    ).mockReturnValue(new Promise(() => {}));
  });

  afterEach(() => {
    // happy-dom persists window.location between tests; reset so the
    // URL-state hook starts from a clean slate.
    (window as unknown as { happyDOM: { setURL: (u: string) => void } })
      .happyDOM.setURL("http://localhost/batch-diff");
  });

  it("SP21 Task 4.5: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
    // Pre-set the URL with `?remit=`. The page doesn't surface any
    // per-remit rows (the diff payload is claim-level), but the
    // drawer must still mount so the URL contract is honored.
    //
    // `useRemitDrawerUrlState` reads `window.location.search`
    // (NOT React Router's search params) so we have to set the
    // global window URL via happyDOM AND give MemoryRouter an
    // initial entry — both stay in lockstep.
    (window as unknown as { happyDOM: { setURL: (u: string) => void } })
      .happyDOM.setURL("http://localhost/batch-diff?remit=REM-7");
    (
      api.listBatches as unknown as ReturnType<typeof vi.fn>
    ).mockResolvedValue([BATCH_A, BATCH_B]);

    const { unmount } = renderIntoContainer(
      <BatchDiff />,
      ["/batch-diff?remit=REM-7"],
    );

    // Page header must be present + drawer must be open.
    await waitFor(
      () => !!document.querySelector('[data-testid="batch-diff-page"]'),
      "page header mounted",
    );
    await waitFor(
      () => !!document.querySelector('[data-testid="remit-drawer"]'),
      "remit drawer mounted via deep link",
    );
    expect(
      document.querySelector('[data-testid="remit-drawer"]'),
    ).not.toBeNull();

    unmount();
  });

  it("renders the awaiting-picks empty state when no batches are selected", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);

    const { unmount } = renderIntoContainer(<BatchDiff />);
    await waitFor(
      () => !!document.querySelector('[data-testid="batch-diff-page"]'),
      "page header mounted",
    );
    // The copy unique to the empty state — verifier the page rendered
    // the awaiting-picks branch and not the loading skeleton.
    expect(document.body.textContent).toContain("awaiting picks");
    expect(document.querySelector('[data-testid="diff-picker-a"]')).not.toBeNull();
    expect(document.querySelector('[data-testid="diff-picker-b"]')).not.toBeNull();
    // Diff not requested yet — getBatchDiff should NOT have been
    // called because the hook gates on both picks being non-null.
    expect(api.getBatchDiff).not.toHaveBeenCalled();
    unmount();
  });

  it("renders the loading skeleton while the diff is in flight", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    // Never resolves — keeps the query in the loading state.
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
      new Promise(() => {}),
    );

    // Start the page with both picks already in the URL.
    const { unmount } = renderIntoContainer(
      <BatchDiff />,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );
    await waitFor(
      () => !!document.querySelector('[data-testid="batch-diff-skeleton"]'),
      "loading skeleton visible",
    );
    unmount();
  });

  it("renders the three-section diff view when the backend returns deltas", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      makeDiffPayload(),
    );

    const { unmount } = renderIntoContainer(
      <BatchDiff />,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    // Wait for the diff view (full, non-empty payload).
    await waitFor(
      () => !!document.querySelector('[data-testid="batch-diff-view"]'),
      "diff view rendered",
    );

    // Sections are present.
    expect(
      document.querySelector('[data-testid="batch-diff-added-section"]'),
    ).not.toBeNull();
    expect(
      document.querySelector('[data-testid="batch-diff-removed-section"]'),
    ).not.toBeNull();
    expect(
      document.querySelector('[data-testid="batch-diff-changed-section"]'),
    ).not.toBeNull();

    // Per-claim rows render with the right indicator + id.
    expect(
      document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
    ).not.toBeNull();
    expect(
      document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
    ).not.toBeNull();
    expect(
      document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
    ).not.toBeNull();

    // Indicator chips are present for each bucket.
    expect(
      document.querySelector('[data-testid="diff-indicator-added"]'),
    ).not.toBeNull();
    expect(
      document.querySelector('[data-testid="diff-indicator-removed"]'),
    ).not.toBeNull();
    expect(
      document.querySelector('[data-testid="diff-indicator-changed"]'),
    ).not.toBeNull();

    unmount();
  });

  it("renders the no-deltas state when the backend reports an empty diff", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    const empty = makeDiffPayload();
    empty.added = [];
    empty.removed = [];
    empty.changed = [];
    empty.summary = {
      addedCount: 0,
      removedCount: 0,
      changedCount: 0,
      unchangedCount: 2,
    };
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      empty,
    );

    const { unmount } = renderIntoContainer(
      <BatchDiff />,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    await waitFor(
      () => !!document.querySelector('[data-testid="batch-diff-empty"]'),
      "empty-diff state visible",
    );
    // Summary cards still surface the counts even when zero.
    expect(
      document.querySelector('[data-testid="batch-diff-summary"]'),
    ).not.toBeNull();
    unmount();
  });

  it("renders the not-found state on a 404 from getBatchDiff", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    // Mirror the backend's 404 contract: ApiError(404).
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
      new ApiError(404, "Not found — batch B-A not found"),
    );

    const { unmount } = renderIntoContainer(
      <BatchDiff />,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    // The not-found state uses the ErrorState primitive with a retry
    // affordance; assert by message text since we don't ship a
    // dedicated testid for the variant.
    await waitFor(
      () => (document.body.textContent ?? "").includes("not found"),
      "not-found error text visible",
    );
    expect(
      (document.body.textContent ?? "").toLowerCase(),
    ).toContain("one of these batches was not found");
    unmount();
  });

  it("renders the network error state on a non-404 failure", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
      new Error("500 Internal Server Error — upstream"),
    );

    const { unmount } = renderIntoContainer(
      <BatchDiff />,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    await waitFor(
      () =>
        (document.body.textContent ?? "").includes(
          "Couldn't compute the diff",
        ),
      "network error text visible",
    );
    // Retry button is rendered (the ErrorState primitive's affordance).
    expect(
      (document.body.textContent ?? "").toLowerCase(),
    ).toContain("retry");
    unmount();
  });

  it("passes the URL a/b picks through to getBatchDiff", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      makeDiffPayload(),
    );

    const { unmount } = renderIntoContainer(
      <BatchDiff />,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    await waitFor(
      () => (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mock.calls.length > 0,
      "getBatchDiff invoked",
    );

    // The two picks are passed through in order: a, b.
    expect(api.getBatchDiff).toHaveBeenCalledWith(BATCH_A.id, BATCH_B.id);
    unmount();
  });

  it("Clear picks button resets both pickers and returns to awaiting-picks state", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      makeDiffPayload(),
    );

    const { unmount } = renderIntoContainer(
      <BatchDiff />,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    await waitFor(
      () => !!document.querySelector('[data-testid="diff-clear-button"]'),
      "clear button visible",
    );

    const clear = document.querySelector(
      '[data-testid="diff-clear-button"]',
    ) as HTMLButtonElement | null;
    await act(async () => {
      clear?.click();
      await Promise.resolve();
    });

    // Back to the awaiting-picks empty state. The hook's
    // `enabled: a && b` predicate stops getBatchDiff from being
    // re-invoked once both ids are null.
    await waitFor(
      () => (document.body.textContent ?? "").includes("awaiting picks"),
      "empty state re-appears",
    );
    // The picker for B should now be empty (the trigger renders the
    // placeholder text).
    expect(
      (document.body.textContent ?? "").toLowerCase(),
    ).toContain("pick a batch");
    unmount();
  });

  it("SP21 Task 5.6: clicking an added-claim id navigates to /claims?claim=ID", async () => {
    // Each ClaimIdCell wraps its id text with DrillableCell, whose
    // onClick navigates to /claims?claim=ID. The MemoryRouter gives
    // us a router context so the navigation completes; we observe
    // it via the rendered pathname on a LocationTracker spy.
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      makeDiffPayload(),
    );

    const captured: { pathname: string; search: string } = {
      pathname: "/batch-diff",
      search: "",
    };
    const Tracker = () => {
      const loc = useLocationSafe();
      React.useEffect(() => {
        captured.pathname = loc.pathname;
        captured.search = loc.search;
      }, [loc.pathname, loc.search]);
      return null;
    };

    const { unmount } = renderIntoContainer(
      <>
        <Tracker />
        <BatchDiff />
      </>,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    await waitFor(
      () => !!document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
      "added row rendered",
    );

    const cell = document.querySelector(
      '[data-testid="diff-added-row-CLM-3"] [data-testid="diff-claim-id"]',
    );
    expect(cell).not.toBeNull();
    // DrillableCell wraps the id text in its own <button>; click the
    // nearest button ancestor so the drillable onClick fires.
    const drillBtn =
      (cell?.closest("button") as HTMLButtonElement | null) ?? null;
    expect(drillBtn).not.toBeNull();
    await act(async () => {
      drillBtn!.click();
      await Promise.resolve();
    });

    await waitFor(
      () => captured.pathname === "/claims" && captured.search === "?claim=CLM-3",
      "navigation to /claims?claim=CLM-3",
    );
    unmount();
  });

  it("SP21 Task 5.6: clicking a removed-claim id still navigates (ClaimDrawer handles 404)", async () => {
    // The diff can list claim ids that no longer exist in the DB
    // (Removed from A means "was in A, not in B" — the claim may
    // still exist or may have been purged). Either way, clicking
    // the id drills to /claims?claim=ID; the ClaimDrawer's 404
    // state (Phase 2) handles the missing-claim case. This test
    // only verifies the click path, not the drawer's 404 surface.
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      makeDiffPayload(),
    );

    const captured: { pathname: string; search: string } = {
      pathname: "/batch-diff",
      search: "",
    };
    const Tracker = () => {
      const loc = useLocationSafe();
      React.useEffect(() => {
        captured.pathname = loc.pathname;
        captured.search = loc.search;
      }, [loc.pathname, loc.search]);
      return null;
    };

    const { unmount } = renderIntoContainer(
      <>
        <Tracker />
        <BatchDiff />
      </>,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    await waitFor(
      () => !!document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
      "removed row rendered",
    );

    const cell = document.querySelector(
      '[data-testid="diff-removed-row-CLM-2"] [data-testid="diff-claim-id"]',
    );
    const drillBtn =
      (cell?.closest("button") as HTMLButtonElement | null) ?? null;
    expect(drillBtn).not.toBeNull();
    await act(async () => {
      drillBtn!.click();
      await Promise.resolve();
    });

    await waitFor(
      () => captured.pathname === "/claims" && captured.search === "?claim=CLM-2",
      "navigation to /claims?claim=CLM-2",
    );
    unmount();
  });

  it("SP21 Task 5.6: clicking a changed-claim id navigates to /claims?claim=ID", async () => {
    // The Changed row uses the A-side id (per existing test
    // assertions). Drill should fire on that id.
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      BATCH_A, BATCH_B,
    ]);
    (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      makeDiffPayload(),
    );

    const captured: { pathname: string; search: string } = {
      pathname: "/batch-diff",
      search: "",
    };
    const Tracker = () => {
      const loc = useLocationSafe();
      React.useEffect(() => {
        captured.pathname = loc.pathname;
        captured.search = loc.search;
      }, [loc.pathname, loc.search]);
      return null;
    };

    const { unmount } = renderIntoContainer(
      <>
        <Tracker />
        <BatchDiff />
      </>,
      [`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
    );

    await waitFor(
      () => !!document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
      "changed row rendered",
    );

    const cell = document.querySelector(
      '[data-testid="diff-changed-row-CLM-1"] [data-testid="diff-claim-id"]',
    );
    const drillBtn =
      (cell?.closest("button") as HTMLButtonElement | null) ?? null;
    expect(drillBtn).not.toBeNull();
    await act(async () => {
      drillBtn!.click();
      await Promise.resolve();
    });

    await waitFor(
      () => captured.pathname === "/claims" && captured.search === "?claim=CLM-1",
      "navigation to /claims?claim=CLM-1",
    );
    unmount();
  });
});

// SP21 Phase 5 Task 5.6: react-router-dom's `useLocation` hook, used
// by the LocationTracker test helper below. Aliased to keep the import
// block tidy alongside React + the page import.
import { useLocation as useLocationSafe } from "react-router-dom";

// Keep `ApiError` referenced so the import isn't tree-shaken by
// vitest's transformer when the mock factory above is hoisted.
void ApiError;
