'use client'

import {
  Children,
  createContext,
  isValidElement,
  useCallback,
  useContext,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type Key,
  type ReactNode,
  type RefObject,
} from 'react'
import { flushSync } from 'react-dom'

import type { SetRequiredNonNullable } from '@graphprotocol/gds-utils'

import { Render, type RenderProp } from './Render.tsx'

type VisibleKey = Key | Key[] | null

type GetChildKey = (child: ReactNode, index: number) => Key | undefined

/**
 * - "entering": just added / made visible, transitioning in.
 * - "visible": present and not transitioning.
 * - "exiting": just removed / made hidden, transitioning out.
 * - "hidden": kept mounted but not shown (persistent mode only).
 */
type Status = 'entering' | 'visible' | 'exiting' | 'hidden'

interface Item {
  key: Key
  element: ReactNode
  status: Status
  /**
   * The previous status before the most recent change, or `null` if status hasn't just changed.
   * Cleared after next frame.
   */
  previousStatus: Status | null
  /** Whether this item was present on mount and hasn't re-entered since. */
  initial: boolean
}

type PresenceItemState = Omit<Item, 'element'>

type PresenceWrapperState = {
  /** Whether at least one item is currently transitioning (entering or exiting). */
  transitioning: boolean
  /** Whether at least one item is present (entering or visible). */
  hasPresent: boolean
  /** Array of all currently rendered items (entering, visible, exiting, and hidden). */
  items: Item[]
}

interface PresenceContextValue {
  /** The status of the nearest `Presence` item ancestor. */
  status: Status
  /**
   * Whether all `Presence` item ancestors are present (entering or visible). If any ancestor is
   * exiting or hidden, this will be `false`.
   */
  present: boolean
}

const PresenceContext = createContext<PresenceContextValue | null>(null)

/**
 * Hook to access presence information from the nearest `Presence` item ancestor.
 *
 * @returns An object with `status` and `present`, or `null` if not inside a `Presence` component.
 */
export function usePresence() {
  return useContext(PresenceContext)
}

interface BasePresenceProps {
  /**
   * How long, in milliseconds, children stay in the "entering" status before switching to
   * "visible".
   *
   * @default 0
   */
  enterMs?: number | undefined
  /**
   * How long, in milliseconds, children stay in the "exiting" status before being unmounted (or
   * switching to "hidden", in persistent mode).
   *
   * @default 0
   */
  exitMs?: number | undefined
  /**
   * Whether children that are present on mount should start in the "entering" status, instead of
   * "visible".
   *
   * @default false
   */
  initial?: boolean | undefined
  /**
   * Element or render function for the outer wrapper. By default, renders a fragment (no wrapper).
   *
   * Receives state with `transitioning`, `hasPresent`, and `items`.
   */
  render?: RenderProp<PresenceWrapperState> | undefined
  /**
   * Element or render function for each child's inner wrapper. By default, renders a `div`.
   *
   * Receives props (including `hidden`, `inert`, `data-key`, `data-status`, `data-starting-style`,
   * and `data-initial`) and state (`key`, `status`, `previousStatus`, and `initial`).
   */
  renderChild?: RenderProp<PresenceItemState> | undefined
  /**
   * Function to extract a key from a child node. Keys determine identity: when a child's key
   * changes, the old one exits and the new one enters (triggering exit/enter transitions). If not
   * provided, falls back to the child's `key` prop (with React's internal prefixes stripped) or the
   * index.
   */
  getChildKey?: GetChildKey | undefined
  /**
   * Called synchronously during state update, before React commits DOM changes. Exiting elements
   * are still in their original positions; entering elements don't exist yet (only keys provided).
   * Useful for measuring layout before transitions begin.
   *
   * `previouslyTransitioning` contains items that were already in `entering` or `exiting` status
   * before this transition started (useful for detecting interrupted transitions).
   */
  onBeforeTransitionStart?:
    | ((changes: {
        entering: Set<Key>
        exiting: Map<Key, HTMLElement>
        previouslyTransitioning: Map<Key, 'entering' | 'exiting'>
      }) => void)
    | undefined
  /**
   * Called in a layout effect after React commits DOM changes but before browser paint. Both
   * entering and exiting elements are in the DOM. Useful for triggering CSS transitions.
   *
   * `previouslyTransitioning` contains items that were already in `entering` or `exiting` status
   * before this transition started (useful for detecting interrupted transitions).
   */
  onTransitionStart?:
    | ((changes: {
        entering: Map<Key, HTMLElement>
        exiting: Map<Key, HTMLElement>
        previouslyTransitioning: Map<Key, 'entering' | 'exiting'>
      }) => void)
    | undefined
  /**
   * Called synchronously during state update, before React commits DOM changes. Both entered and
   * exited elements are still in the DOM. This is the last chance to access exited elements.
   */
  onBeforeTransitionEnd?:
    | ((changes: { entered: Map<Key, HTMLElement>; exited: Map<Key, HTMLElement> }) => void)
    | undefined
  /**
   * Called in a layout effect after React commits DOM changes but before browser paint. Entered
   * elements are still in the DOM; exited elements have been removed (only keys provided).
   */
  onTransitionEnd?:
    | ((changes: { entered: Map<Key, HTMLElement>; exited: Set<Key> }) => void)
    | undefined
  children?: ReactNode
}

/** Unmounting mode: delays unmounting for exit transitions (similar to Motion's `AnimatePresence`). */
interface UnmountingPresenceProps extends BasePresenceProps {
  visibleKey?: undefined
}

