import { useEffect, useImperativeHandle, useRef } from 'react';

import type { WithSpacingProps } from '~/src/lib/hocs/withSpacing';
import type { CSSProperties, FC, HTMLAttributes, RefObject } from 'react';

import withSpacing from '~/src/lib/hocs/withSpacing';
import { caf, raf } from '~/src/lib/utils/raf';
import { stripNonDomProps } from '~/src/lib/utils/stripNonDomProps';
import wait from '~/src/lib/utils/wait';

interface TransitionInOut2PropsInternal extends HTMLAttributes<HTMLDivElement> {
  tag?: string;
  isVisibleInitial?: boolean;
  transitionOnMount?: boolean;
  duration?: number;
  delay?: number;
  styleFrom?: CSSProperties;
  style?: CSSProperties;
  apiRef?: RefObject<TransitionInOut2Api | null>;
  nodeRef?: RefObject<HTMLDivElement | null>;
  role?: string;
}

interface TransitionInOut2Props
  extends WithSpacingProps,
    TransitionInOut2PropsInternal {}

export interface TransitionInOut2Api {
  setVisible: (
    value: boolean,
    options?: { duration?: number }
  ) => Promise<void>;
}

/**
 * The successor to <TransitionInOut> exposing an imperative API
 * giving the caller more control over timing. Performance is also
 * better as toggling visibility doesn't trigger any setState()
 * which causes v-dom reconciliation (expensive).
 *
 * It's less 'reacty' but avoids race conditions encountered using
 * a declarative API. We can just call `await api.setVisible(false)`
 * and know the transition has finished once the promise resolves.
 */
const TransitionInOut2 = withSpacing<TransitionInOut2PropsInternal>(
  ({
    isVisibleInitial = true,
    transitionOnMount = false,
    duration = 400,
    delay = 0,
    className,
    children,
    styleFrom,
    apiRef,
    nodeRef,
    style,
    ...divProps
  }) => {
    const defaultNodeRef = useRef<HTMLDivElement>(null);
    const isVisibleRef = useRef(transitionOnMount ? false : isVisibleInitial);
    const animationFrameRef = useRef<number>(null);

    nodeRef = nodeRef || defaultNodeRef;

    const styleFromInternal = {
      opacity: 0,
      ...styleFrom,
    };

    const styleToInternal = {
      opacity: 1,
      transform: 'none',
    };

    const resolveStyle = () =>
      isVisibleRef.current ? styleToInternal : styleFromInternal;

    /**
     * Main interface to toggle visibility of root el
     */
    const setVisible: TransitionInOut2Api['setVisible'] = async (
      value,
      options = {}
    ) => {
      if (value === isVisibleRef.current) return;

      isVisibleRef.current = value;

      const node = nodeRef?.current;
      if (!node) return;

      const style: CSSProperties = resolveStyle();

      if (options.duration !== undefined) {
        style.transitionDuration = `${options.duration}ms`;
      }

      animationFrameRef.current = raf(() => {
        Object.keys(style).forEach((key) => {
          node.style[key] = style[key];
        });
      });

      // this could be improved to listen for transitionend event,
      // but we haven't noticed visual glitches as of yet
      await wait(options.duration || duration);
    };

    useImperativeHandle(
      apiRef,
      (): TransitionInOut2Api => ({
        setVisible,
      })
    );

    useEffect(() => {
      if (transitionOnMount) {
        animationFrameRef.current = raf(() => {
          setVisible(true);
        });
      }

      // on unmount cleanup any pending rafs
      return () => {
        caf(animationFrameRef.current ?? undefined);
      };
    }, []);

    const styleInternal = {
      ...style,
      transitionProperty: 'opacity,transform',
      transitionDuration: `${duration}ms`,
      transitionDelay: `${delay}ms`,
      ...resolveStyle(),
    };

    return (
      <div
        {...stripNonDomProps(divProps)}
        ref={nodeRef}
        className={className}
        style={styleInternal}
      >
        {children}
      </div>
    );
  }
);

export default TransitionInOut2 as FC<TransitionInOut2Props>;
