import { memo, useCallback, useRef } from 'react';
import classNames from 'classnames';

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

import Gradient from '~/src/components//Gradient';
import Box from '~/src/components/Box';
import FadeOnMount from '~/src/components/FadeOnMount';
import useIsOverflowing from '~/src/components/IsOverflowing/useIsOverflowing';
import useDebounce from '~/src/hooks/useDebounce';
import useIsLargeScreen from '~/src/hooks/useIsLargeScreen';
import { toRgba } from '~/src/lib/utils/color';
import throttledRaf from '~/src/lib/utils/throttledRaf';
import ChevronIcon from '../../Icon/ChevronIcon';

export const SCROLLER_ARROW_CLASS = 'scrollerArrow';
const NOOP = () => null;

export interface HorizontalScrollerProps extends WithSpacingProps {
  onScroll?: (param: { x: number; maxX: number }) => void;
  onScrollEnd?: (param: { x: number; maxX: number }) => void;
  className?: string;
  style?: CSSProperties;
  scrollerStyle?: CSSProperties;
  contentStyle?: CSSProperties;
  component?: string;
  renderAfter?: (params: { isOverflowing: boolean }) => ReactNode;
  withSnapping?: boolean;
  forceOverflowing?: boolean;

  /**
   * Render the content that goes inside the scroller.
   *
   * This is rendered inside a horizontal flexbox by default.
   * To override this you can use `contentStyle` or instead render
   * your own content container using `render` instead.
   */
  renderContent?: (params: {
    isOverflowing: boolean;
    snapItemStyle: CSSProperties;
  }) => ReactNode;

  /**
   * Render content *and* custom content container.
   *
   * For when you need full control over the dom structure.
   * We use this in SortableHorizontalScroller to control the
   * layout when dragging/sorting.
   */
  render?: (params: {
    ref: RefObject<HTMLDivElement | null>;
    isOverflowing: boolean;
  }) => ReactNode;

  withScrollBar?: boolean;

  /**
   * Set the style of the overflow. Arrows can sit inside
   * or outside the scroller.
   *
   * 'outside' style is only applied for large-screen as horizontal
   * scrollers are always flush to the viewport on small-screen.
   *
   * @default undefined
   */
  overflowStyle?: 'inside' | 'outside';

  /**
   * Webkit 'mask' gradient or custom hex code.
   */
  gradientColor?: 'mask' | string;

  rightMaskGradientWidth?: string;
  arrowOffsetY?: number | string;
  arrowSize?: number | string;

  testId?: string;
  scrollerRef?: RefObject<HTMLDivElement | null>;
  tag?: 'ul';
}