/** Persistent mode: keeps children mounted, but toggles their visibility with a delay. */
interface PersistentPresenceProps extends BasePresenceProps {
  /**
   * Key(s) of children that should be visible. Accepts:
   *
   * - Single key: `"foo"`
   * - Array of keys: `["foo", "bar"]`
   * - `null`: no child is visible.
   */
  visibleKey?: VisibleKey
}

export type PresenceProps = UnmountingPresenceProps | PersistentPresenceProps

/**
 * Presence.
 *
 * - If you pass no `visibleKey` → unmounting mode.
 * - If you pass `visibleKey` → persistent mode.
 */
export function Presence({
  visibleKey,
  enterMs = 0,
  exitMs = 0,
  initial = false,
  render,
  renderChild = <div />,
  getChildKey,
  onBeforeTransitionStart,
  onTransitionStart,
  onBeforeTransitionEnd,
  onTransitionEnd,
  children,
}: PresenceProps) {
  if (visibleKey !== undefined) {
    return (
      <PersistentPresence
        visibleKey={visibleKey}
        enterMs={enterMs}
        exitMs={exitMs}
        initial={initial}
        render={render}
        renderChild={renderChild}
        getChildKey={getChildKey}
        onBeforeTransitionStart={onBeforeTransitionStart}
        onTransitionStart={onTransitionStart}
        onBeforeTransitionEnd={onBeforeTransitionEnd}
        onTransitionEnd={onTransitionEnd}
      >
        {children}
      </PersistentPresence>
    )
  }

  return (
    <UnmountingPresence
      enterMs={enterMs}
      exitMs={exitMs}
      initial={initial}
      render={render}
      renderChild={renderChild}
      getChildKey={getChildKey}
      onBeforeTransitionStart={onBeforeTransitionStart}
      onTransitionStart={onTransitionStart}
      onBeforeTransitionEnd={onBeforeTransitionEnd}
      onTransitionEnd={onTransitionEnd}
    >
      {children}
    </UnmountingPresence>
  )
}

/* -------------------------------------------------------------------------- */
/*                               Unmounting mode                              */
/* -------------------------------------------------------------------------- */

interface UnmountingItem extends Item {
  status: Exclude<Status, 'hidden'>
}

interface UnmountingPresenceImplProps extends SetRequiredNonNullable<
  BasePresenceProps,
  'enterMs' | 'exitMs' | 'initial' | 'renderChild'
> {
  render?: BasePresenceProps['render']
  getChildKey?: GetChildKey | undefined
  onBeforeTransitionStart?: BasePresenceProps['onBeforeTransitionStart']
  onTransitionStart?: BasePresenceProps['onTransitionStart']
  onBeforeTransitionEnd?: BasePresenceProps['onBeforeTransitionEnd']
  onTransitionEnd?: BasePresenceProps['onTransitionEnd']
}

/**
 * UnmountingPresence.
 *
 * Mental model:
 *
 * - Build a map of all items (previous items + current children).
 * - Update their statuses ("entering" → "visible" when added, "visible" → "exiting" when unmounted,
 *   "exiting" → "entering" when re-added).
 * - Sort: streaming merge that walks through previous items, using survivors as anchors. For each
 *   survivor, flush all unprocessed current children up to and including it. Exiting items are
 *   inserted at their previous position.
 */
