import {
  cleanup,
  render,
  screen,
  waitFor,
  act,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef } from "react";
import assert from "node:assert";
import {
  afterEach,
  describe,
  mock,
  test,
} from "node:test";
import { Select } from "./index";
import type { ISelectRef, TOptionType } from "./index";

const options: TOptionType[] = [
  { label: "Option A", value: "a" },
  { label: "Option B", value: "b" },
  { label: "Option C", value: "c" },
];

const selectors = {
  hiddenInput: "input[data-js-component-input='select']",
  clearIndicator: ".formSelect__indicator--isClear",
};

const waitCfg = { timeout: 1000 };

const getHiddenInputs = (container: HTMLElement): HTMLInputElement[] => {
  return Array.from(container.querySelectorAll(selectors.hiddenInput)) as HTMLInputElement[];
};

const getHiddenValues = (container: HTMLElement): string[] => {
  return getHiddenInputs(container).map((item) => item.value);
};

// Helper to add a timeout to a promise (safety net)
const withTimeout = <T,>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> => {
  return Promise.race([
    promise,
    new Promise<T>((resolve, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)),
  ]);
};

// Custom touch simulation that creates a TouchList-like object with .item() method
const touchTap = (el: HTMLElement): void => {
  const windowRef = el.ownerDocument.defaultView;
  assert.notStrictEqual(windowRef, null);

  const point = { clientX: 10, clientY: 10 };
  const touchList = {
    0: point,
    length: 1,
    item: (index: number) => (index === 0 ? point : null),
  };

  // @ts-expect-error (mock)
  const touchStartEvent = new (windowRef as Window).Event("touchstart", { bubbles: true, cancelable: true });
  Object.defineProperty(touchStartEvent, "touches", { value: touchList });
  Object.defineProperty(touchStartEvent, "changedTouches", { value: touchList });
  Object.defineProperty(touchStartEvent, "targetTouches", { value: touchList });

  // @ts-expect-error (mock)
  const touchEndEvent = new (windowRef as Window).Event("touchend", { bubbles: true, cancelable: true });
  Object.defineProperty(touchEndEvent, "changedTouches", { value: touchList });

  el.dispatchEvent(touchStartEvent);
  el.dispatchEvent(touchEndEvent);
};

