import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, act } from "~/test-utils.js";
import { InfiniteScrollSentinel } from "./InfiniteScrollSentinel.js";

// ---------------------------------------------------------------------------
// IntersectionObserver mock
// ---------------------------------------------------------------------------

type IntersectionCallback = (entries: IntersectionObserverEntry[]) => void;

let observerCallback: IntersectionCallback | null = null;
let observedElement: Element | null = null;
let disconnectMock: ReturnType<typeof vi.fn>;
let observeMock: ReturnType<typeof vi.fn>;

function simulateIntersection(isIntersecting: boolean) {
  observerCallback?.([{ isIntersecting } as IntersectionObserverEntry]);
}

beforeEach(() => {
  observerCallback = null;
  observedElement = null;
  disconnectMock = vi.fn();
  observeMock = vi.fn((el: Element) => {
    observedElement = el;
  });

  vi.stubGlobal(
    "IntersectionObserver",
    vi.fn((cb: IntersectionCallback, _options?: IntersectionObserverInit) => {
      observerCallback = cb;
      return {
        observe: observeMock,
        disconnect: disconnectMock,
        unobserve: vi.fn(),
        takeRecords: vi.fn(() => []),
        root: null,
        rootMargin: "",
        thresholds: [],
      } satisfies IntersectionObserver;
    }),
  );
});

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

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("InfiniteScrollSentinel", () => {
  // -------------------------------------------------------------------------
  // Rendering
  // -------------------------------------------------------------------------

  describe("rendering", () => {
    it("renders a sentinel div in the DOM", () => {
      const { container } = render(
        <InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
      );
      // The outer div is always present.
      expect(container.firstChild).toBeTruthy();
    });

    it("does NOT render the spinner when isLoading is false", () => {
      const { container } = render(
        <InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
      );
      // The spinner is a div with border classes applied; it must not exist.
      const spinner = container.querySelector(".animate-spin");
      expect(spinner).toBeNull();
    });

    it("renders the spinner when isLoading is true", () => {
      const { container } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
      const spinner = container.querySelector(".animate-spin");
      expect(spinner).not.toBeNull();
    });

    it("toggles the spinner when isLoading changes", () => {
      const { rerender, container } = render(
        <InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />,
      );
      expect(container.querySelector(".animate-spin")).toBeNull();

      rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
      expect(container.querySelector(".animate-spin")).not.toBeNull();

      rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
      expect(container.querySelector(".animate-spin")).toBeNull();
    });
  });

  // -------------------------------------------------------------------------
  // IntersectionObserver lifecycle
  // -------------------------------------------------------------------------

  describe("IntersectionObserver lifecycle", () => {
    it("creates an IntersectionObserver on mount", () => {
      render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
      expect(IntersectionObserver).toHaveBeenCalledOnce();
    });

    it("observes the sentinel element", () => {
      render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
      expect(observeMock).toHaveBeenCalledOnce();
      expect(observedElement).toBeInstanceOf(HTMLDivElement);
    });

    it("creates the observer with threshold: 0.1", () => {
      render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
      const ctorCall = vi.mocked(IntersectionObserver).mock.calls[0]!;
      expect(ctorCall[1]).toEqual({ threshold: 0.1 });
    });

    it("disconnects the observer on unmount", () => {
      const { unmount } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
      unmount();
      expect(disconnectMock).toHaveBeenCalledOnce();
    });

    it("recreates the observer when isLoading changes", () => {
      const { rerender } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
      expect(IntersectionObserver).toHaveBeenCalledTimes(1);

      rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
      // Effect cleanup + re-run: observer should be recreated.
      expect(IntersectionObserver).toHaveBeenCalledTimes(2);
    });

    it("disconnects the previous observer before creating a new one", () => {
      const { rerender } = render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);
      rerender(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading />);
      // disconnect is called at least once during the effect cleanup.
      expect(disconnectMock).toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // onVisible callback
  // -------------------------------------------------------------------------

  describe("onVisible callback", () => {
    it("calls onVisible when the sentinel intersects and isLoading is false", () => {
      const onVisible = vi.fn();
      render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);

      act(() => {
        simulateIntersection(true);
      });

      expect(onVisible).toHaveBeenCalledOnce();
    });

    it("does NOT call onVisible when the sentinel intersects but isLoading is true", () => {
      const onVisible = vi.fn();
      render(<InfiniteScrollSentinel onVisible={onVisible} isLoading />);

      act(() => {
        simulateIntersection(true);
      });

      expect(onVisible).not.toHaveBeenCalled();
    });

    it("does NOT call onVisible when isIntersecting is false", () => {
      const onVisible = vi.fn();
      render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);

      act(() => {
        simulateIntersection(false);
      });

      expect(onVisible).not.toHaveBeenCalled();
    });

    it("calls onVisible each time intersection fires with isLoading=false", () => {
      const onVisible = vi.fn();
      render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);

      act(() => {
        simulateIntersection(true);
      });
      act(() => {
        simulateIntersection(false);
      });
      act(() => {
        simulateIntersection(true);
      });

      expect(onVisible).toHaveBeenCalledTimes(2);
    });

    it("uses the latest onVisible reference after prop update", () => {
      const firstCallback = vi.fn();
      const secondCallback = vi.fn();

      const { rerender } = render(
        <InfiniteScrollSentinel onVisible={firstCallback} isLoading={false} />,
      );

      // Update the callback prop — this triggers effect re-run because onVisible is
      // listed as a dependency.
      rerender(<InfiniteScrollSentinel onVisible={secondCallback} isLoading={false} />);

      act(() => {
        simulateIntersection(true);
      });

      expect(secondCallback).toHaveBeenCalledOnce();
      expect(firstCallback).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // Edge cases
  // -------------------------------------------------------------------------

  describe("edge cases", () => {
    it("does not throw when the observer callback receives an empty entries array", () => {
      render(<InfiniteScrollSentinel onVisible={vi.fn()} isLoading={false} />);

      expect(() => {
        act(() => {
          observerCallback?.([]);
        });
      }).not.toThrow();
    });

    it("does not call onVisible when entry is undefined (empty array)", () => {
      const onVisible = vi.fn();
      render(<InfiniteScrollSentinel onVisible={onVisible} isLoading={false} />);

      act(() => {
        observerCallback?.([]);
      });

      expect(onVisible).not.toHaveBeenCalled();
    });
  });
});
