// @vitest-environment happy-dom
import "@testing-library/jest-dom/vitest";
import { render, screen, act } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { DiffViewer } from "./DiffViewer";
import type { FileDiff } from "./types";

// ── Module mocks ──────────────────────────────────────────────────────────────

vi.mock("./FileCard", () => ({
  FileCard: ({ file }: { file: FileDiff }) => (
    <div data-testid="file-card" data-path={file.newPath} />
  ),
}));

// Hoist the fetch mock so it can be referenced inside vi.mock factories
const { mockGetFileDiffFetch } = vi.hoisted(() => ({
  mockGetFileDiffFetch: vi.fn().mockResolvedValue({ rawDiff: "" }),
}));

vi.mock("@/lib/trpc", () => ({
  trpc: {
    useUtils: () => ({
      getFileDiff: { fetch: mockGetFileDiffFetch },
    }),
  },
}));

// DiffViewer calls useQueryClient() (even though the return value is unused).
// Provide a minimal mock so the hook doesn't throw outside a QueryClientProvider.
vi.mock("@tanstack/react-query", async (importOriginal) => {
  const actual =
    await importOriginal<typeof import("@tanstack/react-query")>();
  return { ...actual, useQueryClient: () => ({}) };
});

// ── IntersectionObserver mock ─────────────────────────────────────────────────

let observerCallback: IntersectionObserverCallback | null = null;
const observedElements = new Set<Element>();

// Class (not arrow function) so it can be used with `new IntersectionObserver(...)`
class MockIntersectionObserver {
  constructor(cb: IntersectionObserverCallback) {
    observerCallback = cb;
  }
  observe(el: Element) {
    observedElements.add(el);
  }
  unobserve(el: Element) {
    observedElements.delete(el);
  }
  disconnect() {
    observedElements.clear();
  }
}

beforeEach(() => {
  vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
  observedElements.clear();
  observerCallback = null;
  mockGetFileDiffFetch.mockClear();
  mockGetFileDiffFetch.mockResolvedValue({ rawDiff: "" });
});

afterEach(() => {
  vi.unstubAllGlobals();
});

// ── Helpers ───────────────────────────────────────────────────────────────────

/**
 * Fire the IntersectionObserver callback with a set of intersecting and
 * non-intersecting file paths. The target element is simulated by an object
 * whose dataset.filePath matches the DiffViewer's data-file-path attribute.
 */
function fireIntersection(
  intersectingPaths: string[],
  nonIntersectingPaths: string[] = [],
) {
  if (!observerCallback) return;
  const entries = [
    ...intersectingPaths.map((p) => ({
      isIntersecting: true,
      target: { dataset: { filePath: p } } as unknown as Element,
    })),
    ...nonIntersectingPaths.map((p) => ({
      isIntersecting: false,
      target: { dataset: { filePath: p } } as unknown as Element,
    })),
  ] as IntersectionObserverEntry[];
  act(() => {
    observerCallback!(entries, {} as IntersectionObserver);
  });
}

function makeFiles(count: number): FileDiff[] {
  return Array.from({ length: count }, (_, i) => ({
    oldPath: `file${i}.ts`,
    newPath: `file${i}.ts`,
    status: "modified" as const,
    additions: 1,
    deletions: 1,
  }));
}

const defaultProps = {
  phaseId: "phase-1",
  commitMode: false,
  commentsByLine: new Map(),
  onAddComment: vi.fn(),
  onResolveComment: vi.fn(),
  onUnresolveComment: vi.fn(),
};

// ── Tests ─────────────────────────────────────────────────────────────────────

describe("DiffViewer", () => {
  it("renders all FileCards when 5 files are all in viewport", () => {
    const files = makeFiles(5);
    render(<DiffViewer files={files} {...defaultProps} />);

    // Trigger all five as intersecting
    fireIntersection(files.map((f) => f.newPath));

    expect(screen.getAllByTestId("file-card")).toHaveLength(5);
  });

  it("shows only intersecting FileCards for 300 files, placeholders for the rest", () => {
    const files = makeFiles(300);
    render(<DiffViewer files={files} {...defaultProps} />);

    // Only first 5 files enter the viewport
    fireIntersection(files.slice(0, 5).map((f) => f.newPath));

    expect(screen.getAllByTestId("file-card")).toHaveLength(5);

    // The remaining 295 should be 48px placeholder divs marked aria-hidden
    const placeholders = document.querySelectorAll(
      '[aria-hidden][style*="height: 48px"]',
    );
    expect(placeholders.length).toBeGreaterThanOrEqual(295);
  });

  it("skips IntersectionObserver for single-file diff and renders FileCard directly", () => {
    render(<DiffViewer files={makeFiles(1)} {...defaultProps} />);

    // Single-file path: isVisible is always true, no intersection event needed
    expect(screen.getAllByTestId("file-card")).toHaveLength(1);
  });

  it("calls scrollIntoView on the wrapper div when onRegisterRef is used for sidebar navigation", () => {
    const files = makeFiles(5);
    const registeredRefs = new Map<string, HTMLDivElement>();
    const onRegisterRef = (filePath: string, el: HTMLDivElement | null) => {
      if (el) registeredRefs.set(filePath, el);
    };

    render(<DiffViewer files={files} {...defaultProps} onRegisterRef={onRegisterRef} />);

    // All wrapper divs should have been registered (including the last one)
    const targetFile = files[4].newPath;
    expect(registeredRefs.has(targetFile)).toBe(true);

    const wrapperEl = registeredRefs.get(targetFile)!;
    const scrollSpy = vi.fn();
    Object.defineProperty(wrapperEl, "scrollIntoView", { value: scrollSpy });

    // Simulate a sidebar click that calls scrollIntoView on the wrapper
    act(() => {
      wrapperEl.scrollIntoView({ behavior: "smooth", block: "start" });
    });
    expect(scrollSpy).toHaveBeenCalledOnce();
  });

  it("fires getFileDiff queries in batches of 10 when expandAll is toggled", async () => {
    const files = makeFiles(25); // 3 batches: 10, 10, 5
    const { rerender } = render(
      <DiffViewer files={files} {...defaultProps} expandAll={false} />,
    );

    rerender(<DiffViewer files={files} {...defaultProps} expandAll={true} />);

    // Wait for all async batch iterations to complete
    await act(async () => {
      await new Promise((r) => setTimeout(r, 100));
    });

    // All 25 non-binary files should have been prefetched
    expect(mockGetFileDiffFetch).toHaveBeenCalledTimes(25);
  });
});
