import {
  createContext,
  memo,
  useContext,
  useEffect,
  useMemo,
  useRef,
} from 'react';

import type { BoxProps } from '~/src/components/Box';
import type { WithSpacingProps } from '~/src/lib/hocs/withSpacing';
import type { CSSProperties, ReactNode, UIEvent } from 'react';

import Box from '~/src/components/Box';
import useIsUnmounted from '~/src/hooks/useIsUnmounted';
import { toRgba } from '~/src/lib/utils/color';
import debounce from '~/src/lib/utils/debounce';
import throttledRaf from '~/src/lib/utils/throttledRaf';
import FadeOnMount from '../FadeOnMount';
import Gradient from '../Gradient';
import useIsOverflowing from '../IsOverflowing/useIsOverflowing';
import Sticky from '../Sticky';

const scrollPositionCache: Record<string, number> = {};

export interface Scroller2Props extends WithSpacingProps {
  onScroll?: (param: { y: number; maxY: number }) => void;
  onScrollStart?: () => void;
  onMount?: (params: { scrollerEl: HTMLDivElement }) => void;
  contentStyle?: CSSProperties;
  component?: string;

  renderBackground?: () => ReactNode;
  renderBefore?: () => ReactNode;
  renderAfter?: () => ReactNode;

  /**
   * Render a custom content container. Handy when you need
   * to be able to customize the styling.
   */
  renderContent?: (params: {
    isOverflowing: boolean;

    contentContainerProps: {
      children: ReactNode;
      nodeRef: BoxProps['nodeRef'];
      style?: CSSProperties;
    };
  }) => ReactNode;

  withScrollBar?: boolean;
  withSmoothScrolling?: boolean;

  withBeforeContentAfterMain?: boolean;

  /**
   * Adds a bottom overflow gradient when there's more
   * content to scroll down to.
   */
  withOverflowGradients?: boolean;
  gradientColor?: string;
  /**
   * Alpha applied to the bottom overflow gradient's terminal stop.
   * Defaults to 0.7 to preserve historical look.
   */
  gradientAlpha?: number;

  /**
   * Experimental and not always visually suitable.
   */
  withTopOverflowGradient?: boolean;

  /**
   * Remember the last scroll position and restore it when a <Scroller>
   * with the same `rememberPositionKey` mounts again. This is
   * particularly handy when a user might be jumping back
   * and forward between stages/views in a dialog.
   */
  rememberPositionKey?: string;
  testId?: string;
  children?: ReactNode;
}

interface ScrollerContextValue {
  getScrollTop: () => number | undefined;
  setScrollTop: (y: number) => void;
  getScrollHeight: () => number;
  scrollTo: (y: number, options?: { smooth?: boolean }) => void;
}

const ScrollerContext = createContext<ScrollerContextValue | null>(null);