describe("Select", () => {
  afterEach(() => {
    cleanup();
  });

  test("renders without crashing", () => {
    const { container } = render(<Select options={options} placeholder={"Choose"} />);
    assert.notStrictEqual(container.querySelector(".formSelect"), null);
  });

  test("updates selected value when controlled value prop changes", () => {
    const { rerender } = render(<Select options={options} value={"a"} />);
    assert.notStrictEqual(screen.queryByText("Option A"), null);

    rerender(<Select options={options} value={"b"} />);
    assert.notStrictEqual(screen.queryByText("Option B"), null);
  });

  test("does not reset uncontrolled value on parent rerender with same options content", async () => {
    const user = userEvent.setup();
    const { container, rerender } = render(
      <Select isSearchable={true} options={options} placeholder={"Choose"} />
    );

    await user.click(screen.getByRole("combobox"));
    await user.click(await screen.findByText("Option B"));

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("b"), true);
    }, waitCfg);

    const nextOptions = options.map((item) => ({ ...item }));
    rerender(<Select isSearchable={true} options={nextOptions} placeholder={"Choose again"} />);

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("b"), true);
    }, waitCfg);
  });

  test("opens menu by touch interaction", async () => {
    const { container } = render(
      <Select isSearchable={true} options={options} placeholder={"Choose"} />
    );

    const control = container.querySelector(".formSelect__control") as HTMLElement;
    assert.notStrictEqual(control, null);

    // Wrap touch simulation in act to ensure state updates are processed
    await act(async () => {
      touchTap(control);
    });

    // Wait for menu to appear with a timeout safety
    await withTimeout(
      screen.findByRole("listbox"), 2000, "Menu did not open after touch interaction (timeout)"
    );
  });

  test("does not close immediately after touch open", async () => {
    const { container } = render(
      <Select isSearchable={true} options={options} placeholder={"Choose"} />
    );

    const control = container.querySelector(".formSelect__control") as HTMLElement;
    assert.notStrictEqual(control, null);

    await act(async () => {
      touchTap(control);
    });

    await withTimeout(
      screen.findByRole("listbox"), 2000, "Menu did not open after touch (timeout)"
    );

    await act(async () => {
      await new Promise((resolve) => setTimeout(resolve, 350));
    });

    assert.notStrictEqual(screen.queryByRole("listbox"), null);
  });

  test("does not close immediately on blur right after touch open", async () => {
    const { container } = render(
      <Select isSearchable={true} options={options} placeholder={"Choose"} />
    );

    const control = container.querySelector(".formSelect__control") as HTMLElement;
    assert.notStrictEqual(control, null);

    await act(async () => {
      touchTap(control);
    });

    await withTimeout(
      screen.findByRole("listbox"), 2000, "Menu did not open after touch (timeout)"
    );

    const input = screen.getByRole("combobox") as HTMLInputElement;
    input.dispatchEvent(new FocusEvent("blur", { bubbles: true }));

    await act(async () => {
      await new Promise((resolve) => setTimeout(resolve, 250));
    });

    assert.notStrictEqual(screen.queryByRole("listbox"), null);
  });

  test("does not close immediately after open for searchable async select", async () => {
    const user = userEvent.setup();
    render(
      <Select
        isSearchable={true}
        isAsync={true}
        onOptionsSearchRequest={{
          requestFn: async () => [],
        }}
      />
    );

    await user.click(screen.getByRole("combobox"));
    await screen.findByRole("listbox");

    await act(async () => {
      await new Promise((resolve) => setTimeout(resolve, 350));
    });

    assert.notStrictEqual(screen.queryByRole("listbox"), null);
  });

  test("loads async options through the debounced callback", async () => {
    const user = userEvent.setup();
    const requestFn = mock.fn(async () => [
      { label: "Async option", value: "async" },
    ]);

    render(
      <Select
        isSearchable={true}
        isAsync={true}
        loadOptionsDebounce={0}
        onOptionsSearchRequest={{ requestFn }}
      />
    );

    await user.type(screen.getByRole("combobox"), "a");

    assert.notStrictEqual(await screen.findByText("Async option"), null);
    assert.strictEqual(requestFn.mock.callCount(), 1);
  });

  test("calls onChange when user selects option", async () => {
    const user = userEvent.setup();
    const onChange = mock.fn();

    render(<Select isSearchable={true} options={options} onChange={onChange} />);

    await user.click(screen.getByRole("combobox"));
    await user.click(await screen.findByText("Option B"));

    assert.strictEqual(onChange.mock.calls.length, 1);
    const args = onChange.mock.calls[0]?.arguments?.[0];
    assert.strictEqual(args.data.value?.value, "b");
  });

  test("sorts options via getOptionsModify on close without duplicates", async () => {
    const user = userEvent.setup();
    let closeResultValues: string[] | null = null;
    render(
      <Select
        isSearchable={true}
        isMulti={true}
        value={[ "b" ]}
        options={options}
        getOptionsModify={({ options: currentOptions, value, type }) => {
          if (type === "close") {
            const [ selectedValue ] = (value || []) as TOptionType[];
            if (selectedValue) {
              currentOptions.unshift(selectedValue);
            }
            const sorted = Array.from(new Set(currentOptions));
            closeResultValues = sorted.map((item) => item?.value || "");
            return sorted;
          }
          return currentOptions;
        }}
      />
    );

    await user.click(screen.getByRole("combobox"));
    await user.click(document.body);

    await waitFor(() => {
      assert.strictEqual(screen.queryByRole("listbox"), null);
      assert.notStrictEqual(closeResultValues, null);
    }, waitCfg);

    await act(async () => {
      await new Promise((resolve) => setTimeout(resolve, 350));
    });

    await user.click(screen.getByRole("combobox"));
    await screen.findByRole("listbox");

    const renderedOptions = screen.getAllByRole("option");
    const renderedLabels = renderedOptions.map((item) => item.textContent?.trim() || "");

    assert.deepStrictEqual(closeResultValues, [ "b", "a", "c" ]);
    assert.strictEqual(renderedLabels.length, 3);
    assert.deepStrictEqual(new Set(renderedLabels).size, renderedLabels.length);
  });

  test("keeps selected value for isMulti with getOptionsModify close-sort", async () => {
    const user = userEvent.setup();
    const customOptions: TOptionType[] = [
      { label: "Option 1", value: "option1" },
      { label: "Option 2", value: "option2" },
      { label: "Option 3", value: "option3" },
    ];
    const { container } = render(
      <Select
        isMulti={true}
        isSearchable={true}
        placeholder={"Choose option..."}
        name={"select[]"}
        options={customOptions}
        getOptionsModify={({ options: currentOptions, value, type }) => {
          if (type !== "close") {
            return currentOptions;
          }

          const selected = Array.isArray(value) ? value[0] : value;
          if (!selected?.value) {
            return currentOptions;
          }

          const rest = currentOptions.filter((item) => item?.value !== selected.value);
          return [ selected, ...rest ];
        }}
      />
    );

    await user.click(screen.getByRole("combobox"));
    await user.click(await screen.findByText("Option 2"));

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("option2"), true);
    }, waitCfg);

    await user.click(document.body);

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("option2"), true);
    }, waitCfg);
  });

  test("passes latest selected item as first in value for getOptionsModify close", async () => {
    const user = userEvent.setup();
    let closeResultValues: string[] | null = null;
    const customOptions: TOptionType[] = [
      { label: "Option 1", value: "option1" },
      { label: "Option 2", value: "option2" },
      { label: "Option 3", value: "option3" },
    ];
    const { container } = render(
      <Select
        isMulti={true}
        isSearchable={true}
        options={customOptions}
        getOptionsModify={({ options: currentOptions, value, type }) => {
          if (type !== "close") {
            return currentOptions;
          }

          const selected = Array.isArray(value) ? value[0] : value;
          if (!selected?.value) {
            return currentOptions;
          }

          const rest = currentOptions.filter((item) => item?.value !== selected.value);
          const sorted = [ selected, ...rest ];
          closeResultValues = sorted.map((item) => item?.value || "");
          return sorted;
        }}
      />
    );

    await user.click(screen.getByRole("combobox"));
    await user.click(await screen.findByText("Option 1"));
    await user.click(await screen.findByText("Option 2"));
    await user.click(document.body);

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("option1"), true);
      assert.strictEqual(values.includes("option2"), true);
      assert.notStrictEqual(closeResultValues, null);
      assert.strictEqual(closeResultValues?.[0], "option2");
    }, waitCfg);
  });

  test("clears selected value by clear indicator when isClearable=true", async () => {
    const user = userEvent.setup();
    const { container } = render(
      <Select isSearchable={true} isClearable={true} options={options} />
    );

    await user.click(screen.getByRole("combobox"));
    await user.click(await screen.findByText("Option B"));

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("b"), true);
    }, waitCfg);

    const clearIndicator = container.querySelector(selectors.clearIndicator) as HTMLElement;
    assert.notStrictEqual(clearIndicator, null);
    await user.click(clearIndicator);

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes(""), true);
    }, waitCfg);
  });

  test("clears selected value by clear indicator when isClearable=true and onChange is provided", async () => {
    const user = userEvent.setup();
    const onChange = mock.fn();
    const { container } = render(
      <Select
        isSearchable={true}
        isClearable={true}
        options={options}
        onChange={onChange}
      />
    );

    await user.click(screen.getByRole("combobox"));
    await user.click(await screen.findByText("Option B"));

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("b"), true);
    }, waitCfg);

    const clearIndicator = container.querySelector(selectors.clearIndicator) as HTMLElement;
    assert.notStrictEqual(clearIndicator, null);
    await user.click(clearIndicator);

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes(""), true);
    }, waitCfg);

    assert.strictEqual(onChange.mock.calls.length > 0, true);
  });

  test("does not restore previous value after clear when value prop is static", async () => {
    const user = userEvent.setup();
    const onChange = mock.fn();
    const { container } = render(
      <Select
        isSearchable={true}
        isClearable={true}
        options={options}
        value={"b"}
        onChange={onChange}
      />
    );

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("b"), true);
    }, waitCfg);

    const clearIndicator = container.querySelector(selectors.clearIndicator) as HTMLElement;
    assert.notStrictEqual(clearIndicator, null);
    await user.click(clearIndicator);

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes(""), true);
    }, waitCfg);

    await user.tab();

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes(""), true);
    }, waitCfg);

    assert.strictEqual(onChange.mock.calls.length > 0, true);
  });

  test("keeps menu open after option select when isMulti=true", async () => {
    const user = userEvent.setup();
    const { container } = render(
      <Select isSearchable={true} isMulti={true} options={options} />
    );

    await user.click(screen.getByRole("combobox"));
    await user.click(await screen.findByText("Option B"));

    await waitFor(() => {
      assert.notStrictEqual(screen.queryByRole("listbox"), null);
    }, waitCfg);

    await user.click(await screen.findByText("Option C"));

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("b"), true);
      assert.strictEqual(values.includes("c"), true);
      assert.notStrictEqual(screen.queryByRole("listbox"), null);
    }, waitCfg);
  });

  test("closes menu by outside click when isMulti=true and value already selected", async () => {
    const user = userEvent.setup();
    const onMenuToggle = mock.fn();
    const { container } = render(
      <Select
        isSearchable={true}
        isMulti={true}
        options={options}
        value={[ "b" ]}
        onMenuToggle={onMenuToggle}
      />
    );

    await waitFor(() => {
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("b"), true);
    }, waitCfg);

    await user.click(screen.getByRole("combobox"));

    await waitFor(() => {
      assert.strictEqual(
        onMenuToggle.mock.calls.some((call) => call.arguments?.[0] === "open"), true
      );
      assert.notStrictEqual(screen.queryByRole("listbox"), null);
    }, waitCfg);

    await user.click(document.body);

    await waitFor(() => {
      assert.strictEqual(
        onMenuToggle.mock.calls.some((call) => call.arguments?.[0] === "close"), true
      );
      const values = getHiddenValues(container);
      assert.strictEqual(values.includes("b"), true);
    }, waitCfg);
  });

  test("closes when isMulti+isCheckboxView+isOpen and clicked outside", async () => {
    const user = userEvent.setup();
    const onMenuToggle = mock.fn();

    render(
      <Select
        isMulti={true}
        isCheckboxView={true}
        isOpen={true}
        isSearchable={true}
        options={options}
        value={[ "b" ]}
        onMenuToggle={onMenuToggle}
      />
    );

    await waitFor(() => {
      assert.notStrictEqual(screen.queryByRole("listbox"), null);
      assert.strictEqual(
        onMenuToggle.mock.calls.some((call) => call.arguments?.[0] === "open"), true
      );
    }, waitCfg);

    await user.click(document.body);

    await waitFor(() => {
      assert.strictEqual(screen.queryByRole("listbox"), null);
      assert.strictEqual(
        onMenuToggle.mock.calls.some((call) => call.arguments?.[0] === "close"), true
      );
    }, waitCfg);
  });

  test("handles backspace according to backspaceRemovesValue prop", async () => {
    const user = userEvent.setup();
    const onChangeFalse = mock.fn();
    const { rerender } = render(
      <Select
        isSearchable={true}
        options={options}
        value={"a"}
        backspaceRemovesValue={false}
        onChange={onChangeFalse}
      />
    );

    await user.click(screen.getByRole("combobox"));
    await user.keyboard("{Backspace}");
    assert.strictEqual(onChangeFalse.mock.calls.length, 0);

    const onChangeTrue = mock.fn();
    rerender(
      <Select
        isSearchable={true}
        options={options}
        value={"a"}
        backspaceRemovesValue={true}
        onChange={onChangeTrue}
      />
    );

    await user.click(screen.getByRole("combobox"));
    await user.keyboard("{Backspace}");
    assert.strictEqual(onChangeTrue.mock.calls.length > 0, true);
  });

  test("calls onMenuToggle('open') for initially opened select", async () => {
    const onMenuToggle = mock.fn();
    render(<Select options={options} isOpen={true} onMenuToggle={onMenuToggle} />);

    await waitFor(() => {
      assert.strictEqual(onMenuToggle.mock.calls.length > 0, true);
    }, waitCfg);

    const firstArg = onMenuToggle.mock.calls[0]?.arguments?.[0];
    assert.strictEqual(firstArg, "open");
  });

  test("updates hidden input attrs when disabled/required props change", async () => {
    const ref = createRef<ISelectRef>();
    const { container, rerender } = render(
      <Select ref={ref} options={options} isDisabled={false} isRequired={false} />
    );

    const hiddenInput = container.querySelector(selectors.hiddenInput) as HTMLInputElement;
    assert.notStrictEqual(hiddenInput, null);
    assert.strictEqual(hiddenInput.disabled, false);
    assert.strictEqual(hiddenInput.getAttribute("data-js-input-required"), null);

    rerender(<Select ref={ref} options={options} isDisabled={true} isRequired={true} />);

    await waitFor(() => {
      const updatedInput = container.querySelector(selectors.hiddenInput) as HTMLInputElement;
      assert.notStrictEqual(updatedInput, null);
      assert.strictEqual(updatedInput.disabled, true);
      assert.strictEqual(updatedInput.getAttribute("data-js-input-required"), "select");
    }, waitCfg);
  });

  test("updates state modifier classes when baseClass changes", async () => {
    const { container, rerender } = render(
      <Select
        baseClass={"formSelectA"}
        options={options}
        isDisabled={true}
        isRequired={true}
      />
    );

    await waitFor(() => {
      const root = container.querySelector(".formSelectA") as HTMLElement;
      assert.notStrictEqual(root, null);
      assert.strictEqual(root.classList.contains("formSelectA--isRequired"), true);
      assert.strictEqual(root.classList.contains("formSelectA--isDisabled"), true);
    }, waitCfg);

    rerender(
      <Select
        baseClass={"formSelectB"}
        options={options}
        isDisabled={true}
        isRequired={true}
      />
    );

    await waitFor(() => {
      const root = container.querySelector(".formSelectB") as HTMLElement;
      assert.notStrictEqual(root, null);
      assert.strictEqual(root.classList.contains("formSelectB--isRequired"), true);
      assert.strictEqual(root.classList.contains("formSelectB--isDisabled"), true);
      assert.strictEqual(root.classList.contains("formSelectA--isRequired"), false);
      assert.strictEqual(root.classList.contains("formSelectA--isDisabled"), false);
    }, waitCfg);
  });
});
