'use client';

import * as React from 'react';
import { classNames, noop } from '@vkontakte/vkjs';
import { useConfigDirection } from '../../hooks/useConfigDirection';
import { useExternRef } from '../../hooks/useExternRef';
import { useFocusVisible } from '../../hooks/useFocusVisible';
import { useFocusVisibleClassName } from '../../hooks/useFocusVisibleClassName';
import { useMergeProps } from '../../hooks/useMergeProps';
import { easeInOutSine } from '../../lib/fx';
import { mergeCalls } from '../../lib/mergeCalls';
import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect';
import { warnOnce } from '../../lib/warnOnce';
import type { HasDataAttribute, HasRef, HTMLAttributesWithRootRef } from '../../types';
import { useHover } from '../Clickable/useState';
import { RootComponent } from '../RootComponent/RootComponent';
import { ScrollArrow, type ScrollArrowProps } from '../ScrollArrow/ScrollArrow';
import styles from './HorizontalScroll.module.css';

/* eslint-disable jsdoc/require-jsdoc */
interface ScrollContext {
  scrollElement: HTMLElement | null;
  scrollAnimationDuration: number;
  animationQueue: VoidFunction[];
  getScrollPosition: (currentPosition: number) => number;
  onScrollToEndBorder: VoidFunction;
  onScrollEnd: VoidFunction;
  onScrollStart: VoidFunction;
  /**
   * Начальная ширина прокрутки.
   * В некоторых случаях может отличаться от текущей ширины прокрутки из-за transforms: translate.
   */
  initialScrollWidth: number;
  textDirection: 'ltr' | 'rtl';
}
/* eslint-enable jsdoc/require-jsdoc */

export type ScrollPositionHandler = (currentPosition: number) => number;

const warn = warnOnce('HorizontalScroll');

export interface HorizontalScrollProps
  extends HTMLAttributesWithRootRef<HTMLDivElement>,
    HasRef<HTMLDivElement> {
  /**
   * Функция для расчета величины прокрутки при нажатии на левую стрелку.
   */
  getScrollToLeft?: ScrollPositionHandler | undefined;
  /**
   * Функция для расчета величины прокрутки при нажатии на правую стрелку.
   */
  getScrollToRight?: ScrollPositionHandler | undefined;
  /**
   * Размер стрелок.
   */
  arrowSize?: ScrollArrowProps['size'] | undefined;
  /**
   * Смещает иконки кнопок навигации по вертикали.
   */
  arrowOffsetY?: number | string | undefined;
  /**
   * Смещает иконки кнопок навигации по горизонтали.
   */
  arrowOffsetX?: number | string;
  /**
   * Показывать ли стрелки.
   */
  showArrows?: boolean | 'always' | undefined;
  /**
   * Свойства, которые можно прокинуть внутрь компонента:
   * - `prevArrow`: свойства для прокидывания в стрелку "назад";
   * - `nextArrow`: свойства для прокидывания в стрелку "вперед".
   */
  slotProps?: {
    prevArrow?: Partial<ScrollArrowProps> & HasDataAttribute;
    nextArrow?: Partial<ScrollArrowProps> & HasDataAttribute;
  };
  /**
   * Длительность анимации скролла.
   */
  scrollAnimationDuration?: number | undefined;
  /**
   * Добавляет возможность прокручивать контент на любое колесо мыши.
   * По умолчанию прокручивается как любой горизонтальный контент через shift.
   */
  scrollOnAnyWheel?: boolean | undefined;
  /**
   * @deprecated Since 8.0.0. Вместо этого используйте `slotProps={ prevArrow: { 'data-testid': ... } }`.
   * Передает атрибут `data-testid` для кнопки прокрутки горизонтального скролла в направлении предыдущего элемента.
   */
  prevButtonTestId?: string | undefined;
  /**
   * @deprecated Since 8.0.0. Вместо этого используйте `slotProps={ nextArrow: { 'data-testid': ... } }`.
   * Передает атрибут `data-testid` для кнопки прокрутки горизонтального скролла в направлении следующего элемента.
   */
  nextButtonTestId?: string | undefined;
  /**
   * Позволяет поменять тег используемый для обертки над контентом, прокинутым в `children`.
   */
  ContentWrapperComponent?: React.ElementType | undefined;
  /**
   * `ref` для обертки над контентом, прокинутым в `children`.
   */
  contentWrapperRef?: React.Ref<HTMLElement> | undefined;
  /**
   * Специфичный `className` для обертки над контентом, прокинутым в `children`.
   */
  contentWrapperClassName?: string | undefined;
  /**
   * Добавляет отступы для контента внутри.
   */
  withPadding?: boolean | undefined;
}

/**
 * Timing method.
 */
function now() {
  return performance && performance.now ? performance.now() : Date.now();
}

/**
 * Округление к большему по модулю.
 *
 * ## Пример
 *
 * ```ts
 * import { strict as assert } from 'node:assert';
 *
 * assert.equal(roundingAwayFromZero(5.1), 6)
 * assert.equal(roundingAwayFromZero(-5.1), -6)
 * ```
 */
