import { Children, isValidElement, useEffect, useLayoutEffect, useReducer, useRef, useState, type ReactElement, type ReactNode } from 'react';
import { cn } from '@/lib/utils';

/**
 * Small, dependency-free motion helpers used across the app.
 * Sections expand / collapse like an FAQ accordion (height + fade) and lists
 * animate reordering, all in ~180ms. Respects prefers-reduced-motion.
 */
export const MOTION_MS = 180;

const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;

type Phase = 'closed' | 'opening' | 'open' | 'closing';

/**
 * Expands its content in when `show` becomes true and collapses it out when false
 * (content stays mounted until the collapse finishes). Set `appear` to animate the first
 * mount too; leave it off for content that is already on screen when the page loads.
 */
export function Reveal({ show = true, appear = false, children, className, innerClassName, ...rest }: {
    show?: boolean; appear?: boolean; children: ReactNode; className?: string; innerClassName?: string;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>) {
    const [phase, setPhase] = useState<Phase>(show ? (appear && !reducedMotion() ? 'closed' : 'open') : 'closed');
    const [mounted, setMounted] = useState(show);
    const first = useRef(true);

    useEffect(() => {
        const isFirst = first.current;
        first.current = false;
        if (reducedMotion()) {
            setMounted(show);
            setPhase(show ? 'open' : 'closed');
            return;
        }
        if (show) {
            if (isFirst && !appear) return;
            setMounted(true);
            // Two frames so the collapsed state paints before transitioning open.
            let r2 = 0;
            const r1 = requestAnimationFrame(() => { r2 = requestAnimationFrame(() => setPhase('opening')); });
            const t = window.setTimeout(() => setPhase('open'), MOTION_MS + 40);
            return () => { cancelAnimationFrame(r1); cancelAnimationFrame(r2); window.clearTimeout(t); };
        }
        if (isFirst) return;
        setPhase('closing');
        const t = window.setTimeout(() => { setMounted(false); setPhase('closed'); }, MOTION_MS);
        return () => window.clearTimeout(t);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [show]);

    if (!mounted) return null;
    const expanded = phase === 'opening' || phase === 'open';

    return (
        <div
            {...rest}
            aria-hidden={phase === 'closing' || undefined}
            className={cn(
                'grid transition-[grid-template-rows,opacity] ease-out',
                expanded ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
                phase === 'closing' && 'pointer-events-none',
                className,
            )}
            style={{ transitionDuration: `${MOTION_MS}ms`, ...rest.style }}
        >
            {/* Clip only while animating so focus rings and popovers aren't cut off once open. */}
            <div className={cn('min-h-0', phase !== 'open' && 'overflow-hidden', innerClassName)}>{children}</div>
        </div>
    );
}

type Entry = { key: string; el: ReactElement; leaving: boolean };

/**
 * Renders keyed children. New children expand in, removed ones collapse out, and
 * reordered ones glide to their new position (FLIP). Items present on the first
 * render don't animate.
 */
export function AnimatedList({ children, className, itemClassName, ...rest }: {
    children: ReactNode; className?: string; itemClassName?: string;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>) {
    const [, rerender] = useReducer((n: number) => n + 1, 0);
    const container = useRef<HTMLDivElement>(null);
    const seen = useRef<Set<string> | null>(null);
    const leaving = useRef(new Map<string, { el: ReactElement; index: number }>());
    const last = useRef<Entry[]>([]);
    const tops = useRef(new Map<string, number>());
    const lastOrder = useRef('');

    const current = Children.toArray(children).filter(isValidElement) as ReactElement[];
    const keys = current.map((c) => String(c.key));
    const keySet = new Set(keys);
    const initial = seen.current === null;
    if (initial) seen.current = new Set(keys);

    // Anything rendered last time but gone now starts leaving from where it was.
    last.current.forEach((e, index) => {
        if (!e.leaving && !keySet.has(e.key) && !leaving.current.has(e.key)) leaving.current.set(e.key, { el: e.el, index });
    });
    keys.forEach((k) => leaving.current.delete(k)); // re-added before it finished leaving

    const merged: Entry[] = current.map((el, i) => ({ key: keys[i], el, leaving: false }));
    [...leaving.current.entries()].sort((a, b) => a[1].index - b[1].index)
        .forEach(([key, { el, index }]) => merged.splice(Math.min(index, merged.length), 0, { key, el, leaving: true }));
    last.current = merged;

    useEffect(() => {
        const timers = [...leaving.current.keys()].map((k) => window.setTimeout(() => {
            if (leaving.current.delete(k)) rerender();
        }, MOTION_MS));
        return () => timers.forEach(window.clearTimeout);
    });

    // FLIP: only when the order of existing items changed (not on every keystroke).
    useLayoutEffect(() => {
        const box = container.current;
        if (!box) return;
        const order = keys.join('|');
        // Compare only items present both times, so adds/removes alone don't count as a reorder.
        const before = lastOrder.current.split('|').filter((k) => keySet.has(k)).join('|');
        const after = keys.filter((k) => lastOrder.current.split('|').includes(k)).join('|');
        const reordered = lastOrder.current !== '' && before !== after;
        const nodes = [...box.children] as HTMLElement[];
        const next = new Map<string, number>();
        nodes.forEach((n) => { const k = n.dataset.itemKey; if (k) next.set(k, n.offsetTop); });
        if (reordered && !reducedMotion()) {
            nodes.forEach((n) => {
                const k = n.dataset.itemKey;
                if (!k || !tops.current.has(k) || !next.has(k)) return;
                const dy = tops.current.get(k)! - next.get(k)!;
                if (!dy) return;
                n.style.transition = 'none';
                n.style.transform = `translateY(${dy}px)`;
                void n.offsetHeight;
                n.style.transition = `transform ${MOTION_MS}ms ease-out`;
                n.style.transform = '';
                window.setTimeout(() => { n.style.transition = ''; }, MOTION_MS + 20);
            });
        }
        tops.current = next;
        lastOrder.current = order;
    });

    const fresh = new Set(initial ? [] : keys.filter((k) => !seen.current!.has(k)));
    keys.forEach((k) => seen.current!.add(k));

    return (
        <div ref={container} className={cn('relative', className)} {...rest}>
            {merged.map((e) => (
                <Reveal key={e.key} data-item-key={e.key} show={!e.leaving} appear={fresh.has(e.key)} className={itemClassName}>
                    {e.el}
                </Reveal>
            ))}
        </div>
    );
}
