import PropTypes from 'prop-types';
import React, {
  useContext,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  useCallback,
} from 'react';
import { TranslationsContext } from '../../providers/translations';
import { withGlobalProps } from '../../providers/globalProps';
import { classNames } from '../../helpers/classNames/classNames';
import { transferProps } from '../../helpers/transferProps';
import { getElementsPositionDifference } from './_helpers/getElementsPositionDifference';
import { useLoadResize } from './_hooks/useLoadResizeHook';
import { useScrollPosition } from './_hooks/useScrollPositionHook';
import styles from './ScrollView.module.scss';

// Function `getElementsPositionDifference` sometimes returns floating point values that results
// in inaccurate detection of start/end. It is necessary to accept this inaccuracy and take
// every value less or equal to 1px as start/end.
const EDGE_DETECTION_INACCURACY_PX = 1;

export const ScrollView = React.forwardRef((props, ref) => {
  const {
    arrows,
    arrowsScrollStep,
    autoScroll,
    children,
    endShadowBackground,
    endShadowInitialOffset,
    endShadowSize,
    id,
    debounce,
    direction,
    nextArrowColor,
    nextArrowElement,
    nextArrowInitialOffset,
    prevArrowColor,
    prevArrowElement,
    prevArrowInitialOffset,
    scrollbar,
    shadows,
    startShadowBackground,
    startShadowInitialOffset,
    startShadowSize,
    ...restProps
  } = props;

  const translations = useContext(TranslationsContext);

  const [isAutoScrollInProgress, setIsAutoScrollInProgress] = useState(false);
  const [isScrolledAtStart, setIsScrolledAtStart] = useState(false);
  const [isScrolledAtEnd, setIsScrolledAtEnd] = useState(false);

  const scrollPositionStart = direction === 'horizontal' ? 'left' : 'top';
  const scrollPositionEnd = direction === 'horizontal' ? 'right' : 'bottom';
  const scrollViewContentEl = useRef(null);
  const blankRef = useRef(null);
  const scrollViewViewportEl = ref ?? blankRef;

  const handleScrollViewState = useCallback((currentPosition) => {
    const isScrolledAtStartActive = currentPosition[scrollPositionStart]
      <= -1 * EDGE_DETECTION_INACCURACY_PX;
    const isScrolledAtEndActive = currentPosition[scrollPositionEnd]
      >= EDGE_DETECTION_INACCURACY_PX;

    setIsScrolledAtStart((prevIsScrolledAtStart) => {
      if (isScrolledAtStartActive !== prevIsScrolledAtStart) {
        return isScrolledAtStartActive;
      }
      return prevIsScrolledAtStart;
    });

    setIsScrolledAtEnd((prevIsScrolledAtEnd) => {
      if (isScrolledAtEndActive !== prevIsScrolledAtEnd) {
        return isScrolledAtEndActive;
      }
      return prevIsScrolledAtEnd;
    });
  }, [scrollPositionStart, scrollPositionEnd]);

  /**
   * It handles scroll event fired on `scrollViewViewportEl` element. If autoScroll is in progress,
   * and element it scrolled to the end of viewport, `isAutoScrollInProgress` is set to `false`.
   */
  const handleScrollWhenAutoScrollIsInProgress = () => {
    const currentPosition = getElementsPositionDifference(
      scrollViewContentEl,
      scrollViewViewportEl,
    );

    if (currentPosition[scrollPositionEnd] <= EDGE_DETECTION_INACCURACY_PX) {
      setIsAutoScrollInProgress(false);
      scrollViewViewportEl.current.removeEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
    }
  };

  /**
   * If autoScroll is enabled, it automatically scrolls viewport element to the end of the
   * viewport when content is changed. It is performed only when viewport element is
   * scrolled to the end of the viewport or when viewport element is in any position but
   * autoScroll triggered by previous change is still in progress.
   */
  const handleScrollWhenAutoScrollIsEnabled = (forceAutoScroll = false) => {
    if (autoScroll === 'off') {
      return () => {};
    }

    const scrollViewContentElement = scrollViewContentEl.current;
    const scrollViewViewportElement = scrollViewViewportEl.current;

    const differenceX = direction === 'horizontal' ? scrollViewContentElement.offsetWidth : 0;
    const differenceY = direction !== 'horizontal' ? scrollViewContentElement.offsetHeight : 0;

    if (autoScroll === 'always' || forceAutoScroll) {
      scrollViewViewportElement.scrollBy(differenceX, differenceY);
    } else if (!isScrolledAtEnd || isAutoScrollInProgress) {
      setIsAutoScrollInProgress(true);
      scrollViewViewportElement.scrollBy(differenceX, differenceY);

      // Handler `handleScrollWhenAutoScrollIsInProgress` sets `isAutoScrollInProgress` to `false`
      // when viewport element is scrolled to the end of the viewport
      scrollViewViewportElement.addEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);

      return () => {
        scrollViewViewportElement.removeEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
      };
    }

    return () => {};
  };

  useEffect(
    () => {
      handleScrollViewState(
        getElementsPositionDifference(scrollViewContentEl, scrollViewViewportEl),
      );
    },
    [], // eslint-disable-line react-hooks/exhaustive-deps
  );

  useLoadResize(
    (currentPosition) => {
      handleScrollViewState(currentPosition);
      handleScrollWhenAutoScrollIsEnabled(true);
    },
    [isScrolledAtStart, isScrolledAtEnd],
    scrollViewContentEl,
    scrollViewViewportEl,
    debounce,
  );

  useScrollPosition(
    (currentPosition) => (handleScrollViewState(currentPosition)),
    scrollViewContentEl,
    scrollViewViewportEl,
    debounce,
  );

  const autoScrollChildrenKeys = autoScroll !== 'off' && children && React.Children
    .map(children, (child) => child.key)
    .reduce((reducedKeys, childKey) => reducedKeys + childKey, '');
  const autoScrollChildrenLength = autoScroll !== 'off' && children && children.length;

  useLayoutEffect(
    handleScrollWhenAutoScrollIsEnabled,
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [autoScroll, autoScrollChildrenKeys, autoScrollChildrenLength],
  );

  // ResizeObserver to detect when content or viewport dimensions change due to style changes
  useLayoutEffect(() => {
    const contentElement = scrollViewContentEl.current;
    const viewportElement = scrollViewViewportEl.current;

    if (!contentElement || !viewportElement) {
      return () => {};
    }

    const resizeObserver = new ResizeObserver(() => {
      handleScrollViewState(
        getElementsPositionDifference(scrollViewContentEl, scrollViewViewportEl),
      );
    });

    // Observe both content and viewport for dimension changes
    resizeObserver.observe(contentElement);
    resizeObserver.observe(viewportElement);

    return () => {
      resizeObserver.disconnect();
    };
  }, [scrollViewContentEl, scrollViewViewportEl, handleScrollViewState]);

  const arrowHandler = (contentEl, viewportEl, scrollViewDirection, shiftDirection, step) => {
    const offset = shiftDirection === 'next' ? step : -1 * step;
    const differenceX = scrollViewDirection === 'horizontal' ? offset : 0;
    const differenceY = scrollViewDirection !== 'horizontal' ? offset : 0;

    viewportEl.current.scrollBy(differenceX, differenceY);
  };

  return (
    <div
      {...transferProps(restProps)}
      className={classNames(
        styles.root,
        isScrolledAtStart && styles.isRootScrolledAtStart,
        isScrolledAtEnd && styles.isRootScrolledAtEnd,
        !scrollbar && styles.hasRootScrollbarDisabled,
        direction === 'horizontal' ? styles.isRootHorizontal : styles.isRootVertical,
      )}
      id={id}
      style={{
        '--rui-local-end-shadow-background': endShadowBackground,
        '--rui-local-end-shadow-direction': direction === 'horizontal' ? 'to left' : 'to top',
        '--rui-local-end-shadow-initial-offset': endShadowInitialOffset,
        '--rui-local-end-shadow-size': endShadowSize,
        '--rui-local-next-arrow-color': nextArrowColor,
        '--rui-local-next-arrow-initial-offset': nextArrowInitialOffset,
        '--rui-local-prev-arrow-color': prevArrowColor,
        '--rui-local-prev-arrow-initial-offset': prevArrowInitialOffset,
        '--rui-local-start-shadow-background': startShadowBackground,
        '--rui-local-start-shadow-direction': direction === 'horizontal' ? 'to right' : 'to bottom',
        '--rui-local-start-shadow-initial-offset': startShadowInitialOffset,
        '--rui-local-start-shadow-size': startShadowSize,
      }}
    >
      <div
        className={styles.viewport}
        ref={scrollViewViewportEl}
      >
        <div
          className={styles.content}
          id={id && `${id}__content`}
          ref={scrollViewContentEl}
        >
          {children}
        </div>
      </div>
      {shadows && (
        <div
          aria-hidden
          className={styles.scrollingShadows}
        />
      )}
      {arrows && (
        <>
          <button
            className={styles.arrowPrev}
            id={id && `${id}__arrowPrevButton`}
            onClick={() => arrowHandler(
              scrollViewContentEl,
              scrollViewViewportEl,
              direction,
              'prev',
              arrowsScrollStep,
            )}
            title={translations.ScrollView.previous}
            type="button"
          >
            {prevArrowElement || (
              <span
                aria-hidden
                className={styles.arrowIcon}
              />
            )}
          </button>
          <button
            className={styles.arrowNext}
            id={id && `${id}__arrowNextButton`}
            onClick={() => arrowHandler(
              scrollViewContentEl,
              scrollViewViewportEl,
              direction,
              'next',
              arrowsScrollStep,
            )}
            title={translations.ScrollView.next}
            type="button"
          >
            {nextArrowElement || (
              <span
                aria-hidden
                className={styles.arrowIcon}
              />
            )}
          </button>
        </>
      )}
    </div>
  );
});

