// @vitest-environment happy-dom
// React Query's state updates need an act-aware environment or it
// logs noisy warnings. Matches the convention from 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 { Batches } from "./Batches";
import { api, ApiError } from "@/lib/api";

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

// Stub react-router-dom's NavLink-aware hooks by wrapping in a memory
// router. The page itself doesn't use react-router state, but if any
// future refactor does this keeps the test future-proof.
function renderIntoContainer(
  element: React.ReactElement,
  initialEntries: string[] = ["/batches"]
): { container: HTMLDivElement; unmount: () => void } {
  const container = document.createElement("div");
  document.body.appendChild(container);
  const qc = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        // The Batches page's `useBatchDetail` hook overrides `retry`
        // with a function that retries up to 3 times for non-404
        // errors. The default retryDelay (~1s, 2s, 4s backoff) would
        // blow past the test timeout, so we pin retries to 10ms —
        // the retry behavior we want to assert is *that retries
        // happen* (i.e. the not_found branch's no-retry contract),
        // not the pacing.
        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();
    },
  };
}

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

/**
 * Poll for a DOM node matching a CSS selector. react-query's
 * `useQuery` returns to `idle` between microtasks; a single
 * `Promise.resolve()` isn't always enough to flush a queryFn that
 * synchronously rejects. This helper keeps yielding until either
 * the selector matches or the deadline expires.
 */
async function waitForSelector(
  selector: string,
  timeoutMs = 2000
): Promise<void> {
  const start = Date.now();
  while (!document.querySelector(selector)) {
    if (Date.now() - start > timeoutMs) {
      throw new Error(
        `waitForSelector: "${selector}" did not appear within ${timeoutMs}ms`
      );
    }
    await act(async () => {
      await Promise.resolve();
    });
  }
}

// 837P-shaped payload: `claims` rows have `claim_id`. The page uses
// the presence of `payer_claim_control_number` on the first row as
// the discriminator between 837P and 835; 837P rows lack it.
function make837(id: string) {
  return {
    envelope: {
      sender_id: "SENDER",
      receiver_id: "RECEIVER",
      control_number: id,
      transaction_date: "2026-06-15",
      transaction_time: "12:00",
    },
    summary: {
      input_file: "x.837",
      control_number: id,
      transaction_date: "2026-06-15",
      total_claims: 2,
      passed: 2,
      failed: 0,
      failed_claim_ids: [],
      issues_by_rule: {},
    },
    claims: [
      { claim_id: "CLM-1" },
      { claim_id: "CLM-2" },
    ],
  };
}

