import { useStoreState } from "@ariakit/react-store";
import {
  useEvent,
  useForceUpdate,
  useId,
  useMergeRefs,
  useTagName,
  createElement,
  createHook,
  forwardRef,
  memo,
} from "@ariakit/react-utils";
import type { Props } from "@ariakit/react-utils";
import { disabledFromProps, removeUndefinedValues } from "@ariakit/utils";
import type { BivariantCallback } from "@ariakit/utils";
import type { ChangeEvent, ElementType, FocusEvent, MouseEvent } from "react";
import { useContext, useEffect, useRef } from "react";
import type { CompositeItemOptions } from "../composite/composite-item.tsx";
import { useCompositeItem } from "../composite/composite-item.tsx";
import {
  RadioGroupDisabledContext,
  useRadioContext,
} from "./radio-context.tsx";
import type { RadioStore, RadioStoreState } from "./radio-store.ts";

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

function getIsChecked(
  value: RadioOptions["value"],
  storeValue?: RadioStoreState["value"],
) {
  if (storeValue === undefined) return;
  if (value != null && storeValue != null) {
    return storeValue === value;
  }
  return !!storeValue;
}

function isNativeRadio(tagName?: string, type?: string) {
  return tagName === "input" && (!type || type === "radio");
}

/**
 * Returns props to create a `Radio` component.
 * @see https://ariakit.com/components/radio
 * @example
 * ```jsx
 * const store = useRadioStore();
 * const props = useRadio({ store, value: "Apple" });
 * <RadioGroup store={store}>
 *   <Role {...props} render={<input />} />
 *   <Radio value="Orange" />
 * </RadioGroup>
 * ```
 */
export const useRadio = createHook<TagName, RadioOptions>(function useRadio({
  store,
  name: nameProp,
  value,
  checked,
  ...props
}) {
  const context = useRadioContext();
  store = store || context;
  const groupDisabled = useContext(RadioGroupDisabledContext);

  const id = useId(props.id);

  const ref = useRef<HTMLType>(null);
  const isChecked = useStoreState(
    store,
    ["value"],
    (state) => checked ?? getIsChecked(value, state?.value),
  );

  // Use the store's id as the default name to ensure radios in different
  // groups have unique names. This prevents the browser from treating all
  // radios as a single group. See https://github.com/ariakit/ariakit/issues/3833
  const storeId = useStoreState(store, "id");
  const name = nameProp ?? storeId;

  // TODO: Consider moving this synchronization into the radio store.
  // Keep the checked radio active; otherwise the first item becomes active.
  useEffect(() => {
    if (!id) return;
    if (!isChecked) return;
    const isActiveItem = store?.getState().activeId === id;
    if (isActiveItem) return;
    store?.setActiveId(id);
  }, [store, isChecked, id]);

  const onChangeProp = props.onChange;
  const tagName = useTagName(ref, TagName);
  const nativeRadio = isNativeRadio(tagName, props.type);
  const disabled = groupDisabled || disabledFromProps(props);
  // When the checked property is programmatically set on the change event, we
  // need to schedule the element's property update, so the controlled
  // isChecked state can be taken into account.
  const [propertyUpdated, schedulePropertyUpdate] = useForceUpdate();

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    if (nativeRadio) return;
    if (isChecked !== undefined) {
      element.checked = isChecked;
    }
    if (name !== undefined) {
      element.name = name;
    }
    if (value !== undefined) {
      element.value = `${value}`;
    }
  }, [propertyUpdated, nativeRadio, isChecked, name, value]);

  const onChange = useEvent((event: ChangeEvent<HTMLType>) => {
    if (disabled) {
      event.preventDefault();
      event.stopPropagation();
      return;
    }
    // Do not call onChange if the value is the same as the current value. See
    // https://github.com/ariakit/ariakit/issues/3771
    if (store?.getState().value === value) return;
    if (!nativeRadio) {
      event.currentTarget.checked = true;
      schedulePropertyUpdate();
    }
    onChangeProp?.(event);
    if (event.defaultPrevented) return;
    store?.setValue(value);
  });

  const onClickProp = props.onClick;

  const onClick = useEvent((event: MouseEvent<HTMLType>) => {
    onClickProp?.(event);
    if (event.defaultPrevented) return;
    if (nativeRadio) return;
    // @ts-expect-error The onChange event expects a ChangeEvent, but here we
    // need to pass a MouseEvent.
    onChange(event);
  });

  const onFocusProp = props.onFocus;

  const onFocus = useEvent((event: FocusEvent<HTMLType>) => {
    onFocusProp?.(event);
    if (event.defaultPrevented) return;
    if (!nativeRadio) return;
    if (disabled) return;
    if (!store) return;
    const { moves, activeId } = store.getState();
    if (!moves) return;
    if (id && activeId !== id) return;
    // The composite keydown handler calls preventDefault() before moving
    // focus, which suppresses the browser's native check-on-arrow-key
    // behavior. Replay that activation with a real click so React delivers an
    // actual change event with checked already set, going through its
    // controlled input state restoration. The checked guard keeps no-op focus
    // events, such as tabbing back to the checked radio, from dispatching
    // spurious clicks. See https://github.com/ariakit/ariakit/issues/6345
    if (event.currentTarget.checked) return;
    event.currentTarget.click();
  });

  props = {
    role: !nativeRadio ? "radio" : undefined,
    type: nativeRadio ? "radio" : undefined,
    "aria-checked": isChecked,
    ...props,
    disabled: disabled || undefined,
    id,
    ref: useMergeRefs(ref, props.ref),
    onChange,
    onClick,
    onFocus,
  };

  props = useCompositeItem<TagName>({
    store,
    clickOnEnter: !nativeRadio,
    ...props,
  });

  // useFormRadio computes the field name after this hook. Remove the
  // native-only sentinels before they can overwrite that later default.
  return removeUndefinedValues({
    name: nativeRadio ? name : undefined,
    value: nativeRadio ? value : undefined,
    checked: isChecked,
    ...props,
  });
});