ScrollView.defaultProps = {
  arrows: false,
  arrowsScrollStep: 200,
  autoScroll: 'off',
  children: undefined,
  debounce: 50,
  direction: 'vertical',
  endShadowBackground: 'linear-gradient(var(--rui-local-end-shadow-direction), rgba(255 255 255 / 1), rgba(255 255 255 / 0))',
  endShadowInitialOffset: '-1rem',
  endShadowSize: '2em',
  id: undefined,
  nextArrowColor: undefined,
  nextArrowElement: undefined,
  nextArrowInitialOffset: '-0.5rem',
  prevArrowColor: undefined,
  prevArrowElement: undefined,
  prevArrowInitialOffset: '-0.5rem',
  scrollbar: true,
  shadows: true,
  startShadowBackground: 'linear-gradient(var(--rui-local-start-shadow-direction), rgba(255 255 255 / 1), rgba(255 255 255 / 0))',
  startShadowInitialOffset: '-1rem',
  startShadowSize: '2em',
};

ScrollView.propTypes = {
  /**
   * If `true`, display the arrow controls.
   */
  arrows: PropTypes.bool,
  /**
   * Portion to scroll by when the arrows are clicked, in px.
   */
  arrowsScrollStep: PropTypes.number,
  /**
   * The auto-scroll mechanism requires having the `key` prop set for every child present in `children`
   * because it detects changes of those keys. Without the keys, the auto-scroll will not work.
   *
   * Option `always` means the auto-scroll scrolls to the end every time the content changes.
   * Option `detectEnd` means the auto-scroll scrolls to the end only when the content is changed
   * and the user has scrolled at the end of the viewport at the moment of the change.
   *
   * See https://reactjs.org/docs/lists-and-keys.html#keys
   */
  autoScroll: PropTypes.oneOf(['always', 'detectEnd', 'off']),
  /**
   * Content to be scrollable.
   */
  children: PropTypes.node,
  /**
   * Delay in ms before the display of arrows and scrolling shadows is evaluated during interaction.
   */
  debounce: PropTypes.number,
  /**
   * Direction of scrolling.
   */
  direction: PropTypes.oneOf(['horizontal', 'vertical']),
  /**
   * Custom background of the end scrolling shadow. Can be a CSS gradient or an image `url()`.
   */
  endShadowBackground: PropTypes.string,
  /**
   * Initial offset of the end scrolling shadow (transitioned). If set, the end scrolling shadow slides in
   * by this distance.
   */
  endShadowInitialOffset: PropTypes.string,
  /**
   * Size of the end scrolling shadow. Accepts any valid CSS length value.
   */
  endShadowSize: PropTypes.string,
  /**
   * ID of the root HTML element. It also serves as base for nested elements:
   * * `<ID>__content`
   * * `<ID>__arrowPrevButton`
   * * `<ID>__arrowNextButton`
   */
  id: PropTypes.string,
  /**
   * Text color of the end arrow control. Accepts any valid CSS color value.
   */
  nextArrowColor: PropTypes.string,
  /**
   * Custom HTML or React Component to replace the default next-arrow control.
   */
  nextArrowElement: PropTypes.node,
  /**
   * Initial offset of the end arrow control (transitioned). If set, the next arrow slides in by this distance.
   */
  nextArrowInitialOffset: PropTypes.string,
  /**
   * Text color of the start arrow control. Accepts any valid CSS color value.
   */
  prevArrowColor: PropTypes.string,
  /**
   * Custom HTML or React Component to replace the default prev-arrow control.
   */
  prevArrowElement: PropTypes.node,
  /**
   * Initial offset of the start arrow control (transitioned). If set, the prev arrow slides in by this distance.
   */
  prevArrowInitialOffset: PropTypes.string,
  /**
   * If `false`, the system scrollbar will be hidden.
   */
  scrollbar: PropTypes.bool,
  /**
   * If `true`, display scrolling shadows.
   */
  shadows: PropTypes.bool,
  /**
   * Custom background of the start scrolling shadow. Can be a CSS gradient or an image `url()`.
   */
  startShadowBackground: PropTypes.string,
  /**
   * Initial offset of the start scrolling shadow (transitioned). If set, the start scrolling shadow slides in
   * by this distance.
   */
  startShadowInitialOffset: PropTypes.string,
  /**
   * Size of the start scrolling shadow. Accepts any valid CSS length value.
   */
  startShadowSize: PropTypes.string,
};

export const ScrollViewWithGlobalProps = withGlobalProps(ScrollView, 'ScrollView');

export default ScrollViewWithGlobalProps;
