import {
  Children,
  type Key,
  type ReactElement,
  type ReactNode,
  cloneElement,
  isValidElement,
  useEffect,
  useRef,
  useState,
} from "react";
import { View } from "../primitives/View";

export interface PresenceBaseProps {
  /**
   * Identity of the current child. When it changes, the previous child is kept
   * mounted (with `exitClassName`) for `exitDurationMs` so it can animate out
   * while the new child animates in — an AnimatePresence-style swap done with
   * pure CSS animations, no animation library.
   */
  activeKey: Key;
  /** How long to keep the exiting child mounted — match the exit animation. */
  exitDurationMs: number;
  /** Animation class applied to the entering (current) child. */
  enterClassName?: string;
  /** Animation class applied to the exiting (previous) child. */
  exitClassName?: string;
  /** Class applied to every item (e.g. `absolute inset-0` to overlap). */
  className?: string;
}

interface Snapshot {
  key: Key;
  node: ReactNode;
}

function joinClasses(...classes: (string | undefined)[]): string {
  return classes.filter(Boolean).join(" ");
}

/**
 * Shared keyed-swap logic. Keeps the previously rendered child as a snapshot
 * after `activeKey` changes so an exit animation can play, then removes it once
 * `exitDurationMs` has elapsed. The entering child is keyed by `activeKey`, so
 * it remounts on each change and replays its enter animation.
 *
 * Assumes one child whose content is derived from `activeKey` (the common
 * keyed-swap case). Updates to the active child between key changes are shown
 * live but not snapshotted for the next exit.
 */
function usePresence(
  activeKey: Key,
  exitDurationMs: number,
  children: ReactNode,
): Snapshot[] {
  const [exiting, setExiting] = useState<Snapshot[]>([]);
  const previousRef = useRef<Snapshot>({ key: activeKey, node: children });
  const childrenRef = useRef<ReactNode>(children);
  childrenRef.current = children;
  const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);

  useEffect(
    () => () => {
      timersRef.current.forEach(clearTimeout);
    },
    [],
  );

  useEffect(() => {
    const previous = previousRef.current;
    if (previous.key === activeKey) {
      return;
    }
    previousRef.current = { key: activeKey, node: childrenRef.current };
    setExiting((list) => [...list, previous]);
    const timer = setTimeout(() => {
      setExiting((list) => list.filter((item) => item !== previous));
      timersRef.current = timersRef.current.filter((t) => t !== timer);
    }, exitDurationMs);
    timersRef.current.push(timer);
  }, [activeKey, exitDurationMs]);

  return exiting;
}

interface PresenceItem {
  key: Key;
  node: ReactNode;
}

function toItems(children: ReactNode): PresenceItem[] {
  return Children.toArray(children)
    .filter(isValidElement)
    .map((child) => ({ key: child.key as Key, node: child }));
}

/**
 * Order-preserving merge of the previously rendered key order with the current
 * live keys (the react-transition-group algorithm). Removed keys stay in their
 * old positions, so an exiting item animates out in place instead of jumping to
 * the end of the list.
 */
function mergeKeys(previous: Key[], next: Key[]): Key[] {
  const nextSet = new Set(next);
  const pendingByNext = new Map<Key, Key[]>();
  let pending: Key[] = [];

  for (const key of previous) {
    if (nextSet.has(key)) {
      if (pending.length > 0) {
        pendingByNext.set(key, pending);
        pending = [];
      }
    } else {
      pending.push(key);
    }
  }

  const result: Key[] = [];
  for (const key of next) {
    const before = pendingByNext.get(key);
    if (before) {
      result.push(...before);
    }
    result.push(key);
  }
  result.push(...pending);
  return result;
}

interface RenderedItem extends PresenceItem {
  exiting: boolean;
}

/**
 * Diffs a list of keyed children across renders. New keys are returned with
 * `exiting: false` (mounted fresh, so their enter animation plays); removed keys
 * are kept with `exiting: true` for `exitDurationMs` so they can animate out,
 * then dropped. Order is preserved via {@link mergeKeys}.
 */
