import {
  useEvent,
  useMergeRefs,
  useMetadataProps,
  useSafeLayoutEffect,
  createElement,
  createHook,
  forwardRef,
} from "@ariakit/react-utils";
import type { Options, Props } from "@ariakit/react-utils";
import {
  addGlobalEventListener,
  isButton,
  isElement,
  isFocusEventOutside,
  isSelfTarget,
  queueBeforeEvent,
  hasFocus,
  isFocusable,
  disabledFromProps,
  removeUndefinedValues,
  isSafari,
} from "@ariakit/utils";
import type { BivariantCallback } from "@ariakit/utils";
import type {
  ElementType,
  EventHandler,
  FocusEvent,
  KeyboardEvent as ReactKeyboardEvent,
  SyntheticEvent,
} from "react";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { isCompositeMoveKey } from "./__utils.ts";
import { FocusableContext } from "./focusable-context.tsx";

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

const accessibleWhenDisabledSymbol = Symbol("accessibleWhenDisabled");

const isSafariBrowser = isSafari();

const nativeTabbableMask = 1;
const supportsDisabledMask = 2;
const defaultElementCapabilities = nativeTabbableMask | supportsDisabledMask;

const alwaysFocusVisibleInputTypes = [
  "text",
  "search",
  "url",
  "tel",
  "email",
  "password",
  "number",
  "date",
  "month",
  "week",
  "time",
  "datetime",
  "datetime-local",
];

function isAlwaysFocusVisible(element: HTMLElement) {
  const { tagName, readOnly, type } = element as HTMLInputElement;
  if (tagName === "TEXTAREA" && !readOnly) return true;
  if (tagName === "SELECT" && !readOnly) return true;
  if (tagName === "INPUT" && !readOnly) {
    return alwaysFocusVisibleInputTypes.includes(type);
  }
  if (element.isContentEditable) return true;
  // Take into account custom Ariakit Select within a form.
  const role = element.getAttribute("role");
  if (role === "combobox" && element.dataset.name) {
    return true;
  }
  return false;
}

function isNativeTabbable(tagName?: string) {
  if (!tagName) return true;
  return (
    tagName === "button" ||
    tagName === "summary" ||
    tagName === "input" ||
    tagName === "select" ||
    tagName === "textarea" ||
    tagName === "a"
  );
}

function supportsDisabledAttribute(tagName?: string) {
  if (!tagName) return true;
  return (
    tagName === "button" ||
    tagName === "input" ||
    tagName === "select" ||
    tagName === "textarea"
  );
}

function getElementCapabilities(tagName?: string) {
  let capabilities = 0;
  if (isNativeTabbable(tagName)) {
    capabilities |= nativeTabbableMask;
  }
  if (supportsDisabledAttribute(tagName)) {
    capabilities |= supportsDisabledMask;
  }
  return capabilities;
}

interface GetTabIndexParams {
  focusable: boolean;
  trulyDisabled: boolean;
  nativeTabbable: boolean;
  supportsDisabled: boolean;
  safariTabIndex: boolean;
  tabIndexProp?: number;
}

function isNativeSubmitControl(element: HTMLElement) {
  if (element.tagName === "BUTTON") {
    const { type } = element as HTMLButtonElement;
    return type === "submit";
  }
  if (element.tagName === "INPUT") {
    const { type } = element as HTMLInputElement;
    return type === "submit" || type === "image";
  }
  return false;
}

function getTabIndex({
  focusable,
  trulyDisabled,
  nativeTabbable,
  supportsDisabled,
  safariTabIndex,
  tabIndexProp,
}: GetTabIndexParams) {
  if (!focusable) return tabIndexProp;
  if (trulyDisabled) {
    if (nativeTabbable && !supportsDisabled) {
      // Anchor, audio and video tags don't support the `disabled` attribute.
      // We must pass tabIndex={-1} so they don't receive focus on tab.
      return -1;
    }
    // Elements that support the `disabled` attribute don't need tabIndex.
    return;
  }
  if (nativeTabbable) {
    // On Safari, buttons and button-like inputs (checkboxes, radios, submit,
    // reset, etc.) require an explicit tabIndex to receive focus on mousedown.
    if (safariTabIndex && tabIndexProp == null) {
      return 0;
    }
    return tabIndexProp;
  }
  // If the element is enabled and is not natively tabbable, we have to
  // fallback tabIndex={0}.
  return tabIndexProp ?? 0;
}