function roundingAwayFromZero(value: number): number {
  return value > 0 ? Math.ceil(value) : Math.floor(value);
}

/**
 * Округляем el.scrollLeft
 * https://github.com/VKCOM/VKUI/pull/2445.
 */
const roundUpElementScrollLeft = (el: HTMLElement) => roundingAwayFromZero(el.scrollLeft);

/**
 * Код анимации скрола, на основе полифила: https://github.com/iamdustan/smoothscroll
 * Константа взята из полифила (468), на дизайн-ревью уточнили до 250.
 * @var {number} SCROLL_ONE_FRAME_TIME время анимации скролла
 */
const SCROLL_ONE_FRAME_TIME = 250;

function doScroll({
  scrollElement,
  getScrollPosition,
  animationQueue,
  onScrollToEndBorder,
  onScrollEnd,
  onScrollStart,
  initialScrollWidth,
  scrollAnimationDuration,
  textDirection,
}: ScrollContext) {
  if (!scrollElement || !getScrollPosition) {
    return;
  }

  /**
   * Крайнее значение сдвига.
   */
  const extremeScrollLeft =
    (textDirection === 'ltr' ? 1 : -1) * (initialScrollWidth - scrollElement.offsetWidth);

  const startScrollLeft = roundUpElementScrollLeft(scrollElement);
  const remappedStartScrollLeft = startScrollLeft * (textDirection === 'rtl' ? -1 : 1);

  let endScrollLeft = getScrollPosition(remappedStartScrollLeft);

  const diff = endScrollLeft - remappedStartScrollLeft;
  if (textDirection === 'rtl') {
    endScrollLeft = startScrollLeft - diff;
  }

  onScrollStart();

  /**
   * Если окончание прокрутки вышло за ноль.
   */
  if (startScrollLeft * endScrollLeft < 0) {
    endScrollLeft = 0;
  }

  if (Math.abs(endScrollLeft) >= Math.abs(extremeScrollLeft)) {
    onScrollToEndBorder();
    endScrollLeft = extremeScrollLeft;
  }

  const startTime = now();

  (function scroll() {
    const time = now();
    const elapsed = Math.min((time - startTime) / scrollAnimationDuration, 1);

    const value = easeInOutSine(elapsed);

    const currentScrollLeft = startScrollLeft + (endScrollLeft - startScrollLeft) * value;
    scrollElement.scrollLeft = roundingAwayFromZero(currentScrollLeft);

    const scrollEnd =
      textDirection === 'ltr' ? Math.max(0, endScrollLeft) : Math.min(0, endScrollLeft);
    if (roundUpElementScrollLeft(scrollElement) !== scrollEnd && elapsed !== 1) {
      requestAnimationFrame(scroll);
      return;
    }

    onScrollEnd();
    animationQueue.shift();
    if (animationQueue.length > 0) {
      animationQueue[0]();
    }
  })();
}

/**
 * @see https://vkui.io/components/horizontal-scroll
 */
