import { useStoreState } from "@ariakit/react-store";
import {
  useAttribute,
  useBooleanEvent,
  useEvent,
  useMergeRefs,
  useWrapElement,
  createElement,
  createHook,
  forwardRef,
} from "@ariakit/react-utils";
import type { Props } from "@ariakit/react-utils";
import {
  toArray,
  disabledFromProps,
  getPopupRole,
  queueBeforeEvent,
  invariant,
} from "@ariakit/utils";
import type { BooleanOrCallback } from "@ariakit/utils";
import type {
  ElementType,
  KeyboardEvent,
  MouseEvent,
  SelectHTMLAttributes,
} from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { withDefaultButtonType } from "../button/utils.ts";
import type { CompositeTypeaheadOptions } from "../composite/composite-typeahead.tsx";
import { useCompositeTypeahead } from "../composite/composite-typeahead.tsx";
import { getBasePlacement } from "../popover/__utils.ts";
import type { PopoverDisclosureOptions } from "../popover/popover-disclosure.tsx";
import { usePopoverDisclosure } from "../popover/popover-disclosure.tsx";
import { getVisuallyHiddenStyle } from "../visually-hidden/visually-hidden.tsx";
import { SelectArrow } from "./select-arrow.tsx";
import {
  SelectScopedContextProvider,
  useSelectProviderContext,
} from "./select-context.tsx";
import type { SelectStore } from "./select-store.ts";

const TagName = "button" satisfies ElementType;
type TagName = typeof TagName;
type HTMLType = HTMLElementTagNameMap[TagName];

function getSelectedValues(select: HTMLSelectElement) {
  return Array.from(select.selectedOptions).map((option) => option.value);
}

// When moving through the items when the select list is closed, we don't want
// to move to items without value, so we filter them out here.
function nextWithValue(store: SelectStore, next: SelectStore["next"]) {
  return () => {
    const visitedIds = new Set<string>();
    let nextId = next();
    while (nextId) {
      const nextItem = store.item(nextId);
      if (!nextItem) return;
      if (nextItem.value != null) {
        return nextItem.id;
      }
      // Walking from the last returned id, as if the key was pressed again
      // from there, skips items without value even across focusLoop
      // boundaries. A repeated id means the walk cycled through every
      // reachable item without finding one with value, so we return undefined
      // to keep move() from changing the active item.
      if (visitedIds.has(nextId)) return;
      visitedIds.add(nextId);
      nextId = next({ activeId: nextId });
    }
    return;
  };
}

/**
 * Returns props to create a `Select` component.
 * @deprecated Use `useComboboxSelect` instead.
 * @see https://ariakit.com/components/select
 * @example
 * ```jsx
 * const store = useSelectStore();
 * const props = useSelect({ store });
 * <Role {...props} />
 * ```
 */