function UnmountingPresence({
  enterMs,
  exitMs,
  initial,
  render,
  renderChild,
  getChildKey,
  onBeforeTransitionStart,
  onTransitionStart,
  onBeforeTransitionEnd,
  onTransitionEnd,
  children,
}: UnmountingPresenceImplProps) {
  const getChildKeyRef = useRef(getChildKey)
  getChildKeyRef.current = getChildKey

  const { setElementRef, fireBeforeTransitionEnd, fireCallbacks } = usePresenceCallbacks(
    onBeforeTransitionStart,
    onTransitionStart,
    onBeforeTransitionEnd,
    onTransitionEnd,
  )

  const [items, setItems] = useState<UnmountingItem[]>(() => {
    const { array } = buildChildMap(children, getChildKey)
    const isEntering = initial && enterMs > 0
    return array.map((child, index) => ({
      key: getStableKey(child, index, getChildKey),
      element: child,
      status: isEntering ? 'entering' : 'visible',
      previousStatus: null,
      initial: true,
    }))
  })

  const { timeoutsRef, addEntered, addExited, flushCallbackRef } = useTransitionTimeouts()
  // Memoize to avoid re-running `useLayoutEffect` on every render (all callbacks are stable)
  const callbacks = useMemo(
    () => ({ setElementRef, fireBeforeTransitionEnd, fireCallbacks }),
    // oxlint-disable-next-line react-hooks/exhaustive-deps -- all callbacks are stable (useCallback with [])
    [],
  )

  // Set up the flush callback to handle batched transition ends
  flushCallbackRef.current = (entered: Set<Key>, exited: Set<Key>) => {
    callbacks.fireBeforeTransitionEnd(entered, exited)
    setItems((current) =>
      current
        .filter((item) => !exited.has(item.key))
        .map((item) =>
          entered.has(item.key)
            ? { ...item, status: 'visible', previousStatus: item.status }
            : item,
        ),
    )
  }

  useInitialTransitions(
    initial,
    enterMs,
    () => {
      const keys = new Set<Key>()
      items.forEach((item) => {
        if (item.status === 'entering' && !timeoutsRef.current.has(item.key)) {
          keys.add(item.key)
        }
      })
      return keys
    },
    timeoutsRef,
    callbacks,
    (key) =>
      setItems((curr) =>
        curr.map((i) =>
          i.key === key ? { ...i, status: 'visible', previousStatus: i.status } : i,
        ),
      ),
  )

  useLayoutEffect(() => {
    const { map: presentByKey, keys: childKeys } = buildChildMap(children, getChildKeyRef.current)
    const nextKeySet = new Set<Key>(childKeys)

    setItems((prevItems) => {
      const keysToScheduleExitEnd = new Set<Key>()
      const keysToScheduleEnterEnd = new Set<Key>()
      const keysEntering = new Set<Key>()
      const keysExiting = new Set<Key>()

      // Build map of items that are currently transitioning (before this update)
      const previouslyTransitioning = new Map<Key, 'entering' | 'exiting'>()
      const prevByKey = new Map<Key, UnmountingItem>()
      prevItems.forEach((item) => {
        prevByKey.set(item.key, item)
        if (item.status === 'entering' || item.status === 'exiting') {
          previouslyTransitioning.set(item.key, item.status)
        }
      })

      const nextItems: UnmountingItem[] = []

      // Process current children
      childKeys.forEach((key) => {
        const prev = prevByKey.get(key)
        const present = presentByKey.get(key)!

        if (prev?.status === 'exiting') {
          // Item coming back from exiting
          cancelTimeout(key, timeoutsRef)
          const status = enterMs > 0 ? 'entering' : 'visible'
          nextItems.push({
            key,
            element: present.element,
            status,
            previousStatus: prev.status,
            initial: false,
          })
          keysEntering.add(key)
          if (status === 'entering') {
            keysToScheduleEnterEnd.add(key)
          }
        } else if (prev) {
          // Item already exists, keep its status (but instant-transition "entering" → "visible" if `enterMs` changed to 0)
          const status = prev.status === 'entering' && enterMs <= 0 ? 'visible' : prev.status
          const statusChanged = status !== prev.status
          nextItems.push({
            key,
            element: present.element,
            status,
            previousStatus: statusChanged ? prev.status : prev.previousStatus,
            initial: prev.initial,
          })
        } else {
          // New item
          const status = enterMs > 0 ? 'entering' : 'visible'
          nextItems.push({
            key,
            element: present.element,
            status,
            previousStatus: 'hidden',
            initial: false,
          })
          keysEntering.add(key)
          if (status === 'entering') {
            keysToScheduleEnterEnd.add(key)
          }
        }
      })

      // Process items being removed
      prevItems.forEach((item) => {
        if (!nextKeySet.has(item.key)) {
          const wasAlreadyExiting = item.status === 'exiting'
          if (!wasAlreadyExiting) {
            cancelTimeout(item.key, timeoutsRef)
            keysExiting.add(item.key)
            if (exitMs > 0) {
              keysToScheduleExitEnd.add(item.key)
            }
          }
          // Keep exiting items if:
          // - `exitMs` > 0: allows exit transition
          // - already exiting: let it finish (e.g., children changed while item was mid-exit)
          if (exitMs > 0 || wasAlreadyExiting) {
            nextItems.push({
              ...item,
              status: 'exiting',
              previousStatus: wasAlreadyExiting ? item.previousStatus : item.status,
            })
          }
        }
      })

      // Fire callbacks (handles both timed and instant transitions)
      callbacks.fireCallbacks(
        children,
        enterMs,
        exitMs,
        keysEntering,
        keysExiting,
        previouslyTransitioning,
      )

      // Sort: preserve order by merging current children with previous items
      const itemsByKey = new Map(nextItems.map((item) => [item.key, item]))
      const childIndexByKey = new Map(childKeys.map((key, index) => [key, index]))
      const result: UnmountingItem[] = []
      const added = new Set<Key>()

      let childIndex = 0

      const addChild = (key: Key) => {
        if (!added.has(key)) {
          const item = itemsByKey.get(key)
          if (item) {
            result.push(item)
            added.add(key)
          }
        }
      }

      // Walk through previous items, inserting current children at their positions
      prevItems.forEach((prevItem) => {
        const item = itemsByKey.get(prevItem.key)
        if (!item) return

        if (item.status === 'exiting') {
          // Exiting items stay at their previous position
          result.push(item)
          added.add(item.key)
        } else {
          // Current child - add all unprocessed children up to and including it
          const indexInChildren = childIndexByKey.get(prevItem.key)
          if (indexInChildren !== undefined) {
            while (childIndex <= indexInChildren) {
              addChild(childKeys[childIndex]!)
              childIndex++
            }
          }
        }
      })

      // Add any remaining new children
      while (childIndex < childKeys.length) {
        addChild(childKeys[childIndex]!)
        childIndex++
      }

      // Schedule async transitions (after state update completes)
      scheduleTransitionTimeouts(
        enterMs,
        exitMs,
        keysToScheduleEnterEnd,
        keysToScheduleExitEnd,
        timeoutsRef,
        addEntered,
        addExited,
      )

      return result
    })
  }, [children, enterMs, exitMs, timeoutsRef, addEntered, addExited, callbacks])

  // Clear `previousStatus` after the browser has painted the new styles
  useAfterPaint(
    items.some((item) => item.previousStatus !== null),
    () => {
      flushSync(() => {
        setItems((current) =>
          current.map((item) =>
            item.previousStatus !== null ? { ...item, previousStatus: null } : item,
          ),
        )
      })
    },
  )

  return (
    <PresenceWrapper render={render} items={items}>
      {items.map((item) => (
        <PresenceItemProvider key={item.key} status={item.status}>
          <Render
            ref={(el: HTMLElement | null) => callbacks.setElementRef(item.key, el)}
            render={renderChild}
            inert={item.status === 'exiting'}
            data-key={item.key}
            data-status={item.status}
            data-starting-style={item.previousStatus !== null || undefined}
            data-initial={item.initial || undefined}
            state={{
              key: item.key,
              status: item.status,
              previousStatus: item.previousStatus,
              initial: item.initial,
            }}
          >
            {item.element}
          </Render>
        </PresenceItemProvider>
      ))}
    </PresenceWrapper>
  )
}