function useDisableEvent(
  onEvent?: EventHandler<SyntheticEvent>,
  disabled?: boolean,
) {
  return useEvent((event: SyntheticEvent) => {
    onEvent?.(event);
    if (event.defaultPrevented) return;
    if (disabled) {
      event.stopPropagation();
      event.preventDefault();
    }
  });
}

let hasInstalledGlobalEventListeners = false;

// isKeyboardModality should be true by default.
let isKeyboardModality = true;

function onGlobalMouseDown(event: MouseEvent) {
  const target = event.target;
  // If the target element is already focus-visible, we keep the keyboard
  // modality.
  if (isElement(target) && !target.hasAttribute("data-focus-visible")) {
    isKeyboardModality = false;
  }
}

function onGlobalKeyDown(event: KeyboardEvent) {
  // Composite items navigate on these keys without checking modifiers, and
  // grids treat Ctrl+Home and Ctrl+End as first and last item, so the modified
  // gesture moves focus just like the unmodified one.
  // https://github.com/ariakit/ariakit/issues/7099
  if (isCompositeMoveKey(event.key)) {
    isKeyboardModality = true;
    return;
  }
  if (event.metaKey) return;
  if (event.ctrlKey) return;
  if (event.altKey) {
    // Safari moves focus with Option+Tab when the macOS keyboard navigation
    // setting is off, so that gesture is keyboard navigation rather than a
    // shortcut. Every other Alt-modified non-navigation key still keeps the
    // current modality, since Option types special characters on macOS.
    // https://github.com/ariakit/ariakit/issues/7094
    if (!isSafariBrowser) return;
    if (event.key !== "Tab") return;
  }
  isKeyboardModality = true;
}

/**
 * Returns props to create a `Focusable` component.
 * @see https://ariakit.com/components/focusable
 * @example
 * ```jsx
 * const props = useFocusable();
 * <Role {...props}>Focusable</Role>
 * ```
 */