const Scroller2 = memo<Scroller2Props>(
  ({
    renderBefore,
    renderAfter,
    renderBackground,
    renderContent,
    children,
    contentStyle,
    onScroll,
    onScrollStart,
    onMount,
    maxHeight,
    fullHeight = true,
    // default to hiding scrollbars as they're
    // ugly and take up space on ms windows
    withScrollBar = false,
    withOverflowGradients = false,
    withTopOverflowGradient = false,
    withBeforeContentAfterMain = false,
    withSmoothScrolling = false,
    rememberPositionKey,
    gradientColor = '#000',
    gradientAlpha = 0.7,

    ...withSpacingProps
  }) => {
    const scrollerRef = useRef<HTMLDivElement>(null);
    const gradientBottomRef = useRef<HTMLDivElement>(null);
    const gradientTopRef = useRef<HTMLDivElement>(null);
    const contentRef = useRef<HTMLDivElement>(null);
    const isScrollingRef = useRef(false);
    const isUnmounted = useIsUnmounted();

    const isOverflowing = useIsOverflowing({
      rootRef: scrollerRef,
      targetRef: contentRef,
      fallbackValue: false,
      skip: !withOverflowGradients,
    });

    const debouncedSetIsScrolling = useMemo(
      () =>
        debounce((isScrolling: boolean) => {
          isScrollingRef.current = isScrolling;
        }, 100),
      []
    );

    const onScrollInternal = throttledRaf(
      ({ target }: UIEvent<HTMLDivElement>) => {
        if (!(target instanceof HTMLDivElement)) return;

        const y = target.scrollTop;
        const maxY = target.scrollHeight - target.clientHeight;
        const isAtEnd = y > maxY - 4;
        const isAtStart = y < 4;

        if (withOverflowGradients) {
          const el1 = gradientTopRef.current;
          if (el1) el1.style.opacity = isAtStart ? '0' : '1';

          const el2 = gradientBottomRef.current;
          if (el2) el2.style.opacity = isAtEnd ? '0' : '1';
        }

        // update the cached scroll position
        if (rememberPositionKey) {
          scrollPositionCache[rememberPositionKey] = y;
        }

        if (!isScrollingRef.current) {
          isScrollingRef.current = true;
          onScrollStart?.();
        }

        const unmounted = isUnmounted();

        // don't call callback if component has unmounted and onScroll is 'zombie'
        if (!unmounted) {
          onScroll?.({
            y,
            maxY,
          });
        }

        debouncedSetIsScrolling(false);
      }
    );

    // if there's a previous scroll position cached
    useEffect(() => {
      if (!scrollerRef.current) return;

      onMount?.({ scrollerEl: scrollerRef.current });

      if (rememberPositionKey) {
        const prevY = scrollPositionCache[rememberPositionKey];
        scrollerRef.current.scrollTop = prevY;
      }
    }, []);

    const beforeContent = (renderBefore || withTopOverflowGradient) && (
      <Box
        positionAbsolute
        left={0}
        right={0}
        top={0}
        pointerEvents="none"
        zIndex={1}
      >
        {renderBefore && renderBefore()}
        {withTopOverflowGradient && isOverflowing && (
          <Gradient
            nodeRef={gradientTopRef}
            from="rgba(0,0,0,0.3)"
            to="transparent"
            height="0.7rem"
            style={{
              opacity: 0,
            }}
          />
        )}
      </Box>
    );

    const contextValue = useMemo<ScrollerContextValue>(
      (): ScrollerContextValue => ({
        getScrollTop: () => scrollerRef.current?.scrollTop,

        setScrollTop: (y: number) => {
          if (!scrollerRef.current) return;
          scrollerRef.current.scrollTop = y;
        },

        getScrollHeight: () => scrollerRef.current!.scrollHeight,

        scrollTo: (y, { smooth = true } = {}) => {
          scrollerRef.current?.scrollTo({
            top: y,
            behavior: smooth ? 'smooth' : undefined,
          });
        },
      }),
      []
    );

    return (
      <ScrollerContext.Provider value={contextValue}>
        <Box {...withSpacingProps} positionRelative fullHeight={fullHeight}>
          {renderBackground && (
            <Box coverParent zIndex={0}>
              {renderBackground()}
            </Box>
          )}
          {!withBeforeContentAfterMain && beforeContent}
          <div
            ref={scrollerRef}
            onScroll={onScrollInternal}
            className="_scroller"
            data-testid="scroller"
            style={{
              position: 'relative',
              zIndex: 0,
              height: '100%',
              contain: fullHeight ? 'strict' : undefined,
              overflowY: 'auto',
              overflowX: 'hidden',
              WebkitOverflowScrolling: 'touch',
              overscrollBehavior: 'none',
              maxHeight,

              // When page navigates to an element `id` using a hash fragment
              // the scrolling will be 'smooth', instead of jumping instantly.
              scrollBehavior: withSmoothScrolling ? 'smooth' : undefined,
            }}
          >
            {renderContent ? (
              renderContent({
                isOverflowing,

                contentContainerProps: {
                  nodeRef: contentRef,
                  children,
                  style: contentStyle,
                },
              })
            ) : (
              <Box style={contentStyle} nodeRef={contentRef} minHeight="100%">
                {children}
              </Box>
            )}
            {withOverflowGradients && isOverflowing && (
              // We render the bottom overflow gradient as sticky so that if we're rendering sticky
              // element in the content area we can control the z-index so that they can float above
              // the gradient. We're using a negative margin to ensure the gradient doesn't take up
              // any space in the content layout.
              <Sticky
                bottom={0}
                style={{ marginTop: '-4rem', pointerEvents: 'none' }}
                zIndex={5}
              >
                <FadeOnMount duration={1000}>
                  {/* extra div required to avoid fade-in styles conflicting with nodeRef.style */}
                  <div data-testid="overflowGradient">
                    <Gradient
                      nodeRef={gradientBottomRef}
                      from="transparent"
                      to={toRgba(gradientColor, gradientAlpha)}
                      height="4rem"
                    />
                  </div>
                </FadeOnMount>
              </Sticky>
            )}
          </div>
          {withBeforeContentAfterMain && beforeContent}
          {renderAfter?.()}
          {/* hide awful scrollbars on chrome windows */}
          <style jsx>{`
            ._scroller::-webkit-scrollbar {
              ${!withScrollBar ? 'display: none;' : ''}
            }
          `}</style>
        </Box>
      </ScrollerContext.Provider>
    );
  }
);

export const useScroller = () => useContext(ScrollerContext);

export default Scroller2;