export const useSelect = createHook<TagName, SelectOptions>(function useSelect({
  store,
  name,
  form,
  required,
  showOnKeyDown = true,
  moveOnKeyDown = true,
  toggleOnPress = true,
  toggleOnClick = toggleOnPress,
  ...props
}) {
  const context = useSelectProviderContext();
  store = store || context;

  invariant(
    store,
    process.env.NODE_ENV !== "production" &&
      "Select must receive a `store` prop or be wrapped in a SelectProvider component.",
  );

  const onKeyDownProp = props.onKeyDown;
  const disabledProp = disabledFromProps(props);
  const showOnKeyDownProp = useBooleanEvent(showOnKeyDown);
  const moveOnKeyDownProp = useBooleanEvent(moveOnKeyDown);
  const placement = useStoreState(store, "placement");
  const dir = getBasePlacement(placement);
  const value = useStoreState(store, "value");
  const multiSelectable = Array.isArray(value);

  const onKeyDown = useEvent((event: KeyboardEvent<HTMLType>) => {
    onKeyDownProp?.(event);
    if (event.defaultPrevented) return;
    if (!store) return;
    const { orientation, items, activeId } = store.getState();
    // moveOnKeyDown
    const isVertical = orientation !== "horizontal";
    const isHorizontal = orientation !== "vertical";
    const isGrid = !!items.find((item) => !item.disabled && item.value != null)
      ?.rowId;
    const moveKeyMap = {
      ArrowUp: (isGrid || isVertical) && nextWithValue(store, store.up),
      ArrowRight: (isGrid || isHorizontal) && nextWithValue(store, store.next),
      ArrowDown: (isGrid || isVertical) && nextWithValue(store, store.down),
      ArrowLeft:
        (isGrid || isHorizontal) && nextWithValue(store, store.previous),
    };
    const getId = moveKeyMap[event.key as keyof typeof moveKeyMap];
    if (getId && moveOnKeyDownProp(event)) {
      event.preventDefault();
      store.move(getId());
    }
    // showOnKeyDown
    const isTopOrBottom = dir === "top" || dir === "bottom";
    const isLeft = dir === "left";
    const isRight = dir === "right";
    const canShowKeyMap = {
      ArrowDown: isTopOrBottom,
      ArrowUp: isTopOrBottom,
      ArrowLeft: isLeft,
      ArrowRight: isRight,
    };
    const canShow = canShowKeyMap[event.key as keyof typeof canShowKeyMap];
    if (canShow && showOnKeyDownProp(event)) {
      event.preventDefault();
      store.move(activeId);
      // Schedule the show event to run after the key event has finished
      // bubbling. This is necessary to avoid the page to scroll when the
      // popover is shown.
      queueBeforeEvent(event.currentTarget, "keyup", store.show);
    }
  });

  props = useWrapElement(
    props,
    (element) => (
      <SelectScopedContextProvider value={store}>
        {element}
      </SelectScopedContextProvider>
    ),
    [store],
  );

  const [autofill, setAutofill] = useState(false);
  const nativeSelectChangedRef = useRef(false);

  // Resets the autofilled state when the select value changes, but only if
  // the change wasn't triggered by the native select element (which is an
  // autofill).
  useEffect(() => {
    const nativeSelectChanged = nativeSelectChangedRef.current;
    nativeSelectChangedRef.current = false;
    if (nativeSelectChanged) return;
    setAutofill(false);
  }, [value]);

  const labelElement = useStoreState(store, "labelElement");
  useAttribute(labelElement, "id");
  const labelId = labelElement?.id;
  const label = props["aria-label"];
  const labelledBy = props["aria-labelledby"] || labelId;
  const items = useStoreState(store, ["items"], (state) => {
    if (!name) return;
    return state.items;
  });
  const values = useMemo(() => {
    // Filter out items without value and duplicate values.
    const itemValues = items?.flatMap((item) => item.value ?? []);
    return [...new Set(itemValues)];
  }, [items]);

  // Renders a native select element with the same value as the select so we
  // support browser autofill. When the native select value changes, the
  // onChange event is triggered and we set the autofill state to true.
  props = useWrapElement(
    props,
    (element) => {
      if (!name) return element;
      return (
        <>
          <select
            style={getVisuallyHiddenStyle()}
            tabIndex={-1}
            aria-hidden
            aria-label={label}
            aria-labelledby={label != null ? undefined : labelledBy}
            name={name}
            form={form}
            required={required}
            disabled={disabledProp}
            value={value}
            multiple={multiSelectable}
            // Even though this element is visually hidden and is not
            // tabbable, it's still focusable. Some autofill extensions like
            // 1password will move focus to the next form element on autofill.
            // In this case, we want to move focus to our custom select
            // element.
            onFocus={() => store?.getState().selectElement?.focus()}
            onChange={(event) => {
              nativeSelectChangedRef.current = true;
              setAutofill(true);
              store?.setValue(
                multiSelectable
                  ? getSelectedValues(event.target)
                  : event.target.value,
              );
            }}
          >
            {toArray(value).map((value) => {
              if (value == null) return null;
              if (values.includes(value)) return null;
              return (
                <option key={value} value={value}>
                  {value}
                </option>
              );
            })}
            {values.map((value) => (
              <option key={value} value={value}>
                {value}
              </option>
            ))}
          </select>
          {element}
        </>
      );
    },
    [
      store,
      label,
      labelledBy,
      name,
      form,
      required,
      value,
      multiSelectable,
      values,
      disabledProp,
    ],
  );

  const children = (
    <>
      {value}
      <SelectArrow />
    </>
  );

  const contentElement = useStoreState(store, "contentElement");

  props = {
    role: "combobox",
    "aria-autocomplete": "none",
    "aria-labelledby": props["aria-label"] != null ? undefined : labelId,
    "aria-haspopup": getPopupRole(contentElement, "listbox"),
    "data-autofill": autofill || undefined,
    "data-name": name,
    children,
    ...props,
    ref: useMergeRefs(store.setSelectElement, props.ref),
    onKeyDown,
  };

  props = usePopoverDisclosure({ store, toggleOnClick, ...props });
  props = useCompositeTypeahead<TagName>({ store, ...props });

  return props;
});