/**
 * Renders a radio button element that's typically wrapped in a
 * [`RadioGroup`](https://ariakit.com/reference/radio-group) component.
 * @see https://ariakit.com/components/radio
 * @example
 * ```jsx {3-4}
 * <RadioProvider>
 *   <RadioGroup>
 *     <Radio value="Apple" />
 *     <Radio value="Orange" />
 *   </RadioGroup>
 * </RadioProvider>
 * ```
 */
export const Radio = memo(
  forwardRef(function Radio(props: RadioProps) {
    const htmlProps = useRadio(props);
    return createElement(TagName, htmlProps);
  }),
);

export interface RadioOptions<
  T extends ElementType = TagName,
> extends CompositeItemOptions<T> {
  /**
   * Object returned by the
   * [`useRadioStore`](https://ariakit.com/reference/use-radio-store) hook. If
   * not provided, the closest
   * [`RadioGroup`](https://ariakit.com/reference/radio-group) or
   * [`RadioProvider`](https://ariakit.com/reference/radio-provider) components'
   * context will be used.
   */
  store?: RadioStore;
  /**
   * The value of the radio button.
   *
   * Live examples:
   * - [FormRadio](https://ariakit.com/examples/form-radio)
   * - [MenuItemRadio](https://ariakit.com/examples/menu-item-radio)
   */
  value: string | number;
  /**
   * The native `name` attribute.
   */
  name?: string;
  /**
   * Determines if the radio button is checked. Using this prop will make the
   * radio button controlled and override the
   * [`value`](https://ariakit.com/reference/radio-provider#value) state.
   */
  checked?: boolean;
  /**
   * Callback function that is called when the radio button state changes.
   */
  onChange?: BivariantCallback<(event: ChangeEvent<HTMLType>) => void>;
}

export type RadioProps<T extends ElementType = TagName> = Props<
  T,
  RadioOptions<T>
>;