export const HorizontalScroll = ({
  children,
  getScrollToLeft,
  getScrollToRight,
  showArrows = true,
  arrowSize = 'm',
  arrowOffsetY,
  arrowOffsetX,
  scrollAnimationDuration = SCROLL_ONE_FRAME_TIME,
  getRef,
  scrollOnAnyWheel = false,
  prevButtonTestId,
  nextButtonTestId,
  // ContentWrapper
  ContentWrapperComponent = 'div',
  contentWrapperRef,
  contentWrapperClassName,
  withPadding,
  onPointerEnter,
  onPointerLeave,
  onMouseEnter,

  slotProps,
  ...restProps
}: HorizontalScrollProps): React.ReactNode => {
  /* istanbul ignore if: не проверяем в тестах */
  if (process.env.NODE_ENV === 'development' && (prevButtonTestId || nextButtonTestId)) {
    warn(
      "Свойства `prevButtonTestId` и `nextButtonTestId` устаревшие, используйте `slotProps={ prevArrow: { 'data-testid': ... }, nextArrow: { 'data-testid': ... } }`",
    );
  }

  const [canScrollStart, setCanScrollStart] = React.useState(false);
  const [canScrollEnd, setCanScrollEnd] = React.useState(false);
  const { focusVisible, ...focusEvents } = useFocusVisible();
  const focusVisibleClassNames = useFocusVisibleClassName({
    focusVisible,
  });

  const direction = useConfigDirection();
  const isRtl = direction === 'rtl';

  const isCustomScrollingRef = React.useRef(false);

  const scrollerRef = useExternRef(getRef);

  const animationQueue = React.useRef<VoidFunction[]>([]);

  const { isHovered, ...hoverHandlers } = useHover();

  const scrollTo = React.useCallback(
    (getScrollPosition: ScrollPositionHandler) => {
      const scrollElement = scrollerRef.current;

      animationQueue.current.push(() =>
        doScroll({
          scrollElement,
          getScrollPosition,
          animationQueue: animationQueue.current,
          onScrollToEndBorder: () => setCanScrollEnd(false),
          onScrollEnd: () => (isCustomScrollingRef.current = false),
          onScrollStart: () => (isCustomScrollingRef.current = true),
          initialScrollWidth: scrollElement?.firstElementChild?.scrollWidth || 0,
          scrollAnimationDuration,
          textDirection: direction,
        }),
      );
      if (animationQueue.current.length === 1) {
        animationQueue.current[0]();
      }
    },
    [scrollerRef, scrollAnimationDuration, direction, setCanScrollEnd],
  );

  const scrollToStart = React.useCallback(() => {
    const getScrollPosition =
      getScrollToLeft ?? ((i: number) => i - scrollerRef.current!.offsetWidth);
    scrollTo(getScrollPosition);
  }, [getScrollToLeft, scrollTo, scrollerRef]);

  const scrollToEnd = React.useCallback(() => {
    const getScrollPosition =
      getScrollToRight ?? ((i: number) => i + scrollerRef.current!.offsetWidth);
    scrollTo(getScrollPosition);
  }, [getScrollToRight, scrollTo, scrollerRef]);

  const calculateArrowsVisibility = React.useCallback(() => {
    if (showArrows && scrollerRef.current && !isCustomScrollingRef.current) {
      const scrollElement = scrollerRef.current;
      const scrollLeft = scrollElement.scrollLeft;

      setCanScrollStart(isRtl ? scrollLeft < 0 : scrollLeft > 0);
      setCanScrollEnd(
        Math.abs(roundUpElementScrollLeft(scrollElement)) + scrollElement.offsetWidth <
          scrollElement.scrollWidth,
      );
    }
  }, [showArrows, scrollerRef, isRtl]);

  React.useEffect(calculateArrowsVisibility, [calculateArrowsVisibility, children]);

  useIsomorphicLayoutEffect(
    function addWheelEventHandler() {
      const scrollEl = scrollerRef.current;
      if (!scrollEl) {
        return noop;
      }
      /**
       * Прокрутка с помощью любого колеса мыши.
       */
      const onWheel = (e: WheelEvent) => {
        scrollerRef.current!.scrollBy({ left: e.deltaX + e.deltaY, behavior: 'auto' });
        e.preventDefault();
      };

      const listenerOptions = { passive: false };

      if (scrollOnAnyWheel) {
        scrollEl.addEventListener('wheel', onWheel, listenerOptions);
      }
      scrollEl.addEventListener('scroll', calculateArrowsVisibility, listenerOptions);

      return () => {
        if (scrollOnAnyWheel) {
          // @ts-expect-error: TS2769 В интерфейсе EventListenerOptions для wheel нет passive свойства
          scrollEl.removeEventListener('wheel', onWheel, listenerOptions);
        }
        // @ts-expect-error: TS2769 В интерфейсе EventListenerOptions для scroll нет passive свойства
        scrollEl.removeEventListener('scroll', calculateArrowsVisibility, listenerOptions);
      };
    },
    [scrollOnAnyWheel, calculateArrowsVisibility, scrollerRef],
  );

  const handlers = mergeCalls(hoverHandlers, { onPointerEnter, onPointerLeave });

  const prevArrowProps = useMergeProps(
    {
      className: classNames(styles.arrow, styles.arrowLeft),
      onClick: scrollToStart,
    },
    slotProps?.prevArrow,
  );

  const nextArrowProps = useMergeProps(
    {
      className: classNames(styles.arrow, styles.arrowRight),
      onClick: scrollToEnd,
    },
    slotProps?.nextArrow,
  );

  return (
    <RootComponent
      {...restProps}
      {...handlers}
      baseClassName={classNames(
        styles.host,
        'vkuiInternalHorizontalScroll',
        (showArrows === 'always' || isHovered) && styles.showArrows,
        isRtl && styles.rtl,
        withPadding && styles.withPadding,
      )}
      // FIXME: onMouseEnter из restProps затирается, а при callMultiply орет линтер на рефы.
      onMouseEnter={calculateArrowsVisibility}
    >
      {showArrows && canScrollStart && (
        <ScrollArrow
          data-testid={prevButtonTestId}
          size={arrowSize}
          offsetY={arrowOffsetY}
          offsetX={arrowOffsetX}
          direction="left"
          aria-hidden
          tabIndex={-1}
          {...prevArrowProps}
        />
      )}
      {showArrows && canScrollEnd && (
        <ScrollArrow
          data-testid={nextButtonTestId}
          size={arrowSize}
          offsetY={arrowOffsetY}
          offsetX={arrowOffsetX}
          direction="right"
          aria-hidden
          tabIndex={-1}
          {...nextArrowProps}
        />
      )}
      <div
        className={classNames(styles.in, focusVisibleClassNames)}
        ref={scrollerRef}
        {...focusEvents}
      >
        <ContentWrapperComponent
          className={classNames(styles.inWrapper, contentWrapperClassName)}
          ref={contentWrapperRef}
        >
          {children}
        </ContentWrapperComponent>
      </div>
    </RootComponent>
  );
};