/**
 * Renders a custom select element that controls the visibility of either a
 * [`SelectList`](https://ariakit.com/reference/select-list) or a
 * [`SelectPopover`](https://ariakit.com/reference/select-popover) component.
 *
 * By default, the
 * [`value`](https://ariakit.com/reference/select-provider#value) state is
 * rendered as the children, followed by a
 * [`SelectArrow`](https://ariakit.com/reference/select-arrow) component. This
 * can be customized by passing different children to the component.
 * @deprecated Use
 * [`ComboboxSelect`](https://ariakit.com/reference/combobox-select) instead.
 * @see https://ariakit.com/components/select
 * @example
 * ```jsx {2}
 * <SelectProvider>
 *   <Select />
 *   <SelectPopover>
 *     <SelectItem value="Apple" />
 *     <SelectItem value="Orange" />
 *   </SelectPopover>
 * </SelectProvider>
 * ```
 */
export const Select = forwardRef(function Select(props: SelectProps) {
  const htmlProps = useSelect(withDefaultButtonType(props));
  return createElement(TagName, htmlProps);
});

/** @deprecated Use `ComboboxSelectOptions` instead. */
export interface SelectOptions<T extends ElementType = TagName>
  extends
    PopoverDisclosureOptions<T>,
    CompositeTypeaheadOptions<T>,
    Pick<
      SelectHTMLAttributes<HTMLSelectElement>,
      "name" | "form" | "required"
    > {
  /**
   * Object returned by the
   * [`useSelectStore`](https://ariakit.com/reference/use-select-store) hook. If
   * not provided, the closest
   * [`SelectProvider`](https://ariakit.com/reference/select-provider)
   * component's context will be used.
   */
  store?: SelectStore;
  /**
   * Determines if the
   * [`SelectList`](https://ariakit.com/reference/select-list) or
   * [`SelectPopover`](https://ariakit.com/reference/select-popover) components
   * will appear when the user uses arrow keys while the select element is
   * in focus.
   * @default true
   */
  showOnKeyDown?: BooleanOrCallback<KeyboardEvent<HTMLElement>>;
  /**
   * Determines whether pressing arrow keys will move the active item even when
   * the [`SelectList`](https://ariakit.com/reference/select-list) or
   * [`SelectPopover`](https://ariakit.com/reference/select-popover) components
   * are hidden.
   * @default true
   */
  moveOnKeyDown?: BooleanOrCallback<KeyboardEvent<HTMLElement>>;
  /**
   * Determines whether pressing Space, Enter, or a click event will
   * [`toggle`](https://ariakit.com/reference/use-select-store#toggle) the
   * [`SelectList`](https://ariakit.com/reference/select-list) or
   * [`SelectPopover`](https://ariakit.com/reference/select-popover) components.
   * @default true
   * @deprecated Use
   * [`toggleOnClick`](https://ariakit.com/reference/select#toggleonclick)
   * instead.
   */
  toggleOnPress?: BooleanOrCallback<
    MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>
  >;
}

/** @deprecated Use `ComboboxSelectProps` instead. */
export type SelectProps<T extends ElementType = TagName> = Props<
  T,
  SelectOptions<T>
>;