function usePresenceList(
  children: ReactNode,
  exitDurationMs: number,
): RenderedItem[] {
  const items = toItems(children);
  const liveKeys = items.map((item) => item.key);
  const signature = liveKeys.join(" ");

  const nodesRef = useRef<Map<Key, ReactNode>>(new Map());
  for (const item of items) {
    nodesRef.current.set(item.key, item.node);
  }
  const liveKeysRef = useRef(liveKeys);
  liveKeysRef.current = liveKeys;

  const [order, setOrder] = useState<Key[]>(liveKeys);
  const orderRef = useRef(order);
  orderRef.current = order;
  const timersRef = useRef<Map<Key, ReturnType<typeof setTimeout>>>(new Map());

  useEffect(
    () => () => {
      timersRef.current.forEach(clearTimeout);
    },
    [],
  );

  useEffect(() => {
    const live = new Set(liveKeysRef.current);
    const newOrder = mergeKeys(orderRef.current, liveKeysRef.current);

    // A key that came back before its timer fired cancels its pending exit.
    for (const key of liveKeysRef.current) {
      const timer = timersRef.current.get(key);
      if (timer) {
        clearTimeout(timer);
        timersRef.current.delete(key);
      }
    }
    // A key that's gone animates out, then is dropped once the timer fires.
    for (const key of newOrder) {
      if (!live.has(key) && !timersRef.current.has(key)) {
        const timer = setTimeout(() => {
          timersRef.current.delete(key);
          nodesRef.current.delete(key);
          setOrder((current) => current.filter((k) => k !== key));
        }, exitDurationMs);
        timersRef.current.set(key, timer);
      }
    }
    setOrder(newOrder);
  }, [signature, exitDurationMs]);

  const live = new Set(liveKeys);
  return order.map((key) => ({
    key,
    node: nodesRef.current.get(key),
    exiting: !live.has(key),
  }));
}

export interface PresenceListProps {
  /** How long to keep a removed child mounted — match the exit animation. */
  exitDurationMs: number;
  /** Animation class applied to entering children. */
  enterClassName?: string;
  /** Animation class applied to exiting children. */
  exitClassName?: string;
  /** Class applied to every item's wrapper `<View>`. */
  className?: string;
  /**
   * A list of keyed elements — each child must have a stable `key`. Adding a key
   * animates that item in while the others stay put; removing a key animates only
   * that item out.
   */
  children: ReactNode;
}

/**
 * Keyed-list presence (AnimatePresence-style). Renders a list of keyed children,
 * each wrapped in its own `<View>`, and animates individual add/remove: added
 * keys mount with `enterClassName`, removed keys stay mounted with `exitClassName`
 * for `exitDurationMs` before unmounting. For swapping a single child, use
 * {@link PresenceOne}.
 */
export function PresenceList({
  exitDurationMs,
  enterClassName,
  exitClassName,
  className,
  children,
}: PresenceListProps): ReactNode {
  const items = usePresenceList(children, exitDurationMs);

  return (
    <>
      {items.map((item) => (
        <View
          key={item.key}
          className={joinClasses(
            className,
            item.exiting ? exitClassName : enterClassName,
          )}
        >
          {item.node}
        </View>
      ))}
    </>
  );
}

type StyledElement = ReactElement<{ className?: string }>;

export interface PresenceOneProps extends PresenceBaseProps {
  /**
   * A single element that accepts and forwards `className` to its root view.
   * The `className` + enter/exit animation classes are merged onto it directly,
   * so no extra wrapper `<View>` is added to the tree.
   */
  children: StyledElement;
}

/**
 * Keyed-swap presence that merges the animation classes onto the child element
 * itself via `cloneElement` — no wrapper `<View>`. Requires a single element
 * that forwards `className`; for anything else use {@link PresenceList}.
 */
export function PresenceOne({
  activeKey,
  exitDurationMs,
  enterClassName,
  exitClassName,
  className,
  children,
}: PresenceOneProps): ReactNode {
  const exiting = usePresence(activeKey, exitDurationMs, children);

  return (
    <>
      {exiting.map((item) => {
        const node = item.node as StyledElement;
        return cloneElement(node, {
          key: item.key,
          className: joinClasses(
            node.props.className,
            className,
            exitClassName,
          ),
        });
      })}
      {cloneElement(children, {
        key: activeKey,
        className: joinClasses(
          children.props.className,
          className,
          enterClassName,
        ),
      })}
    </>
  );
}