/* -------------------------------------------------------------------------- */
/*                        Persistent (show/hide) mode                         */
/* -------------------------------------------------------------------------- */

interface PersistentItem extends Item {
  order: number
}

interface PersistentPresenceImplProps extends SetRequiredNonNullable<
  BasePresenceProps,
  'enterMs' | 'exitMs' | 'initial' | 'renderChild'
> {
  visibleKey: VisibleKey
  render?: BasePresenceProps['render']
  getChildKey?: GetChildKey | undefined
  onBeforeTransitionStart?: BasePresenceProps['onBeforeTransitionStart']
  onTransitionStart?: BasePresenceProps['onTransitionStart']
  onBeforeTransitionEnd?: BasePresenceProps['onBeforeTransitionEnd']
  onTransitionEnd?: BasePresenceProps['onTransitionEnd']
}

function normalizeVisibleKey(input: VisibleKey): Set<Key> {
  if (input === null) {
    return new Set()
  }

  if (Array.isArray(input)) {
    return new Set(input)
  }

  // Single key - TypeScript can't narrow the union type here
  return new Set([input])
}

/**
 * PersistentPresence.
 *
 * - Tracks children by key in a `Map`.
 * - Items are never unmounted _unless_ you stop rendering them at all.
 * - When a key enters `visibleKey`:
 *
 *   - status goes "hidden" → "entering" for `enterMs`
 *   - then "visible"
 * - When a key leaves `visibleKey`:
 *
 *   - status goes "visible" → "exiting" for `exitMs`
 *   - then "hidden" (still mounted, but `hidden` attribute on wrapper)
 */