export const useFocusable = createHook<TagName, FocusableOptions>(
  function useFocusable({
    focusable = true,
    accessibleWhenDisabled,
    autoFocus,
    onFocusVisible,
    ...props
  }) {
    const ref = useRef<HTMLType>(null);
    const [parentAccessibleWhenDisabled, metadataProps] = useMetadataProps(
      props,
      accessibleWhenDisabledSymbol,
      accessibleWhenDisabled,
    );
    accessibleWhenDisabled ??= parentAccessibleWhenDisabled;

    // Add global event listeners to determine whether the user is using a
    // keyboard to navigate the site or not.
    useEffect(() => {
      if (!focusable) return;
      if (hasInstalledGlobalEventListeners) return;
      addGlobalEventListener("mousedown", onGlobalMouseDown, true);
      addGlobalEventListener("keydown", onGlobalKeyDown, true);
      hasInstalledGlobalEventListeners = true;
    }, [focusable]);

    const disabled = focusable && disabledFromProps(props);
    const trulyDisabled = disabled && !accessibleWhenDisabled;
    const [focusVisible, setFocusVisible] = useState(false);
    const focusVisibleRef = useRef(false);
    const nativeSubmitObserverCleanupRef = useRef<(() => void) | null>(null);
    const cleanupFocusVisible = useEvent((element: HTMLElement | null) => {
      nativeSubmitObserverCleanupRef.current?.();
      nativeSubmitObserverCleanupRef.current = null;
      focusVisibleRef.current = false;
      element?.removeAttribute("data-focus-visible");
    });

    // When the focusable element is disabled, it doesn't trigger a blur event,
    // and turning focusable off disables the blur handler below. Clear the
    // internal focus-visible state here in both cases.
    useEffect(() => {
      if (focusable && !trulyDisabled) return;
      cleanupFocusVisible(ref.current);
      if (focusVisible) {
        setFocusVisible(false);
      }
    }, [focusable, trulyDisabled, focusVisible, cleanupFocusVisible]);

    // When an element that has focus becomes hidden, it doesn't trigger a blur
    // event so we can't set focusVisible to false there. We observe the element
    // and check if it's still focusable. Otherwise, we set focusVisible to
    // false.
    useEffect(() => {
      if (!focusable) return;
      if (!focusVisible) return;
      const element = ref.current;
      if (!element) return;
      if (typeof IntersectionObserver === "undefined") return;
      const observer = new IntersectionObserver(() => {
        if (!isFocusable(element)) {
          focusVisibleRef.current = false;
          setFocusVisible(false);
        }
      });
      observer.observe(element);
      return () => observer.disconnect();
    }, [focusable, focusVisible]);

    useEffect(() => {
      return () => nativeSubmitObserverCleanupRef.current?.();
    }, []);

    // Disable events when the element is disabled.
    const onKeyPressCapture = useDisableEvent(
      props.onKeyPressCapture,
      disabled,
    );
    const onMouseDownCapture = useDisableEvent(
      props.onMouseDownCapture,
      disabled,
    );
    const onClickCapture = useDisableEvent(props.onClickCapture, disabled);

    const handleFocusVisible = (
      event: SyntheticEvent<HTMLType>,
      currentTarget?: HTMLType,
    ) => {
      if (currentTarget) {
        event.currentTarget = currentTarget;
      }
      if (!focusable) return;
      const element = event.currentTarget;
      if (!element) return;
      // Some extensions like 1password dispatches some keydown events on
      // autofill and immediately moves focus to the next field. That's why we
      // need to check if the current element is still focused.
      if (!hasFocus(element)) return;
      onFocusVisible?.(event);
      if (event.defaultPrevented) return;
      // Make sure data-focus-visible is applied visually at the same time as
      // other data attributes like data-active-item. See
      // https://github.com/ariakit/ariakit/issues/4083
      element.dataset.focusVisible = "true";
      focusVisibleRef.current = true;
      // React 19's useFormStatus may lose the pending state when local
      // component state changes while a native submit control is pending.
      if (isNativeSubmitControl(element)) {
        nativeSubmitObserverCleanupRef.current?.();
        nativeSubmitObserverCleanupRef.current = null;
        if (typeof IntersectionObserver !== "undefined") {
          const observer = new IntersectionObserver(() => {
            if (isFocusable(element)) return;
            cleanupFocusVisible(element);
          });
          observer.observe(element);
          nativeSubmitObserverCleanupRef.current = () => observer.disconnect();
        }
        return;
      }
      setFocusVisible(true);
    };

    const onKeyDownCaptureProp = props.onKeyDownCapture;

    const onKeyDownCapture = useEvent((event: ReactKeyboardEvent<HTMLType>) => {
      onKeyDownCaptureProp?.(event);
      if (event.defaultPrevented) return;
      if (!focusable) return;
      if (focusVisible) return;
      if (focusVisibleRef.current) return;
      const hasModifier = event.metaKey || event.altKey || event.ctrlKey;
      // This guard also matters when the key moves nothing, such as the last
      // item of a composite that doesn't loop: no focus event fires, so this
      // handler is the only path that applies data-focus-visible.
      // https://github.com/ariakit/ariakit/issues/7099
      if (hasModifier && !isCompositeMoveKey(event.key)) return;
      if (!isSelfTarget(event)) return;
      const element = event.currentTarget;
      const applyFocusVisible = () => handleFocusVisible(event, element);
      queueBeforeEvent(element, "focusout", applyFocusVisible);
    });

    const onFocusCaptureProp = props.onFocusCapture;

    const onFocusCapture = useEvent((event: FocusEvent<HTMLType>) => {
      onFocusCaptureProp?.(event);
      if (event.defaultPrevented) return;
      if (!focusable) return;
      if (!isSelfTarget(event)) {
        setFocusVisible(false);
        return;
      }
      const element = event.currentTarget;
      const applyFocusVisible = () => handleFocusVisible(event, element);
      if (isKeyboardModality || isAlwaysFocusVisible(event.target)) {
        queueBeforeEvent(event.target, "focusout", applyFocusVisible);
      } else {
        setFocusVisible(false);
      }
    });

    const onBlurProp = props.onBlur;

    // Note: Can't use onBlurCapture here otherwise it will not work with
    // CompositeItem's with the virtualFocus state set to true.
    const onBlur = useEvent((event: FocusEvent<HTMLType>) => {
      onBlurProp?.(event);
      if (!focusable) return;
      if (!isFocusEventOutside(event)) return;
      // Since we set the data-focus-visible attribute on the element in the
      // handleFocusVisible function, we remove it directly here. Otherwise, the
      // attribute might not be removed on lower-end devices.
      cleanupFocusVisible(event.currentTarget);
      setFocusVisible(false);
    });

    const autoFocusOnShow = useContext(FocusableContext);

    // The native autoFocus prop is problematic in many ways. For example, when
    // an element has the native autofocus attribute, the focus event will be
    // triggered before React effects (even layout effects) and before refs are
    // assigned. This means we won't have access to the element's ref or
    // anything else that's set up by React effects on the onFocus event. So we
    // don't pass the autoFocus prop to the element and instead manually focus
    // the element when it's mounted. The order in which this effect runs also
    // matters. See
    // https://x.com/diegohaz/status/1408180632933388289
    const autoFocusRef = useEvent((element: HTMLElement | null) => {
      if (!focusable) return;
      if (!autoFocus) return;
      if (!element) return;
      if (!autoFocusOnShow) return;
      // We have to queue focus so other effects and refs can be applied first.
      // See select-animated example.
      queueMicrotask(() => {
        if (hasFocus(element)) return;
        if (!isFocusable(element)) return;
        element.focus();
      });
    });

    // Track only the element capabilities that affect the returned props so
    // resolving the default native element doesn't require another render.
    const [elementCapabilities, setElementCapabilities] = useState(
      defaultElementCapabilities,
    );
    useSafeLayoutEffect(() => {
      const element = ref.current;
      if (!element) return;
      const nextCapabilities = getElementCapabilities(
        element.tagName.toLowerCase(),
      );
      if (nextCapabilities === defaultElementCapabilities) return;
      setElementCapabilities(nextCapabilities);
    }, []);
    const nativeTabbable =
      focusable && !!(elementCapabilities & nativeTabbableMask);
    const supportsDisabled =
      focusable && !!(elementCapabilities & supportsDisabledMask);

    // On Safari, buttons and button-like inputs don't receive focus on
    // mousedown. We detect this from the DOM element (not props) so it works
    // with render={<input type="submit" />} and custom components.
    const [safariTabIndex, setSafariTabIndex] = useState(false);

    if (isSafariBrowser) {
      useEffect(() => {
        if (!focusable) return;
        const element = ref.current;
        if (!element) return;
        const { type } = element as HTMLInputElement;
        const isNativeCheckboxOrRadio =
          element.tagName === "INPUT" &&
          (type === "checkbox" || type === "radio");
        setSafariTabIndex(isButton(element) || isNativeCheckboxOrRadio);
      }, [focusable]);
    }

    const styleProp = props.style;
    const style = useMemo(() => {
      if (trulyDisabled) {
        return { pointerEvents: "none" as const, ...styleProp };
      }
      return styleProp;
    }, [trulyDisabled, styleProp]);

    props = {
      "data-focus-visible": (focusable && focusVisible) || undefined,
      "data-autofocus": autoFocus || undefined,
      "aria-disabled": disabled || undefined,
      ...props,
      ...metadataProps,
      ref: useMergeRefs(ref, autoFocusRef, props.ref),
      style,
      tabIndex: getTabIndex({
        focusable,
        trulyDisabled,
        nativeTabbable,
        supportsDisabled,
        safariTabIndex,
        tabIndexProp: props.tabIndex,
      }),
      disabled: supportsDisabled && trulyDisabled ? true : undefined,
      // TODO: Add contentEditable coverage.
      contentEditable: disabled ? undefined : props.contentEditable,
      onKeyPressCapture,
      onClickCapture,
      onMouseDownCapture,
      onKeyDownCapture,
      onFocusCapture,
      onBlur,
    };

    // Higher-level hooks such as useMenu compute defaults after useDialog has
    // called this hook. Keep direct hook composition protected too.
    return removeUndefinedValues(props);
  },
);