describe("Batches page", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

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

  it("renders the list skeleton when loading", async () => {
    // Never resolve — keeps the query in the loading state forever.
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
      new Promise(() => {})
    );

    const { unmount } = renderIntoContainer(<Batches />);

    // The skeleton primitive carries the data-testid the page sets
    // on the wrapping container; check the attribute instead of text.
    await act(async () => {
      await Promise.resolve();
    });
    expect(
      document.querySelector('[data-testid="batches-skeleton"]')
    ).not.toBeNull();

    unmount();
  });

  it("renders the empty state when the backend returns no batches", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);

    const { unmount } = renderIntoContainer(<Batches />);

    // Eyebrow copy unique to the empty state.
    await waitForText("awaiting first ingest");

    unmount();
  });

  it("renders rows with the correct kind badge colors", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      {
        id: "B-1",
        kind: "837p",
        inputFilename: "co_medicaid_837p.txt",
        parsedAt: "2026-06-15T12:00:00Z",
        claimCount: 5,
      },
      {
        id: "B-2",
        kind: "835",
        inputFilename: "co_medicaid_835.txt",
        parsedAt: "2026-06-15T13:00:00Z",
        claimCount: 7,
      },
    ]);

    const { unmount } = renderIntoContainer(<Batches />);

    // Wait for at least one row to appear.
    await waitForText("co_medicaid_837p.txt");

    // Both kinds render with their own data-testid.
    expect(
      document.querySelector('[data-testid="kind-badge-837p"]')
    ).not.toBeNull();
    expect(
      document.querySelector('[data-testid="kind-badge-835"]')
    ).not.toBeNull();

    // Class-name spot checks: 837p is sky-* (cool blue), 835 is
    // amber-* (warm). We match `text-sky-300` for 837p and
    // `text-amber-300` for 835 — the literal class strings baked
    // into `KindBadge`. This catches accidental swaps of the color
    // palettes between the two variants.
    const sky = document.querySelector('[data-testid="kind-badge-837p"]');
    const amber = document.querySelector('[data-testid="kind-badge-835"]');
    expect(sky?.className).toContain("text-sky-300");
    expect(amber?.className).toContain("text-amber-300");

    unmount();
  });

  it("clicking a row opens the detail drawer", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      {
        id: "B-9",
        kind: "837p",
        inputFilename: "demo.837",
        parsedAt: "2026-06-15T12:00:00Z",
        claimCount: 3,
      },
    ]);
    // Resolve the detail call so the drawer renders content rather
    // than a skeleton — we just need to confirm it opens.
    (api.getBatch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      make837("B-9")
    );

    const { unmount } = renderIntoContainer(<Batches />);

    await waitForText("demo.837");

    // Drawer should NOT be in the DOM before click.
    expect(
      document.querySelector('[data-testid="batch-detail-drawer"]')
    ).toBeNull();

    // Click the row.
    const row = document.querySelector(
      '[data-testid="batch-row-B-9"]'
    ) as HTMLElement | null;
    expect(row).not.toBeNull();
    await act(async () => {
      row?.click();
      // Let the query kick off.
      await Promise.resolve();
    });

    // Drawer now mounted and showing the detail content.
    expect(
      document.querySelector('[data-testid="batch-detail-drawer"]')
    ).not.toBeNull();
    // Once the mock resolves, the drawer renders content (not
    // skeleton, not error).
    await waitForText("Envelope");
    expect(
      document.querySelector('[data-testid="batch-detail-content"]')
    ).not.toBeNull();

    unmount();
  });

  it("Escape closes the drawer", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      {
        id: "B-9",
        kind: "837p",
        inputFilename: "demo.837",
        parsedAt: "2026-06-15T12:00:00Z",
        claimCount: 3,
      },
    ]);
    (api.getBatch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
      make837("B-9")
    );

    const { unmount } = renderIntoContainer(<Batches />);
    await waitForText("demo.837");

    // Open the drawer.
    const row = document.querySelector(
      '[data-testid="batch-row-B-9"]'
    ) as HTMLElement | null;
    await act(async () => {
      row?.click();
      await Promise.resolve();
    });
    await waitForText("Envelope");
    expect(
      document.querySelector('[data-testid="batch-detail-drawer"]')
    ).not.toBeNull();

    // Dispatch Escape from `document.body` — `useDrawerKeyboard`
    // ignores events whose target is an editable element, so
    // dispatching from `body` simulates the user pressing Esc while
    // the page (not an input) has focus.
    await act(async () => {
      const ev = new KeyboardEvent("keydown", {
        key: "Escape",
        bubbles: true,
      });
      document.body.dispatchEvent(ev);
      await Promise.resolve();
    });

    // Drawer closed — null result because the Dialog returns early.
    expect(
      document.querySelector('[data-testid="batch-detail-drawer"]')
    ).toBeNull();

    unmount();
  });

  it("renders the not-found error state on a 404 from getBatch", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      {
        id: "B-missing",
        kind: "837p",
        inputFilename: "ghost.837",
        parsedAt: "2026-06-15T12:00:00Z",
        claimCount: 0,
      },
    ]);

    // getBatch throws the same plain-Error shape `api.ts` produces:
    // `${status} ${statusText} — ${detail}`. The hook rewraps the 404
    // into ApiError(404) before it reaches the drawer's branching
    // helper, so we mirror that contract here — assert that the
    // drawer's error state is the not_found variant, not the
    // generic network variant.
    (api.getBatch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
      new Error("404 Not Found — batch not found")
    );

    const { unmount } = renderIntoContainer(<Batches />);
    await waitForText("ghost.837");

    const row = document.querySelector(
      '[data-testid="batch-row-B-missing"]'
    ) as HTMLElement | null;
    await act(async () => {
      row?.click();
    });
    await waitForSelector('[data-testid="batch-detail-error-not_found"]');

    // not_found branch: must be present, network branch must not.
    expect(
      document.querySelector(
        '[data-testid="batch-detail-error-not_found"]'
      )
    ).not.toBeNull();
    expect(
      document.querySelector(
        '[data-testid="batch-detail-error-network"]'
      )
    ).toBeNull();

    // The not_found variant has no retry affordance — a retry won't
    // bring back a batch that doesn't exist.
    expect(
      document.querySelector('[data-testid="batch-detail-error-retry"]')
    ).toBeNull();

    unmount();
  });

  it("renders the network error state on a non-404 failure", async () => {
    (api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
      {
        id: "B-bad",
        kind: "835",
        inputFilename: "broken.835",
        parsedAt: "2026-06-15T12:00:00Z",
        claimCount: 0,
      },
    ]);
    // Non-404 failure (server error). getBatch throws plain Error;
    // the hook only rewraps 404s, so this surfaces as a generic
    // "network" error with a retry button.
    (api.getBatch as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
      new Error("500 Internal Server Error — upstream")
    );

    const { unmount } = renderIntoContainer(<Batches />);
    await waitForText("broken.835");

    const row = document.querySelector(
      '[data-testid="batch-row-B-bad"]'
    ) as HTMLElement | null;
    await act(async () => {
      row?.click();
    });
    await waitForSelector('[data-testid="batch-detail-error-network"]');

    expect(
      document.querySelector('[data-testid="batch-detail-error-network"]')
    ).not.toBeNull();
    expect(
      document.querySelector('[data-testid="batch-detail-error-not_found"]')
    ).toBeNull();
    // Network variant offers retry.
    expect(
      document.querySelector('[data-testid="batch-detail-error-retry"]')
    ).not.toBeNull();

    unmount();
  });
});

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