function PersistentPresence({
  visibleKey,
  enterMs,
  exitMs,
  initial,
  render,
  renderChild,
  getChildKey,
  onBeforeTransitionStart,
  onTransitionStart,
  onBeforeTransitionEnd,
  onTransitionEnd,
  children,
}: PersistentPresenceImplProps) {
  const getChildKeyRef = useRef(getChildKey)
  getChildKeyRef.current = getChildKey

  const { setElementRef, fireBeforeTransitionEnd, fireCallbacks } = usePresenceCallbacks(
    onBeforeTransitionStart,
    onTransitionStart,
    onBeforeTransitionEnd,
    onTransitionEnd,
  )

  const [itemsMap, setItemsMap] = useState<Map<Key, PersistentItem>>(() => {
    const { array } = buildChildMap(children, getChildKey)
    const visibleSet = normalizeVisibleKey(visibleKey)
    const map = new Map<Key, PersistentItem>()

    array.forEach((child, index) => {
      const key = getStableKey(child, index, getChildKey)
      const status = getInitialStatus(visibleSet.has(key), initial, enterMs)
      map.set(key, {
        key,
        element: child,
        status,
        previousStatus: null,
        initial: true,
        order: index,
      })
    })

    return map
  })

  const { timeoutsRef, addEntered, addExited, flushCallbackRef } = useTransitionTimeouts()
  // Memoize to avoid re-running `useLayoutEffect` on every render (all callbacks are stable)
  const callbacks = useMemo(
    () => ({ setElementRef, fireBeforeTransitionEnd, fireCallbacks }),
    // oxlint-disable-next-line react-hooks/exhaustive-deps -- all callbacks are stable (useCallback with [])
    [],
  )

  // Set up the flush callback to handle batched transition ends
  flushCallbackRef.current = (entered: Set<Key>, exited: Set<Key>) => {
    callbacks.fireBeforeTransitionEnd(entered, exited)
    setItemsMap((current) => {
      const copy = new Map(current)
      exited.forEach((key) => {
        const item = copy.get(key)
        if (item) copy.set(key, { ...item, status: 'hidden', previousStatus: item.status })
      })
      entered.forEach((key) => {
        const item = copy.get(key)
        if (item) copy.set(key, { ...item, status: 'visible', previousStatus: item.status })
      })
      return copy
    })
  }

  useInitialTransitions(
    initial,
    enterMs,
    () => {
      const keys = new Set<Key>()
      itemsMap.forEach((item) => {
        if (item.status === 'entering' && !timeoutsRef.current.has(item.key)) {
          keys.add(item.key)
        }
      })
      return keys
    },
    timeoutsRef,
    callbacks,
    (key) =>
      setItemsMap((curr) => {
        const copy = new Map(curr)
        const item = copy.get(key)
        if (item?.status === 'entering') {
          copy.set(key, { ...item, status: 'visible', previousStatus: item.status })
        }
        return copy
      }),
  )

  // Sync items with children and handle visibility transitions
  useLayoutEffect(() => {
    const { map: presentByKey } = buildChildMap(children, getChildKeyRef.current)
    const visibleSet = normalizeVisibleKey(visibleKey)

    setItemsMap((prevMap) => {
      const nextMap = new Map(prevMap)
      const keysToScheduleExitEnd = new Set<Key>()
      const keysToScheduleEnterEnd = new Set<Key>()
      const keysEntering = new Set<Key>()
      const keysExiting = new Set<Key>()

      // Build map of items that are currently transitioning (before this update)
      const previouslyTransitioning = new Map<Key, 'entering' | 'exiting'>()
      prevMap.forEach((item) => {
        if (item.status === 'entering' || item.status === 'exiting') {
          previouslyTransitioning.set(item.key, item.status)
        }
      })

      // 1) Update / add all present keys
      presentByKey.forEach(({ element, index }, key) => {
        const existing = nextMap.get(key)
        if (existing) {
          nextMap.set(key, { ...existing, element, order: index })
        } else {
          const isVisible = visibleSet.has(key)
          const status = getInitialStatus(isVisible, false, enterMs)
          nextMap.set(key, {
            key,
            element,
            status,
            previousStatus: null,
            initial: false,
            order: index,
          })
          // Track new entering items (for callbacks, even if instant)
          if (isVisible) {
            keysEntering.add(key)
            if (status === 'entering') {
              keysToScheduleEnterEnd.add(key)
            }
          }
        }
      })

      // 2) Remove keys that no longer appear in children
      nextMap.forEach((_item, key) => {
        if (!presentByKey.has(key)) nextMap.delete(key)
      })

      // 3) Handle visibility transitions for existing items
      nextMap.forEach((item, key) => {
        // Skip newly added items (already handled above)
        if (!prevMap.has(key)) return

        const shouldBeVisible = visibleSet.has(key)

        if (shouldBeVisible) {
          if (item.status === 'exiting' || item.status === 'hidden') {
            cancelTimeout(key, timeoutsRef)
            const status = enterMs > 0 ? 'entering' : 'visible'
            nextMap.set(key, { ...item, status, previousStatus: item.status, initial: false })
            keysEntering.add(key)
            if (status === 'entering') {
              keysToScheduleEnterEnd.add(key)
            }
          } else if (item.status === 'entering' && enterMs <= 0) {
            nextMap.set(key, { ...item, status: 'visible', previousStatus: item.status })
          }
        } else if (item.status === 'visible' || item.status === 'entering') {
          cancelTimeout(key, timeoutsRef)
          const status = exitMs > 0 ? 'exiting' : 'hidden'
          nextMap.set(key, { ...item, status, previousStatus: item.status })
          keysExiting.add(key)
          if (status === 'exiting') {
            keysToScheduleExitEnd.add(key)
          }
        } else if (item.status === 'exiting' && exitMs <= 0) {
          nextMap.set(key, { ...item, status: 'hidden', previousStatus: item.status })
        }
      })

      // Fire callbacks (handles both timed and instant transitions)
      callbacks.fireCallbacks(
        visibleKey,
        enterMs,
        exitMs,
        keysEntering,
        keysExiting,
        previouslyTransitioning,
      )

      // Schedule async transitions (after state update completes)
      scheduleTransitionTimeouts(
        enterMs,
        exitMs,
        keysToScheduleEnterEnd,
        keysToScheduleExitEnd,
        timeoutsRef,
        addEntered,
        addExited,
      )

      return nextMap
    })
  }, [children, visibleKey, enterMs, exitMs, timeoutsRef, addEntered, addExited, callbacks])

  const items = Array.from(itemsMap.values()).sort((a, b) => a.order - b.order)

  // Clear `previousStatus` after the browser has painted the new styles
  useAfterPaint(
    items.some((item) => item.previousStatus !== null),
    () => {
      flushSync(() => {
        setItemsMap((current) => {
          const copy = new Map(current)
          copy.forEach((item, key) => {
            if (item.previousStatus !== null) {
              copy.set(key, { ...item, previousStatus: null })
            }
          })
          return copy
        })
      })
    },
  )

  return (
    <PresenceWrapper render={render} items={items}>
      {items.map((item) => (
        <PresenceItemProvider key={item.key} status={item.status}>
          <Render
            ref={(el: HTMLElement | null) => callbacks.setElementRef(item.key, el)}
            render={renderChild}
            hidden={item.status === 'hidden'}
            inert={item.status === 'exiting'}
            data-key={item.key}
            data-status={item.status}
            data-starting-style={item.previousStatus !== null || undefined}
            data-initial={item.initial || undefined}
            state={{
              key: item.key,
              status: item.status,
              previousStatus: item.previousStatus,
              initial: item.initial,
            }}
          >
            {item.element}
          </Render>
        </PresenceItemProvider>
      ))}
    </PresenceWrapper>
  )
}

/* -------------------------------------------------------------------------- */
/*                       Helper components and utilities                      */
/* -------------------------------------------------------------------------- */

/** Renders a wrapper with computed state if the `render` prop is passed. */
function PresenceWrapper({
  render,
  items,
  children,
}: {
  render: BasePresenceProps['render']
  items: Item[]
  children: ReactNode
}) {
  if (!render) {
    return children
  }

  return (
    <Render
      render={render}
      state={{
        transitioning: items.some(
          (item) => item.status === 'entering' || item.status === 'exiting',
        ),
        hasPresent: items.some((item) => item.status === 'entering' || item.status === 'visible'),
        items,
      }}
    >
      {children}
    </Render>
  )
}

/**
 * Provider component that wraps each `Presence` item to provide presence context to descendants.
 * Computes `present` by checking if this item and all ancestor items are present.
 */
function PresenceItemProvider({ status, children }: { status: Status; children: ReactNode }) {
  const parentContext = useContext(PresenceContext)

  // Current item is present if it's entering or visible
  const isPresent = status === 'entering' || status === 'visible'

  // Overall present state is true only if this item AND all ancestors are present
  const present = isPresent && (parentContext?.present ?? true)

  return <PresenceContext value={{ status, present }}>{children}</PresenceContext>
}

/**
 * Get a stable key for our internal arrays.
 *
 * - If a custom `getChildKey` function is provided, use it.
 * - Otherwise, use the element's `key` prop (with React's internal prefixes stripped).
 * - Fall back to the index if no key is available.
 */