const HorizontalScroller = memo<HorizontalScrollerProps>(
  ({
    renderAfter = NOOP,
    renderContent = NOOP,
    render,
    style,
    contentStyle,
    scrollerStyle,
    onScroll,
    onScrollEnd,
    centerContent,
    testId,
    overflowStyle,
    gradientColor = '#000',
    rightMaskGradientWidth = '3.5rem',
    withSnapping,
    forceOverflowing,
    arrowOffsetY,
    arrowSize,
    scrollerRef,
    tag,
    ...restProps
  }) => {
    const scrollerRefInternal = useRef<HTMLDivElement>(null);
    const withOverflowStyle = !!overflowStyle;
    const isMaskOverflow = gradientColor === 'mask';
    const withOverflowElements = overflowStyle;

    const gradientRightRef = useRef<HTMLDivElement>(null);
    const gradientLeftRef = useRef<HTMLDivElement>(null);
    const contentRef = useRef<HTMLDivElement>(null);

    const onScrollEndDebounced = useDebounce(
      (params: { x: number; maxX: number }) => {
        onScrollEnd?.(params);
      },
      100
    );

    const scrollerFinalRef = scrollerRef || scrollerRefInternal;

    const isOverflowingInternal = useIsOverflowing({
      rootRef: scrollerFinalRef,
      targetRef: contentRef,
      fallbackValue: false,
      skip: !withOverflowStyle,
    });

    const isOverflowing = forceOverflowing || isOverflowingInternal;

    const moveScroller = (dir: 'forward' | 'back') => {
      const el = scrollerFinalRef.current;
      if (!el) return;

      let left = el.offsetWidth;

      if (isMaskOverflow) {
        const style = getComputedStyle(el);
        const paddingLeft = parseFloat(style.paddingLeft);
        const paddingRight = parseFloat(style.paddingRight);
        left -= paddingLeft + paddingRight;
      }

      if (dir === 'back') left = -left;

      el.scrollBy({
        left: Math.round(left),
        behavior: 'smooth',
      });
    };

    // eslint-disable-next-line react-hooks/exhaustive-deps
    const onScrollInternal = useCallback(
      throttledRaf(({ target }: UIEvent<HTMLDivElement>) => {
        if (!onScroll && !withOverflowElements) return;
        if (!(target instanceof HTMLDivElement)) return;

        const x = target.scrollLeft;
        const maxX = target.scrollWidth - target.clientWidth;
        const isAtEnd = x > maxX - 4;
        const isAtStart = x < 4;

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

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

        onScroll?.({
          x,
          maxX,
        });

        onScrollEndDebounced({
          x,
          maxX,
        });
      }),
      []
    );

    return (
      <Box {...restProps} positionRelative data-testid={testId} style={style}>
        {withOverflowElements && isOverflowing && (
          <OverflowGradient
            dir="left"
            overflowStyle={overflowStyle}
            gradientColor={gradientColor}
            onArrowClick={() => moveScroller('back')}
            nodeRef={gradientLeftRef}
            arrowOffsetY={arrowOffsetY}
            arrowSize={arrowSize}
          />
        )}
        <div
          ref={scrollerFinalRef}
          onScroll={onScrollInternal}
          className={classNames('_scroller', {
            isMaskOverflow,
            withSnapping,
          })}
          data-testid="scroller"
          style={{
            ...scrollerStyle,

            position: 'relative',
            zIndex: 0,
            overflowX: 'auto',
            overflowY: 'hidden',
            height: '100%',

            WebkitOverflowScrolling: 'touch',
            scrollSnapType: withSnapping ? 'x mandatory' : undefined,

            // REVIEW: this can flash on mount as isOverflowing always starts `false`
            WebkitMaskImage:
              isMaskOverflow && isOverflowing
                ? `linear-gradient(90deg,transparent 0%,#000 2rem, #000 calc(100% - ${rightMaskGradientWidth}), transparent 100%)`
                : undefined,

            padding: isMaskOverflow ? '0 1rem' : '',

            // COMPLEX: this removes strange whitespace around the child content container.
            // It's important there's no surplus whitespace to ensure `isOverflowing` is accurate.
            lineHeight: 0,
          }}
        >
          {render ? (
            render({ ref: contentRef, isOverflowing })
          ) : (
            <Box
              nodeRef={contentRef}
              className="items"
              tag={tag}
              style={{
                display: 'inline-flex',
                minWidth: '100%',
                height: '100%',
                justifyContent: centerContent ? 'center' : '',
                verticalAlign: 'top',
                alignItems: 'center',
                ...contentStyle,
              }}
            >
              {renderContent({
                isOverflowing,
                snapItemStyle: {
                  scrollSnapAlign: 'center',
                },
              })}
            </Box>
          )}
        </div>
        {renderAfter({ isOverflowing })}
        {withOverflowElements && isOverflowing && (
          <OverflowGradient
            dir="right"
            overflowStyle={overflowStyle}
            gradientColor={gradientColor}
            onArrowClick={() => moveScroller('forward')}
            nodeRef={gradientRightRef}
            arrowOffsetY={arrowOffsetY}
            arrowSize={arrowSize}
          />
        )}
        {/* hide awful scrollbars on chrome windows */}
        <style jsx>{`
          ._scroller::-webkit-scrollbar {
            display: none;
          }

          /* Safari-only hack to fix alignment on last item with snapping */
          @supports (-webkit-hyphens: none) {
            ._scroller.isMaskOverflow.withSnapping :global(.items) {
              padding-right: 1rem;
            }
          }
        `}</style>
      </Box>
    );
  }
);

const OverflowGradient: FC<{
  dir: 'left' | 'right';
  overflowStyle: 'inside' | 'outside';
  gradientColor: 'mask' | string;
  onArrowClick: () => void;
  nodeRef: RefObject<HTMLDivElement | null>;
  arrowOffsetY?: number | string;
  arrowSize?: number | string;
}> = ({
  dir,
  onArrowClick,
  nodeRef,
  overflowStyle,
  gradientColor,
  arrowOffsetY,
  arrowSize,
}) => {
  const isLargeScreen = useIsLargeScreen();
  const isOutsideStyle = isLargeScreen && overflowStyle === 'outside';
  const gradientOpacity = isOutsideStyle ? 0.7 : 0.8;
  const gradientWidth = isOutsideStyle ? '1.2rem' : '3.4rem';
  const isLeft = dir === 'left';

  return (
    <FadeOnMount bypass={isLeft}>
      <div>
        <div
          ref={nodeRef}
          className="overflowGradient"
          style={{
            position: 'absolute',
            top: 0,
            bottom: 0,
            zIndex: 1,
            width: gradientWidth,
            pointerEvents: 'none',
            opacity: isLeft ? 0 : 1,
            transition: 'opacity 600ms',
            [dir]: 0,
          }}
        >
          {gradientColor !== 'mask' && (
            <Gradient
              coverParent
              to={toRgba(gradientColor, gradientOpacity)}
              angle={isLeft ? 90 : -90}
            />
          )}
          <ChevronIcon
            coverParent
            centerContent
            className={SCROLLER_ARROW_CLASS}
            direction={dir}
            size={arrowSize || (isOutsideStyle ? '2.1rem' : '2.4rem')}
            pointerEvents="all"
            onClick={onArrowClick}
            withHoverOpacityFrom={isOutsideStyle ? 0.6 : undefined}
            testId={`${dir}ScrollerArrow`}
            style={{
              [dir]: isOutsideStyle ? `-3rem` : 0,
              bottom: arrowOffsetY,

              // hide on small-screen devices where horizontal scroll is good ux
              // and clickable arrows aren't required
              display: !isLargeScreen ? 'none' : '',
            }}
          />
        </div>
      </div>
    </FadeOnMount>
  );
};

export default HorizontalScroller;
