import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";

import { PaginatedSearchSelect } from "./PaginatedSearchSelect";
import type { SearchSelectOption } from "./SearchSelect";
import { chooseSelectOption } from "../../../tests/test-utils";

const OPTIONS: SearchSelectOption[] = [
  { label: "alias-alpha", value: "alias-alpha" },
  { label: "alias-beta", value: "alias-beta" },
  { label: "gamma-key", value: "gamma-key" },
];

function renderSelect(overrides: Partial<React.ComponentProps<typeof PaginatedSearchSelect>> = {}) {
  const props: React.ComponentProps<typeof PaginatedSearchSelect> = {
    options: OPTIONS,
    onValueChange: vi.fn(),
    onSearchChange: vi.fn(),
    onLoadMore: vi.fn(),
    ...overrides,
  };
  render(<PaginatedSearchSelect {...props} />);
  return props;
}

function setListMetrics(list: HTMLElement, metrics: { scrollTop: number; clientHeight: number; scrollHeight: number }) {
  Object.defineProperty(list, "scrollTop", { value: metrics.scrollTop, configurable: true });
  Object.defineProperty(list, "clientHeight", { value: metrics.clientHeight, configurable: true });
  Object.defineProperty(list, "scrollHeight", { value: metrics.scrollHeight, configurable: true });
}