function getStableKey(child: ReactNode, index: number, getChildKey?: GetChildKey): Key {
  // Use custom key function if provided and if it returns a value for this child
  if (getChildKey) {
    const customKey = getChildKey(child, index)
    if (customKey !== undefined) {
      return customKey
    }
  }

  // For valid React elements, use their `key` prop
  if (isValidElement(child)) {
    if (child.key !== null) {
      // Undo prefix added by `Children.toArray` for explicit keys: ".$foo" → "foo"
      if (typeof child.key === 'string' && child.key.startsWith('.$')) {
        const slicedKey = child.key.slice(2)
        if (/^-?\d+(\.\d+)?$/.test(slicedKey)) {
          return Number(slicedKey)
        }
        return slicedKey
      }
      return child.key
    }
  }

  // Fallback to index for anything else
  return index
}

/** Build a map of child keys to their elements and indices. */
function buildChildMap(children: ReactNode, getChildKey?: GetChildKey) {
  const array = Children.toArray(children)
  const map = new Map<Key, { element: ReactNode; index: number }>()
  const keys: Key[] = []

  array.forEach((child, index) => {
    const key = getStableKey(child, index, getChildKey)
    keys.push(key)
    map.set(key, { element: child, index })
  })

  return { array, map, keys }
}

/** Cancel a pending timeout and remove it from the timeouts map. */
function cancelTimeout(key: Key, timeoutsRef: RefObject<Map<Key, number>>) {
  const timeoutId = timeoutsRef.current.get(key)
  if (timeoutId !== undefined) {
    window.clearTimeout(timeoutId)
    timeoutsRef.current.delete(key)
  }
}

/**
 * Hook that runs a callback after the browser has painted. Uses double `requestAnimationFrame`: the
 * first rAF schedules before the next paint, the second runs after that paint completes.
 */
function useAfterPaint(shouldRun: boolean, callback: () => void) {
  useLayoutEffect(() => {
    if (!shouldRun) return

    let outerFrameId: number
    let innerFrameId: number
    outerFrameId = requestAnimationFrame(() => {
      innerFrameId = requestAnimationFrame(() => {
        callback()
      })
    })
    return () => {
      cancelAnimationFrame(outerFrameId)
      cancelAnimationFrame(innerFrameId)
    }
  })
}

/** Determine initial status for an item based on visibility and transition settings. */
function getInitialStatus(
  isVisible: boolean,
  initial: boolean,
  enterMs: number,
): 'entering' | 'visible' | 'hidden' {
  if (!isVisible) return 'hidden'
  return initial && enterMs > 0 ? 'entering' : 'visible'
}

/**
 * Hook to manage transition timeouts with batching. Each key gets its own timeout (so cancelling
 * one doesn't affect others), but callbacks are batched together when timeouts fire in the same
 * event loop tick.
 */
function useTransitionTimeouts() {
  const timeoutsRef = useRef<Map<Key, number>>(new Map())
  const pendingEnteredRef = useRef<Set<Key>>(new Set())
  const pendingExitedRef = useRef<Set<Key>>(new Set())
  const flushScheduledRef = useRef(false)
  const flushCallbackRef = useRef<((entered: Set<Key>, exited: Set<Key>) => void) | null>(null)

  useEffect(() => {
    const timeouts = timeoutsRef.current
    return () => {
      for (const id of timeouts.values()) {
        window.clearTimeout(id)
      }
      timeouts.clear()
    }
  }, [])

  const flush = useCallback(() => {
    flushScheduledRef.current = false
    const pendingEntered = pendingEnteredRef.current
    const pendingExited = pendingExitedRef.current
    if (pendingEntered.size === 0 && pendingExited.size === 0) return

    const entered = new Set(pendingEntered)
    const exited = new Set(pendingExited)
    pendingEntered.clear()
    pendingExited.clear()

    flushCallbackRef.current?.(entered, exited)
  }, [])

  const scheduleFlush = useCallback(() => {
    if (flushScheduledRef.current) return
    flushScheduledRef.current = true
    // Use setTimeout(0) instead of queueMicrotask because each setTimeout callback is its own
    // macrotask. Microtasks run at the end of the CURRENT macrotask, so queueMicrotask would flush
    // before other setTimeout callbacks with the same delay have a chance to run.
    window.setTimeout(flush, 0)
  }, [flush])

  const addEntered = useCallback(
    (key: Key) => {
      timeoutsRef.current.delete(key)
      pendingEnteredRef.current.add(key)
      scheduleFlush()
    },
    [scheduleFlush],
  )

  const addExited = useCallback(
    (key: Key) => {
      timeoutsRef.current.delete(key)
      pendingExitedRef.current.add(key)
      scheduleFlush()
    },
    [scheduleFlush],
  )

  return { timeoutsRef, addEntered, addExited, flushCallbackRef }
}

/**
 * Schedule timeouts for entering/exiting transitions. Each key gets its own timeout to ensure
 * cancelling one key's transition doesn't affect others. Callbacks are batched when timeouts fire
 * in the same event loop tick.
 */
function scheduleTransitionTimeouts(
  enterMs: number,
  exitMs: number,
  keysToScheduleEnterEnd: Set<Key>,
  keysToScheduleExitEnd: Set<Key>,
  timeoutsRef: RefObject<Map<Key, number>>,
  addEntered: (key: Key) => void,
  addExited: (key: Key) => void,
) {
  if (exitMs > 0) {
    keysToScheduleExitEnd.forEach((key) => {
      if (timeoutsRef.current.has(key)) return
      const timeoutId = window.setTimeout(() => addExited(key), exitMs)
      timeoutsRef.current.set(key, timeoutId)
    })
  }
  if (enterMs > 0) {
    keysToScheduleEnterEnd.forEach((key) => {
      if (timeoutsRef.current.has(key)) return
      const timeoutId = window.setTimeout(() => addEntered(key), enterMs)
      timeoutsRef.current.set(key, timeoutId)
    })
  }
}