/**
 * Renders a focusable element. When this element gains keyboard focus, it gets
 * a
 * [`data-focus-visible`](https://ariakit.com/guide/styling#data-focus-visible)
 * attribute and triggers the
 * [`onFocusVisible`](https://ariakit.com/reference/focusable#onfocusvisible)
 * prop.
 *
 * The `Focusable` component supports the
 * [`disabled`](https://ariakit.com/reference/focusable#disabled) prop for all
 * elements, even those not supporting the native `disabled` attribute. Disabled
 * elements using the `Focusable` component may be still accessible via keyboard
 * by using the the
 * [`accessibleWhenDisabled`](https://ariakit.com/reference/focusable#accessiblewhendisabled)
 * prop.
 * @see https://ariakit.com/components/focusable
 * @example
 * ```jsx
 * <Focusable>Focusable</Focusable>
 * ```
 */
export const Focusable = forwardRef(function Focusable(props: FocusableProps) {
  const htmlProps = useFocusable(props);
  return createElement(TagName, htmlProps);
});

export interface FocusableOptions<
  _T extends ElementType = TagName,
> extends Options {
  /**
   * Determines if the element is disabled. This sets the `aria-disabled`
   * attribute accordingly, enabling support for all elements, including those
   * that don't support the native `disabled` attribute.
   *
   * This feature can be combined with the
   * [`accessibleWhenDisabled`](https://ariakit.com/reference/focusable#accessiblewhendisabled)
   * prop to make disabled elements still accessible via keyboard.
   *
   * **Note**: For this prop to work, the
   * [`focusable`](https://ariakit.com/reference/command#focusable) prop must be
   * set to `true`, if it's not set by default.
   *
   * Live examples:
   * - [Submenu](https://ariakit.com/examples/menu-nested)
   * - [Combobox with Tabs](https://ariakit.com/examples/combobox-tabs)
   * - [Context Menu](https://ariakit.com/examples/menu-context-menu)
   * @default false
   */
  disabled?: boolean;
  /**
   * Automatically focuses the element upon mounting, similar to the native
   * `autoFocus` prop. This addresses an issue where the element with the native
   * `autoFocus` attribute might receive focus before React effects are
   * executed.
   *
   * The `autoFocus` prop can also be used with
   * [Focusable](https://ariakit.com/components/focusable) elements within a
   * [Dialog](https://ariakit.com/components/dialog) component, establishing the
   * initial focus as the dialog opens.
   *
   * **Note**: For this prop to work, the
   * [`focusable`](https://ariakit.com/reference/command#focusable) prop must be
   * set to `true`, if it's not set by default.
   *
   * Live examples:
   * - [Warning on Dialog
   *   hide](https://ariakit.com/examples/dialog-hide-warning)
   * - [Dialog with React
   *   Router](https://ariakit.com/examples/dialog-react-router)
   * - [Nested Dialog](https://ariakit.com/examples/dialog-nested)
   * @default false
   */
  autoFocus?: boolean;
  /**
   * Determines if [Focusable](https://ariakit.com/components/focusable)
   * features should be active on non-native focusable elements.
   *
   * **Note**: This prop only turns off the additional features provided by the
   * [`Focusable`](https://ariakit.com/reference/focusable) component.
   * Non-native focusable elements will lose their focusability entirely.
   * However, native focusable elements will retain their inherent focusability,
   * but without added features such as improved
   * [`autoFocus`](https://ariakit.com/reference/focusable#autofocus),
   * [`accessibleWhenDisabled`](https://ariakit.com/reference/focusable#accessiblewhendisabled),
   * [`onFocusVisible`](https://ariakit.com/reference/focusable#onfocusvisible),
   * etc.
   * @default true
   */
  focusable?: boolean;
  /**
   * Indicates whether the element should be focusable even when it is
   * [`disabled`](https://ariakit.com/reference/focusable#disabled).
   *
   * This is important when discoverability is a concern. For example:
   *
   * > A toolbar in an editor contains a set of special smart paste functions
   * that are disabled when the clipboard is empty or when the function is not
   * applicable to the current content of the clipboard. It could be helpful to
   * keep the disabled buttons focusable if the ability to discover their
   * functionality is primarily via their presence on the toolbar.
   *
   * Learn more on [Focusability of disabled
   * controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusabilityofdisabledcontrols).
   *
   * Live examples:
   * - [Combobox with Tabs](https://ariakit.com/examples/combobox-tabs)
   * - [Command Menu with
   *   Tabs](https://ariakit.com/examples/dialog-combobox-tab-command-menu)
   */
  accessibleWhenDisabled?: boolean;
  /**
   * Custom event handler invoked when the element gains focus through keyboard
   * interaction or a key press occurs while the element is in focus. This is
   * the programmatic equivalent of the
   * [`data-focus-visible`](https://ariakit.com/guide/styling#data-focus-visible)
   * attribute.
   *
   * **Note**: For this prop to work, the
   * [`focusable`](https://ariakit.com/reference/command#focusable) prop must be
   * set to `true`, if it's not set by default.
   *
   * Live examples:
   * - [Navigation Menubar](https://ariakit.com/examples/menubar-navigation)
   * - [Custom Checkbox](https://ariakit.com/examples/checkbox-custom)
   */
  onFocusVisible?: BivariantCallback<
    (event: SyntheticEvent<HTMLElement>) => void
  >;
}

export type FocusableProps<T extends ElementType = TagName> = Props<
  T,
  FocusableOptions<T>
>;