describe("PaginatedSearchSelect", () => {
  it("reports the typed query to the server instead of filtering locally", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange });

    const input = screen.getByRole("combobox");
    await user.click(input);
    fireEvent.change(input, { target: { value: "gamma" } });

    await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"));

    expect(await screen.findByText("alias-alpha")).toBeInTheDocument();
  });

  it("does not re-query the server when an item is selected", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();

    function Controlled() {
      const [value, setValue] = useState("");
      return (
        <PaginatedSearchSelect
          options={OPTIONS}
          value={value}
          onValueChange={setValue}
          onSearchChange={onSearchChange}
          onLoadMore={vi.fn()}
        />
      );
    }
    render(<Controlled />);

    await chooseSelectOption(user, screen.getByRole("combobox"), "alias-beta");

    expect(screen.getByRole("combobox")).toHaveValue("alias-beta");
    await new Promise((resolve) => setTimeout(resolve, 400));
    expect(onSearchChange).not.toHaveBeenCalled();
  });

  it("still reports a cleared input so the unfiltered page comes back", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange, value: "alias-alpha" });

    await user.click(document.querySelector('[data-slot="combobox-clear"]') as HTMLElement);

    await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith(""));
  });

  it("requests the next page once the list is scrolled near the bottom", async () => {
    const user = userEvent.setup();
    const onLoadMore = vi.fn();
    renderSelect({ onLoadMore, hasNextPage: true });

    await user.click(screen.getByRole("combobox"));
    const list = await screen.findByTestId("paginated-search-select-list");

    setListMetrics(list, { scrollTop: 0, clientHeight: 100, scrollHeight: 1000 });
    fireEvent.scroll(list);
    expect(onLoadMore).not.toHaveBeenCalled();

    setListMetrics(list, { scrollTop: 850, clientHeight: 100, scrollHeight: 1000 });
    fireEvent.scroll(list);
    expect(onLoadMore).toHaveBeenCalledTimes(1);
  });

  it("does not request more pages when there is no next page or one is already in flight", async () => {
    const user = userEvent.setup();
    const onLoadMore = vi.fn();
    const { unmount } = render(
      <PaginatedSearchSelect
        options={OPTIONS}
        onValueChange={vi.fn()}
        onSearchChange={vi.fn()}
        onLoadMore={onLoadMore}
        hasNextPage={false}
      />,
    );
    await user.click(screen.getByRole("combobox"));
    let list = await screen.findByTestId("paginated-search-select-list");
    setListMetrics(list, { scrollTop: 900, clientHeight: 100, scrollHeight: 1000 });
    fireEvent.scroll(list);
    expect(onLoadMore).not.toHaveBeenCalled();
    unmount();

    renderSelect({ onLoadMore, hasNextPage: true, isFetchingNextPage: true });
    await user.click(screen.getByRole("combobox"));
    list = await screen.findByTestId("paginated-search-select-list");
    setListMetrics(list, { scrollTop: 900, clientHeight: 100, scrollHeight: 1000 });
    fireEvent.scroll(list);
    expect(onLoadMore).not.toHaveBeenCalled();
  });

  it("keeps showing a selected value that is absent from the current page of options", () => {
    renderSelect({ options: [], value: "alias-from-an-earlier-page" });

    expect(screen.getByRole("combobox")).toHaveValue("alias-from-an-earlier-page");
  });

  it("reports the selected option's value", async () => {
    const user = userEvent.setup();
    const onValueChange = vi.fn();
    renderSelect({ onValueChange });

    await chooseSelectOption(user, screen.getByRole("combobox"), "alias-beta");

    expect(onValueChange).toHaveBeenCalledWith("alias-beta");
  });

  it("keeps the typed query when a refreshed page of options arrives while a value is selected", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();

    function ServerBacked() {
      const [search, setSearch] = useState("");
      const [value, setValue] = useState("alias-alpha");
      const freshlyBuiltOptions = OPTIONS.filter((option) => option.label.includes(search)).map((option) => ({
        ...option,
      }));
      return (
        <PaginatedSearchSelect
          options={freshlyBuiltOptions}
          value={value}
          onValueChange={setValue}
          onSearchChange={(query) => {
            onSearchChange(query);
            setSearch(query);
          }}
          onLoadMore={vi.fn()}
        />
      );
    }
    render(<ServerBacked />);

    const input = screen.getByRole("combobox");
    await user.click(input);
    await user.type(input, "gamma");

    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
    await waitFor(() => expect(input).toHaveValue("gamma"));
    expect(await screen.findByText("gamma-key")).toBeInTheDocument();
  });

  it("shows the selection again after the popup closes with the query abandoned", async () => {
    const user = userEvent.setup();
    renderSelect({ value: "alias-alpha" });

    const input = screen.getByRole("combobox");
    await user.click(input);
    expect(input).toHaveValue("");

    await user.type(input, "gamma");
    await user.keyboard("{Escape}");

    await waitFor(() => expect(input).toHaveValue("alias-alpha"));
  });

  it("puts the unfiltered page back when a typed query is abandoned", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange });

    const input = screen.getByRole("combobox");
    await user.click(input);
    await user.type(input, "gamma");
    await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"));

    await user.keyboard("{Escape}");

    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
  });

  it("puts the unfiltered page back once an option found by typing is picked", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange });

    const input = screen.getByRole("combobox");
    await user.click(input);
    await user.type(input, "gamma");
    await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma"));

    await user.click(await screen.findByText("gamma-key"));

    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith(""));
  });

  it("keeps the first character when typing is what opened the list", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange });

    await user.tab();
    await user.keyboard("gamma");

    expect(screen.getByRole("combobox")).toHaveValue("gamma");
    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
  });

  it("keeps showing a picked option's label after it drops out of the loaded page", async () => {
    const user = userEvent.setup();

    function Refetching() {
      const [options, setOptions] = useState<SearchSelectOption[]>([{ label: "Beta Team", value: "team-2" }]);
      const [value, setValue] = useState("");
      return (
        <>
          <PaginatedSearchSelect
            options={options}
            value={value}
            onValueChange={setValue}
            onSearchChange={vi.fn()}
            onLoadMore={vi.fn()}
          />
          <button type="button" onClick={() => setOptions([])}>
            refetch
          </button>
        </>
      );
    }
    render(<Refetching />);

    await chooseSelectOption(user, screen.getByRole("combobox"), "Beta Team");
    await user.click(screen.getByRole("button", { name: "refetch" }));

    expect(screen.getByRole("combobox")).toHaveValue("Beta Team");
  });

  it("starts a fresh query when typing lands after the selected label", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange, value: "alias-alpha" });

    const input = screen.getByRole("combobox") as HTMLInputElement;
    input.focus();
    input.setSelectionRange(input.value.length, input.value.length);
    await user.keyboard("gamma");

    expect(input).toHaveValue("gamma");
    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("gamma"));
  });

  it("commits the first server-filtered match on Enter when autoHighlight is always", async () => {
    const user = userEvent.setup();

    function ServerBacked() {
      const [search, setSearch] = useState("");
      const [value, setValue] = useState("");
      return (
        <PaginatedSearchSelect
          options={OPTIONS.filter((option) => option.label.includes(search))}
          value={value}
          onValueChange={setValue}
          onSearchChange={setSearch}
          onLoadMore={vi.fn()}
          autoHighlight="always"
        />
      );
    }
    render(<ServerBacked />);

    const input = screen.getByRole("combobox");
    await user.click(input);
    await user.type(input, "gamma");
    await waitFor(() => expect(screen.queryByText("alias-alpha")).not.toBeInTheDocument());
    await user.keyboard("{Enter}");

    await waitFor(() => expect(input).toHaveValue("gamma-key"));
  });

  it("highlights the picked label on focus so typing starts over", async () => {
    const user = userEvent.setup();
    renderSelect({ value: "alias-alpha" });

    await user.tab();

    const input = screen.getByRole("combobox") as HTMLInputElement;
    expect(input.selectionStart).toBe(0);
    expect(input.selectionEnd).toBe("alias-alpha".length);
  });

  it("takes a paste over the highlighted label wholesale even when it shares a prefix", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange, value: "alias-alpha" });

    await user.tab();
    await user.paste("alias-alphabet");

    expect(screen.getByRole("combobox")).toHaveValue("alias-alphabet");
    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alias-alphabet"));
  });

  it("starts a fresh query when typing lands inside the selected label", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange, value: "alias-alpha" });

    const input = screen.getByRole("combobox") as HTMLInputElement;
    input.focus();
    input.setSelectionRange(3, 3);
    await user.keyboard("g");

    expect(input).toHaveValue("g");
    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("g"));
  });

  it("surfaces loading and fetching-more affordances", async () => {
    const user = userEvent.setup();
    const { unmount } = render(
      <PaginatedSearchSelect
        options={[]}
        onValueChange={vi.fn()}
        onSearchChange={vi.fn()}
        onLoadMore={vi.fn()}
        isLoading
        loadingText="Loading key aliases…"
      />,
    );
    await user.click(screen.getByRole("combobox"));
    expect(await screen.findByText("Loading key aliases…")).toBeInTheDocument();
    unmount();

    renderSelect({ isFetchingNextPage: true });
    await user.click(screen.getByRole("combobox"));
    expect(await screen.findByTestId("paginated-search-select-loading-more")).toBeInTheDocument();
  });

  it("queries the trimmed label when a character is deleted from the end of the selection", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange, value: "alias-alpha" });

    const input = screen.getByRole("combobox") as HTMLInputElement;
    input.focus();
    input.setSelectionRange(input.value.length, input.value.length);
    await user.keyboard("{Backspace}");

    expect(input).toHaveValue("alias-alph");
    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("alias-alph"));
  });

  it("queries what is left when a character is deleted from inside the selection", async () => {
    const user = userEvent.setup();
    const onSearchChange = vi.fn();
    renderSelect({ onSearchChange, value: "alias-alpha" });

    const input = screen.getByRole("combobox") as HTMLInputElement;
    input.focus();
    input.setSelectionRange(6, 6);
    await user.keyboard("{Backspace}");

    expect(input).toHaveValue("aliasalpha");
    await waitFor(() => expect(onSearchChange).toHaveBeenLastCalledWith("aliasalpha"));
  });
});