/** Hook to handle initial "entering" → "visible" transitions on mount (when `initial={true}`). */
function useInitialTransitions(
  initial: boolean,
  enterMs: number,
  getEnteringKeys: () => Set<Key>,
  timeoutsRef: RefObject<Map<Key, number>>,
  callbacks: ReturnType<typeof usePresenceCallbacks>,
  updateItemStatus: (key: Key) => void,
) {
  const initialCallbacksFiredRef = useRef(false)

  // Fire `onBeforeTransitionStart` and `onTransitionStart` for initial transitions
  useLayoutEffect(() => {
    if (!initial || enterMs <= 0 || initialCallbacksFiredRef.current) return

    const enteringKeys = getEnteringKeys()
    if (enteringKeys.size === 0) return

    initialCallbacksFiredRef.current = true
    // For initial transitions, elements already exist, so we can fire callbacks directly
    // Use `enterMs > 0` to ensure `fireCallbacks` treats this as a timed transition
    // No previously transitioning items on initial mount
    callbacks.fireCallbacks({}, enterMs, 0, enteringKeys, new Set(), new Map())
    // oxlint-disable-next-line react-hooks/exhaustive-deps -- only run on mount
  }, [])

  // Schedule timeouts for "entering" → "visible" transitions
  // This must be a separate effect without a guard because React Strict Mode clears timeouts
  // on cleanup, and they need to be re-scheduled on the second run.
  useEffect(() => {
    if (!initial || enterMs <= 0) return

    const enteringKeys = getEnteringKeys()
    if (enteringKeys.size === 0) return

    enteringKeys.forEach((key) => {
      const timeoutId = window.setTimeout(() => {
        callbacks.fireBeforeTransitionEnd(new Set([key]), new Set())
        updateItemStatus(key)
        timeoutsRef.current.delete(key)
      }, enterMs)
      timeoutsRef.current.set(key, timeoutId)
    })
    // oxlint-disable-next-line react-hooks/exhaustive-deps -- only run on mount
  }, [])
}

/**
 * Pending callbacks to be fired in `useLayoutEffect`. Using a single object makes the flow clearer
 * and avoids complex merging logic.
 */
interface PendingCallbacks {
  /** Keys for `onTransitionStart` (entering items) - fires in `useLayoutEffect` after DOM commit. */
  transitionStartEntering: Set<Key>
  /** Keys for `onTransitionStart` (exiting items) - fires in `useLayoutEffect` after DOM commit. */
  transitionStartExiting: Set<Key>
  /** Previously transitioning items for `onTransitionStart`. */
  transitionStartPreviously: Map<Key, 'entering' | 'exiting'>
  /**
   * Keys for `onBeforeTransitionEnd` (entered items) - fires in `useLayoutEffect` for instant
   * enters.
   */
  beforeEndEntered: Set<Key>
  /** Keys for `onTransitionEnd` (entered items) - fires in `useLayoutEffect` after DOM commit. */
  transitionEndEntered: Set<Key>
  /** Keys for `onTransitionEnd` (exited items) - fires in `useLayoutEffect` after DOM commit. */
  transitionEndExited: Set<Key>
}

function createEmptyPending(): PendingCallbacks {
  return {
    transitionStartEntering: new Set(),
    transitionStartExiting: new Set(),
    transitionStartPreviously: new Map(),
    beforeEndEntered: new Set(),
    transitionEndEntered: new Set(),
    transitionEndExited: new Set(),
  }
}

