// @vitest-environment happy-dom
(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 } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Acks } from "./Acks";
import { api } from "@/lib/api";

vi.mock("@/lib/api", () => ({
  api: {
    isConfigured: true,
    listAcks: vi.fn(),
    getAck: vi.fn(),
  },
}));

function renderIntoContainer(element: React.ReactElement): {
  container: HTMLDivElement;
  unmount: () => void;
} {
  const container = document.createElement("div");
  document.body.appendChild(container);
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  const root: Root = createRoot(container);
  act(() => {
    root.render(
      React.createElement(QueryClientProvider, { client: qc }, element),
    );
  });
  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 a predicate until it holds or we time out. react-query's fetch
 * resolves asynchronously, and Radix portals content into
 * `document.body`, so the DOM doesn't reflect new state synchronously
 * after a keypress — we have to await a tick before asserting.
 */
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));
    });
  }
}

/**
 * Dispatch a synthetic keydown on `window`. Matches the dispatching
 * pattern from `useDrawerKeyboard.test.ts` and `Claims.test.tsx` —
 * `bubbles: true` so the event propagates like a real keypress, and
 * `cancelable: true` so the hook's `preventDefault` has something to
 * act on.
 */
function pressKey(key: string): void {
  act(() => {
    window.dispatchEvent(
      new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
    );
  });
}

/** The row at `idx` (per `data-row-index={idx}` on the row). */
function rowAt(idx: number): HTMLTableRowElement | null {
  return document.querySelector(
    `tbody tr[data-row-index="${idx}"]`
  ) as HTMLTableRowElement | null;
}

/** True iff exactly one row carries `data-state="selected"`. */
function hasExactlyOneSelectedRow(): boolean {
  const selected = document.querySelectorAll(
    'tbody tr[data-state="selected"]'
  );
  return selected.length === 1;
}

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

  it("renders a single ack row with counts and ack code", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 42,
          sourceBatchId: "b-uuid-1",
          acceptedCount: 3,
          rejectedCount: 1,
          receivedCount: 4,
          ackCode: "P",
          parsedAt: "2026-06-20T12:00:00Z",
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("42");

    // The row's ack code badge must be visible.
    expect(document.body.textContent).toContain("P");
    // Counts must be visible (3 accepted, 1 rejected, 4 received).
    expect(document.body.textContent).toContain("3");
    expect(document.body.textContent).toContain("1");
    expect(document.body.textContent).toContain("4");
    // The source batch id must be visible.
    expect(document.body.textContent).toContain("b-uuid-1");
    unmount();
  });

  it("test_initial_render_shows_no_selection_highlight", async () => {
    // Three rows in the fixture so there's plenty to select; the test
    // asserts the *initial* state has no row marked selected.
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
        {
          id: 2,
          sourceBatchId: "b-2",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:01:00Z",
        },
        {
          id: 3,
          sourceBatchId: "b-3",
          acceptedCount: 3,
          rejectedCount: 0,
          receivedCount: 3,
          ackCode: "A",
          parsedAt: "2026-06-20T12:02:00Z",
        },
      ],
      total: 3,
      returned: 3,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-3");

    // None of the three rows should be marked selected — the spec
    // requires the initial render shows no selection highlight.
    expect(
      document.querySelectorAll('tbody tr[data-state="selected"]').length
    ).toBe(0);

    unmount();
  });

  it("test_pressing_j_increments_selection", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
        {
          id: 2,
          sourceBatchId: "b-2",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:01:00Z",
        },
        {
          id: 3,
          sourceBatchId: "b-3",
          acceptedCount: 3,
          rejectedCount: 0,
          receivedCount: 3,
          ackCode: "A",
          parsedAt: "2026-06-20T12:02:00Z",
        },
      ],
      total: 3,
      returned: 3,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-3");

    // From null, j lands on row 0.
    pressKey("j");
    await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
    expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
    expect(rowAt(1)?.getAttribute("data-state")).not.toBe("selected");
    expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");

    // j again → row 1.
    pressKey("j");
    await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
    expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
    expect(rowAt(0)?.getAttribute("data-state")).not.toBe("selected");

    // j again → row 2.
    pressKey("j");
    await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
    expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");

    // j from last row wraps to row 0 (per the wrap-or-clamp policy
    // documented in Acks.tsx).
    pressKey("j");
    await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
    expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");

    unmount();
  });

  it("test_pressing_k_decrements_selection_with_wrap", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
        {
          id: 2,
          sourceBatchId: "b-2",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:01:00Z",
        },
        {
          id: 3,
          sourceBatchId: "b-3",
          acceptedCount: 3,
          rejectedCount: 0,
          receivedCount: 3,
          ackCode: "A",
          parsedAt: "2026-06-20T12:02:00Z",
        },
      ],
      total: 3,
      returned: 3,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-3");

    // From null, k jumps to the last row — a one-keystroke "bottom
    // of list" gesture.
    pressKey("k");
    await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
    expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");

    // k again → row 1.
    pressKey("k");
    await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
    expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
    expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");

    // k twice more lands on row 0.
    pressKey("k");
    await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
    expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");

    // k from row 0 wraps to row 2 (last).
    pressKey("k");
    await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
    expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");

    unmount();
  });

  it("test_question_mark_toggles_cheatsheet_and_escape_closes", async () => {
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
      ],
      total: 1,
      returned: 1,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-1");

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

    // `?` opens it (Radix portals into document.body — query there).
    pressKey("?");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).not.toBeNull();

    // Escape closes it. The page wires onClose via useRowKeyboard
    // (Escape → onClose → setHelpOpen(false)).
    pressKey("Escape");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).toBeNull();

    // `?` again re-opens (proving the toggle works in both directions).
    pressKey("?");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).not.toBeNull();

    unmount();
  });

  it("test_j_does_not_navigate_while_cheatsheet_is_open", async () => {
    // `enabled: !helpOpen` is the recommended pairing with
    // useRowKeyboard: j/k nav yields to the cheatsheet while it's
    // open. The cheatsheet's own dismiss listener still closes it on
    // any non-`?` keypress (per KeyboardCheatsheet.tsx), so pressing
    // j will close the cheatsheet but the row should NOT have moved.
    (api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
      items: [
        {
          id: 1,
          sourceBatchId: "b-1",
          acceptedCount: 1,
          rejectedCount: 0,
          receivedCount: 1,
          ackCode: "A",
          parsedAt: "2026-06-20T12:00:00Z",
        },
        {
          id: 2,
          sourceBatchId: "b-2",
          acceptedCount: 2,
          rejectedCount: 0,
          receivedCount: 2,
          ackCode: "A",
          parsedAt: "2026-06-20T12:01:00Z",
        },
      ],
      total: 2,
      returned: 2,
      has_more: false,
    });

    const { unmount } = renderIntoContainer(React.createElement(Acks));
    await waitForText("b-2");

    // Move selection to row 0 first (so we can later verify it didn't
    // jump to row 1 while the cheatsheet is open).
    pressKey("j");
    await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");

    // Open cheatsheet.
    pressKey("?");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
        null
    );

    // Press j while cheatsheet is open. Cheatsheet closes (its own
    // listener handles any non-? key), but selection should NOT have
    // moved — row 0 is still selected.
    pressKey("j");
    await settle(
      () =>
        document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
        null
    );
    expect(
      document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
    ).toBeNull();
    // Row 0 is still the selected row.
    expect(hasExactlyOneSelectedRow()).toBe(true);
    expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");

    unmount();
  });
});