/** Hook to manage callback-related refs and helpers for `Presence` components. */
function usePresenceCallbacks(
  onBeforeTransitionStart: BasePresenceProps['onBeforeTransitionStart'],
  onTransitionStart: BasePresenceProps['onTransitionStart'],
  onBeforeTransitionEnd: BasePresenceProps['onBeforeTransitionEnd'],
  onTransitionEnd: BasePresenceProps['onTransitionEnd'],
) {
  // Store callbacks in refs to avoid re-creating `useCallback` functions
  const callbackRefs = useRef({
    onBeforeTransitionStart,
    onTransitionStart,
    onBeforeTransitionEnd,
    onTransitionEnd,
  })
  callbackRefs.current = {
    onBeforeTransitionStart,
    onTransitionStart,
    onBeforeTransitionEnd,
    onTransitionEnd,
  }

  const elementRefsRef = useRef<Map<Key, HTMLElement>>(new Map())
  const pendingRef = useRef<PendingCallbacks>(createEmptyPending())
  // Guard to prevent double-firing in React Strict Mode
  // Initialize with a unique symbol to avoid collision with any valid `children` value (including `null`)
  const guardRef = useRef<unknown>(Symbol('unset'))

  const setElementRef = useCallback((key: Key, element: HTMLElement | null) => {
    if (element) {
      elementRefsRef.current.set(key, element)
    } else {
      elementRefsRef.current.delete(key)
    }
  }, [])

  /** Resolves keys to elements from refs. */
  const resolveElements = useCallback((keys: Set<Key>) => {
    const map = new Map<Key, HTMLElement>()
    keys.forEach((key) => {
      const element = elementRefsRef.current.get(key)
      if (element) map.set(key, element)
    })
    return map
  }, [])

  /**
   * Fires `onBeforeTransitionEnd` callback and tracks keys for `onTransitionEnd`. Called from:
   *
   * - Render phase for instant exits (`exitMs <= 0`)
   * - `useLayoutEffect` for instant enters (`enterMs <= 0`)
   * - `flushCallbackRef.current` for timed transitions (via `useTransitionTimeouts` batching)
   */
  const fireBeforeTransitionEnd = useCallback(
    (entered: Set<Key>, exited: Set<Key>) => {
      if (entered.size === 0 && exited.size === 0) return

      // Track for the `onTransitionEnd` callback (will fire in `useLayoutEffect`)
      entered.forEach((key) => pendingRef.current.transitionEndEntered.add(key))
      exited.forEach((key) => pendingRef.current.transitionEndExited.add(key))

      // Fire the `onBeforeTransitionEnd` callback synchronously
      callbackRefs.current.onBeforeTransitionEnd?.({
        entered: resolveElements(entered),
        exited: resolveElements(exited),
      })
    },
    [resolveElements],
  )

  /**
   * Main entry point for firing callbacks during state updates. Handles both timed and instant
   * transitions:
   *
   * - `onBeforeTransitionStart`: Always fires synchronously (entering keys + exiting elements)
   * - `onTransitionStart` for timed: Tracks for `useLayoutEffect`.
   * - `onTransitionStart` for instant exits: Fires synchronously (element exists, will be unmounted)
   * - `onTransitionStart` for instant enters: Tracks for `useLayoutEffect` (element doesn't exist
   *   yet)
   * - `onBeforeTransitionEnd` for instant exits: Fires synchronously.
   * - `onBeforeTransitionEnd` for instant enters: Tracks for `useLayoutEffect`.
   */
  const fireCallbacks = useCallback(
    (
      guardValue: unknown,
      enterMs: number,
      exitMs: number,
      entering: Set<Key>,
      exiting: Set<Key>,
      previouslyTransitioning: Map<Key, 'entering' | 'exiting'>,
    ) => {
      // Guard against React Strict Mode double-firing
      if (guardRef.current === guardValue) return
      if (entering.size === 0 && exiting.size === 0) return
      guardRef.current = guardValue

      const pending = pendingRef.current
      const hasInstantExits = exitMs <= 0 && exiting.size > 0
      const hasInstantEnters = enterMs <= 0 && entering.size > 0

      // 1. Fire `onBeforeTransitionStart` synchronously (always)
      callbackRefs.current.onBeforeTransitionStart?.({
        entering,
        exiting: resolveElements(exiting),
        previouslyTransitioning,
      })

      // 2. Handle `onTransitionStart`
      // - All enters go to `useLayoutEffect` (element may not exist yet)
      // - Timed exits go to `useLayoutEffect`
      // - Instant exits fire synchronously (element exists, will be unmounted)
      entering.forEach((key) => pending.transitionStartEntering.add(key))
      if (exitMs > 0) {
        exiting.forEach((key) => pending.transitionStartExiting.add(key))
      }
      previouslyTransitioning.forEach((status, key) => {
        if (!pending.transitionStartPreviously.has(key)) {
          pending.transitionStartPreviously.set(key, status)
        }
      })

      if (hasInstantExits) {
        // Fire `onTransitionStart` synchronously for instant exits only
        callbackRefs.current.onTransitionStart?.({
          entering: new Map(),
          exiting: resolveElements(exiting),
          previouslyTransitioning,
        })
      }

      // 3. Handle `onBeforeTransitionEnd`
      // - Instant exits: fire synchronously (element exists)
      // - Instant enters: track for `useLayoutEffect` (element doesn't exist yet)
      if (hasInstantExits) {
        fireBeforeTransitionEnd(new Set(), exiting)
      }
      if (hasInstantEnters) {
        entering.forEach((key) => pending.beforeEndEntered.add(key))
      }
    },
    [resolveElements, fireBeforeTransitionEnd],
  )

  // Fire pending callbacks in `useLayoutEffect` (after DOM commit, before paint)
  useLayoutEffect(() => {
    const pending = pendingRef.current
    const hasStart =
      pending.transitionStartEntering.size > 0 || pending.transitionStartExiting.size > 0
    const hasBeforeEnd = pending.beforeEndEntered.size > 0
    const hasEnd = pending.transitionEndEntered.size > 0 || pending.transitionEndExited.size > 0

    if (!hasStart && !hasBeforeEnd && !hasEnd) return

    // Capture pending state before resetting
    const {
      transitionStartEntering,
      transitionStartExiting,
      transitionStartPreviously,
      beforeEndEntered,
      transitionEndEntered,
      transitionEndExited,
    } = pending
    pendingRef.current = createEmptyPending()

    // Fire `onTransitionStart`
    if (hasStart) {
      callbackRefs.current.onTransitionStart?.({
        entering: resolveElements(transitionStartEntering),
        exiting: resolveElements(transitionStartExiting),
        previouslyTransitioning: transitionStartPreviously,
      })
    }

    // Fire `onBeforeTransitionEnd` for instant enters (this also tracks for `onTransitionEnd`)
    if (hasBeforeEnd) {
      fireBeforeTransitionEnd(beforeEndEntered, new Set())
    }

    // Merge any new entries added by `fireBeforeTransitionEnd` above
    pendingRef.current.transitionEndEntered.forEach((key) => transitionEndEntered.add(key))
    pendingRef.current.transitionEndExited.forEach((key) => transitionEndExited.add(key))
    pendingRef.current.transitionEndEntered = new Set()
    pendingRef.current.transitionEndExited = new Set()

    // Fire `onTransitionEnd` (includes keys from both synchronous and `useLayoutEffect` paths)
    if (transitionEndEntered.size > 0 || transitionEndExited.size > 0) {
      callbackRefs.current.onTransitionEnd?.({
        entered: resolveElements(transitionEndEntered),
        exited: transitionEndExited,
      })
    }
  })

  return {
    setElementRef,
    fireBeforeTransitionEnd,
    fireCallbacks,
  }
}
