import * as React from "react";
import {
    Animated,
    Easing,
    FlatList,
    I18nManager,
    Platform,
    ScrollView,
    StyleProp,
    View,
    ViewPropTypes,
    ViewStyle,
} from "react-native";
import {
    defaultScrollInterpolator,
    stackScrollInterpolator,
    tinderScrollInterpolator,
    defaultAnimatedStyles,
    shiftAnimatedStyles,
    stackAnimatedStyles,
    tinderAnimatedStyles,
} from "../utils/animations";

const IS_IOS = Platform.OS === "ios";

// Native driver for scroll events
// See: https://facebook.github.io/react-native/blog/2017/02/14/using-native-driver-for-animated.html
const AnimatedFlatList = FlatList
    ? Animated.createAnimatedComponent(FlatList)
    : null;
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);

// React Native automatically handles RTL layouts; unfortunately, it's buggy with horizontal ScrollView
// See https://github.com/facebook/react-native/issues/11960
// NOTE: the following variable is not declared in the constructor
// otherwise it is undefined at init, which messes with custom indexes
const IS_RTL = I18nManager.isRTL;

type Props = {
    data: any[],
    renderItem: (data, props) => React.ReactNode,
    itemWidth?: number, // required for horizontal carousel
    itemHeight?: number, // required for vertical carousel
    sliderWidth?: number, // required for horizontal carousel
    sliderHeight?: number, // required for vertical carousel
    activeAnimationType?: string,
    activeAnimationOptions?: Record<string, unknown>,
    activeSlideAlignment?: "center" | "end" | "start",
    activeSlideOffset?: number,
    apparitionDelay?: number,
    autoplay?: boolean,
    autoplayDelay?: number,
    autoplayInterval?: number,
    callbackOffsetMargin?: number,
    containerCustomStyle?: StyleProp<ViewStyle>,
    contentContainerCustomStyle?: StyleProp<ViewStyle>,
    enableMomentum?: boolean,
    enableSnap?: boolean,
    firstItem?: number,
    hasParallaxImages?: boolean,
    inactiveSlideOpacity?: number,
    inactiveSlideScale?: number,
    inactiveSlideShift?: number,
    layout?: "default" | "stack" | "tinder",
    layoutCardOffset?: number,
    lockScrollTimeoutDuration?: number,
    lockScrollWhileSnapping?: boolean,
    loop?: boolean,
    loopClonesPerSide?: number,
    scrollEnabled?: boolean,
    scrollInterpolator?: (index, props) => void,
    slideInterpolatedStyle?: (index, animatedValue, props) => void,
    slideStyle?: StyleProp<ViewStyle>,
    swipeThreshold?: number,
    useScrollView?: boolean | (() => void),
    vertical?: boolean,
    onBeforeSnapToItem?: (index) => void,
    onSnapToItem?: (index?) => void,
    onScroll?: (evt) => void,
    keyExtractor?: string | (() => void),
    onTouchStart?: (evt?) => void,
    onTouchEnd?: (evt?) => void,
    onScrollBeginDrag?: (evt) => void,
    onScrollEndDrag?: (evt) => void,
    style?: Record<string, unknown>,
    customAnimationType?: string,
    customAnimationOptions?: Record<string, unknown>,
    onScrollViewScroll?: (evt) => void,
    onStartShouldSetResponderCapture?: (evt) => void,
    onMomentumScrollEnd?: (evt?) => void,
    onLayout?: (evt?) => void,
};

type State = {
    hideCarousel: boolean,
    interpolators: any[],
}

export default class Carousel extends React.PureComponent<Props, State> {

    static defaultProps = {
        activeAnimationType: "timing",
        activeAnimationOptions: null,
        activeSlideAlignment: "center",
        activeSlideOffset: 20,
        apparitionDelay: 0,
        autoplay: false,
        autoplayDelay: 1000,
        autoplayInterval: 3000,
        callbackOffsetMargin: 5,
        containerCustomStyle: {},
        contentContainerCustomStyle: {},
        enableMomentum: false,
        enableSnap: true,
        firstItem: 0,
        hasParallaxImages: false,
        inactiveSlideOpacity: 0.7,
        inactiveSlideScale: 0.9,
        inactiveSlideShift: 0,
        layout: "default",
        lockScrollTimeoutDuration: 1000,
        lockScrollWhileSnapping: false,
        loop: false,
        loopClonesPerSide: 3,
        scrollEnabled: true,
        slideStyle: {},
        swipeThreshold: 20,
        useScrollView: !AnimatedFlatList,
        vertical: false,
    };

    state: State = {
        hideCarousel: true,
        interpolators: [],
    };

    _activeItem: number;
    _previousActiveItem: number;
    _previousFirstItem: number;
    _previousItemsLength: number;

    _mounted: boolean;
    _positions: any[];
    _currentContentOffset: number; // store ScrollView's scroll position
    _canFireBeforeCallback: boolean;
    _canFireCallback: boolean;
    _scrollOffsetRef = null;
    _onScrollTriggered = true; // used when momentum is enabled to prevent an issue with edges items
    _lastScrollDate: number; // used to work around a FlatList bug
    _scrollEnabled: boolean;

    // This bool aims at fixing an iOS bug due to scrollTo that triggers onMomentumScrollEnd.
    // onMomentumScrollEnd fires this._snapScroll, thus creating an infinite loop.
    _ignoreNextMomentum: boolean;

    _carouselRef;

    _scrollPos: Animated.Value;

    _onScrollHandler;

    _apparitionTimeout;
    _hackSlideAnimationTimeout;
    _enableAutoplayTimeout;
    _autoplayTimeout;
    _snapNoMomentumTimeout;
    _edgeItemTimeout;
    _lockScrollTimeout;

    _autoplayInterval;

    _itemToSnapTo: number;

    _autoplay: boolean;
    _autoplaying: boolean;

    _scrollStartOffset: number;
    _scrollStartActive: number;
    _scrollEndOffset: number;
    _scrollEndActive: number;

    _onLayoutInitDone: boolean;

    constructor(props) {
        super(props);

        // The following values are not stored in the state because 'setState()' is asynchronous
        // and this results in an absolutely crappy behavior on Android while swiping (see #156)
        const initialActiveItem = this._getFirstItem(props.firstItem);
        this._activeItem = initialActiveItem;
        this._previousActiveItem = initialActiveItem;
        this._previousFirstItem = initialActiveItem;
        this._previousItemsLength = initialActiveItem;

        this._mounted = false;
        this._positions = [];
        this._currentContentOffset = 0; // store ScrollView's scroll position
        this._canFireBeforeCallback = false;
        this._canFireCallback = false;
        this._scrollOffsetRef = null;
        this._onScrollTriggered = true; // used when momentum is enabled to prevent an issue with edges items
        this._lastScrollDate = 0; // used to work around a FlatList bug
        this._scrollEnabled = props.scrollEnabled !== false;

        // This bool aims at fixing an iOS bug due to scrollTo that triggers onMomentumScrollEnd.
        // onMomentumScrollEnd fires this._snapScroll, thus creating an infinite loop.
        this._ignoreNextMomentum = false;

        this._initPositionsAndInterpolators = this._initPositionsAndInterpolators.bind(
            this
        );
        this._renderItem = this._renderItem.bind(this);
        this._onSnap = this._onSnap.bind(this);

        this._onLayout = this._onLayout.bind(this);
        this._onScroll = this._onScroll.bind(this);
        this._onScrollBeginDrag = props.enableSnap
            ? this._onScrollBeginDrag.bind(this)
            : undefined;
        this._onScrollEnd =
            props.enableSnap || props.autoplay
                ? this._onScrollEnd.bind(this)
                : undefined;
        this._onScrollEndDrag = !props.enableMomentum
            ? this._onScrollEndDrag.bind(this)
            : undefined;
        this._onMomentumScrollEnd = props.enableMomentum
            ? this._onMomentumScrollEnd.bind(this)
            : undefined;
        this._onTouchStart = this._onTouchStart.bind(this);
        this._onTouchEnd = this._onTouchEnd.bind(this);
        this._onTouchRelease = this._onTouchRelease.bind(this);

        this._getKeyExtractor = this._getKeyExtractor.bind(this);

        this._setScrollHandler(props);

        // Warnings
        if (!ViewPropTypes) {
            console.warn(
                "react-native-snap-carousel: It is recommended to use at least version 0.44 of React Native with the plugin"
            );
        }
        if (!props.vertical && (!props.sliderWidth || !props.itemWidth)) {
            console.error(
                "react-native-snap-carousel: You need to specify both `sliderWidth` and `itemWidth` for horizontal carousels"
            );
        }
        if (props.vertical && (!props.sliderHeight || !props.itemHeight)) {
            console.error(
                "react-native-snap-carousel: You need to specify both `sliderHeight` and `itemHeight` for vertical carousels"
            );
        }
        if (props.apparitionDelay && !IS_IOS && !props.useScrollView) {
            console.warn(
                "react-native-snap-carousel: Using `apparitionDelay` on Android is not recommended since it can lead to rendering issues"
            );
        }
        if (props.customAnimationType || props.customAnimationOptions) {
            console.warn(
                "react-native-snap-carousel: Props `customAnimationType` and `customAnimationOptions` have been renamed to `activeAnimationType` and `activeAnimationOptions`"
            );
        }
        if (props.onScrollViewScroll) {
            console.error(
                "react-native-snap-carousel: Prop `onScrollViewScroll` has been removed. Use `onScroll` instead"
            );
        }
    }

    componentDidMount() {
        const { apparitionDelay, autoplay, firstItem } = this.props;
        const _firstItem = this._getFirstItem(firstItem);
        const apparitionCallback = () => {
            this.setState({ hideCarousel: false });
            if (autoplay) {
                this.startAutoplay();
            }
        };

        this._mounted = true;
        this._initPositionsAndInterpolators();

        // Without 'requestAnimationFrame' or a `0` timeout, images will randomly not be rendered on Android...
        requestAnimationFrame(() => {
            if (!this._mounted) {
                return;
            }

            this._snapToItem(_firstItem, false, false, true, false);
            this._hackActiveSlideAnimation(_firstItem, "start", true);

            if (apparitionDelay) {
                this._apparitionTimeout = setTimeout(() => {
                    apparitionCallback();
                }, apparitionDelay);
            } else {
                apparitionCallback();
            }
        });
    }

    componentDidUpdate(prevProps) {
        const { interpolators } = this.state;
        const {
            firstItem,
            itemHeight,
            itemWidth,
            scrollEnabled,
            sliderHeight,
            sliderWidth,
        } = this.props;
        const itemsLength = this._getCustomDataLength(this.props);

        if (!itemsLength) {
            return;
        }

        const nextFirstItem = this._getFirstItem(firstItem, this.props);
        let nextActiveItem =
            this._activeItem || this._activeItem === 0
                ? this._activeItem
                : nextFirstItem;

        const hasNewSliderWidth =
            sliderWidth && sliderWidth !== prevProps.sliderWidth;
        const hasNewSliderHeight =
            sliderHeight && sliderHeight !== prevProps.sliderHeight;
        const hasNewItemWidth = itemWidth && itemWidth !== prevProps.itemWidth;
        const hasNewItemHeight = itemHeight && itemHeight !== prevProps.itemHeight;
        const hasNewScrollEnabled = scrollEnabled !== prevProps.scrollEnabled;

        // Prevent issues with dynamically removed items
        if (nextActiveItem > itemsLength - 1) {
            nextActiveItem = itemsLength - 1;
        }

        // Handle changing scrollEnabled independent of user -> carousel interaction
        if (hasNewScrollEnabled) {
            this._setScrollEnabled(scrollEnabled);
        }

        if (
            interpolators.length !== itemsLength ||
            hasNewSliderWidth ||
            hasNewSliderHeight ||
            hasNewItemWidth ||
            hasNewItemHeight
        ) {
            this._activeItem = nextActiveItem;
            this._previousItemsLength = itemsLength;

            this._initPositionsAndInterpolators(this.props);

            // Handle scroll issue when dynamically removing items (see #133)
            // This also fixes first item's active state on Android
            // Because 'initialScrollIndex' apparently doesn't trigger scroll
            if (this._previousItemsLength > itemsLength) {
                this._hackActiveSlideAnimation(nextActiveItem, null, true);
            }

            if (
                hasNewSliderWidth ||
                hasNewSliderHeight ||
                hasNewItemWidth ||
                hasNewItemHeight
            ) {
                this._snapToItem(nextActiveItem, false, false, false, false);
            }
        } else if (
            nextFirstItem !== this._previousFirstItem &&
            nextFirstItem !== this._activeItem
        ) {
            this._activeItem = nextFirstItem;
            this._previousFirstItem = nextFirstItem;
            this._snapToItem(nextFirstItem, false, true, false, false);
        }

        if (this.props.onScroll !== prevProps.onScroll) {
            this._setScrollHandler(this.props);
        }
    }

    componentWillUnmount() {
        this._mounted = false;
        this.stopAutoplay();
        clearTimeout(this._apparitionTimeout);
        clearTimeout(this._hackSlideAnimationTimeout);
        clearTimeout(this._enableAutoplayTimeout);
        clearTimeout(this._autoplayTimeout);
        clearTimeout(this._snapNoMomentumTimeout);
        clearTimeout(this._edgeItemTimeout);
        clearTimeout(this._lockScrollTimeout);
    }

    get realIndex() {
        return this._activeItem;
    }

    get currentIndex() {
        return this._getDataIndex(this._activeItem);
    }

    get currentScrollPosition() {
        return this._currentContentOffset;
    }

    _setScrollHandler(props) {
        // Native driver for scroll events
        const scrollEventConfig = {
            listener: this._onScroll,
            useNativeDriver: true,
        };
        this._scrollPos = new Animated.Value(0);
        const argMapping = props.vertical
            ? [{ nativeEvent: { contentOffset: { y: this._scrollPos } } }]
            : [{ nativeEvent: { contentOffset: { x: this._scrollPos } } }];

        if (props.onScroll && Array.isArray(props.onScroll._argMapping)) {
            // Because of a react-native issue https://github.com/facebook/react-native/issues/13294
            argMapping.pop();
            const [argMap] = props.onScroll._argMapping;
            if (argMap && argMap.nativeEvent && argMap.nativeEvent.contentOffset) {
                // Shares the same animated value passed in props
                this._scrollPos =
                    argMap.nativeEvent.contentOffset.x ||
                    argMap.nativeEvent.contentOffset.y ||
                    this._scrollPos;
            }
            argMapping.push(...props.onScroll._argMapping);
        }
        this._onScrollHandler = Animated.event(argMapping, scrollEventConfig);
    }

    _needsScrollView() {
        const { useScrollView } = this.props;
        return (
            useScrollView ||
            !AnimatedFlatList ||
            this._shouldUseStackLayout() ||
            this._shouldUseTinderLayout()
        );
    }

    _needsRTLAdaptations() {
        const { vertical } = this.props;
        return IS_RTL && !IS_IOS && !vertical;
    }

    _canLockScroll() {
        const {
            scrollEnabled,
            enableMomentum,
            lockScrollWhileSnapping,
        } = this.props;
        return scrollEnabled && !enableMomentum && lockScrollWhileSnapping;
    }

    _enableLoop() {
        const { data, enableSnap, loop } = this.props;
        return enableSnap && loop && data && data.length && data.length > 1;
    }

    _shouldAnimateSlides(props = this.props) {
        const {
            inactiveSlideOpacity,
            inactiveSlideScale,
            scrollInterpolator,
            slideInterpolatedStyle,
        } = props;
        return (
            inactiveSlideOpacity < 1 ||
            inactiveSlideScale < 1 ||
            !!scrollInterpolator ||
            !!slideInterpolatedStyle ||
            this._shouldUseShiftLayout() ||
            this._shouldUseStackLayout() ||
            this._shouldUseTinderLayout()
        );
    }

    _shouldUseCustomAnimation() {
        const { activeAnimationOptions } = this.props;
        return (
            !!activeAnimationOptions &&
            !this._shouldUseStackLayout() &&
            !this._shouldUseTinderLayout()
        );
    }

    _shouldUseShiftLayout() {
        const { inactiveSlideShift, layout } = this.props;
        return layout === "default" && inactiveSlideShift !== 0;
    }

    _shouldUseStackLayout() {
        return this.props.layout === "stack";
    }

    _shouldUseTinderLayout() {
        return this.props.layout === "tinder";
    }

    _getCustomData(props = this.props) {
        const { data, loopClonesPerSide } = props;
        const dataLength = data && data.length;

        if (!dataLength) {
            return [];
        }

        if (!this._enableLoop()) {
            return data;
        }

        let previousItems = [];
        let nextItems = [];

        if (loopClonesPerSide > dataLength) {
            const dataMultiplier = Math.floor(loopClonesPerSide / dataLength);
            const remainder = loopClonesPerSide % dataLength;

            for (let i = 0; i < dataMultiplier; i++) {
                previousItems.push(...data);
                nextItems.push(...data);
            }

            previousItems.unshift(...data.slice(-remainder));
            nextItems.push(...data.slice(0, remainder));
        } else {
            previousItems = data.slice(-loopClonesPerSide);
            nextItems = data.slice(0, loopClonesPerSide);
        }

        return previousItems.concat(data, nextItems);
    }

    _getCustomDataLength(props = this.props) {
        const { data, loopClonesPerSide } = props;
        const dataLength = data && data.length;

        if (!dataLength) {
            return 0;
        }

        return this._enableLoop() ? dataLength + 2 * loopClonesPerSide : dataLength;
    }

    _getCustomIndex(index, props = this.props) {
        const itemsLength = this._getCustomDataLength(props);

        if (!itemsLength || (!index && index !== 0)) {
            return 0;
        }

        return this._needsRTLAdaptations() ? itemsLength - index - 1 : index;
    }

    _getDataIndex(index) {
        const { data, loopClonesPerSide } = this.props;
        const dataLength = data && data.length;

        if (!this._enableLoop() || !dataLength) {
            return index;
        }

        if (index >= dataLength + loopClonesPerSide) {
            return loopClonesPerSide > dataLength
                ? (index - loopClonesPerSide) % dataLength
                : index - dataLength - loopClonesPerSide;
        } else if (index < loopClonesPerSide) {
            // TODO: is there a simpler way of determining the interpolated index?
            if (loopClonesPerSide > dataLength) {
                const baseDataIndexes = [];
                const dataIndexes = [];
                const dataMultiplier = Math.floor(loopClonesPerSide / dataLength);
                const remainder = loopClonesPerSide % dataLength;

                for (let i = 0; i < dataLength; i++) {
                    baseDataIndexes.push(i);
                }

                for (let j = 0; j < dataMultiplier; j++) {
                    dataIndexes.push(...baseDataIndexes);
                }

                dataIndexes.unshift(...baseDataIndexes.slice(-remainder));
                return dataIndexes[index];
            } else {
                return index + dataLength - loopClonesPerSide;
            }
        } else {
            return index - loopClonesPerSide;
        }
    }

    // Used with `snapToItem()` and 'PaginationDot'
    _getPositionIndex(index) {
        const { loop, loopClonesPerSide } = this.props;
        return loop ? index + loopClonesPerSide : index;
    }

    _getFirstItem(index, props = this.props) {
        const { loopClonesPerSide } = props;
        const itemsLength = this._getCustomDataLength(props);

        if (!itemsLength || index > itemsLength - 1 || index < 0) {
            return 0;
        }

        return this._enableLoop() ? index + loopClonesPerSide : index;
    }

    _getWrappedRef() {
        if (
            this._carouselRef &&
            ((this._needsScrollView() && this._carouselRef.scrollTo) ||
                (!this._needsScrollView() && this._carouselRef.scrollToOffset))
        ) {
            return this._carouselRef;
        }
        // https://github.com/facebook/react-native/issues/10635
        // https://stackoverflow.com/a/48786374/8412141
        return (
            this._carouselRef &&
            this._carouselRef.getNode &&
            this._carouselRef.getNode()
        );
    }

    _getScrollEnabled() {
        return this._scrollEnabled;
    }

    _setScrollEnabled(scrollEnabled = true) {
        const wrappedRef = this._getWrappedRef();

        if (!wrappedRef || !wrappedRef.setNativeProps) {
            return;
        }

        // 'setNativeProps()' is used instead of 'setState()' because the latter
        // really takes a toll on Android behavior when momentum is disabled
        wrappedRef.setNativeProps({ scrollEnabled });
        this._scrollEnabled = scrollEnabled;
    }

    _getKeyExtractor(item, index) {
        return this._needsScrollView()
            ? `scrollview-item-${index}`
            : `flatlist-item-${index}`;
    }

    _getScrollOffset(event) {
        const { vertical } = this.props;
        return (
            (event &&
                event.nativeEvent &&
                event.nativeEvent.contentOffset &&
                event.nativeEvent.contentOffset[vertical ? "y" : "x"]) ||
            0
        );
    }

    _getContainerInnerMargin(opposite = false) {
        const {
            sliderWidth,
            sliderHeight,
            itemWidth,
            itemHeight,
            vertical,
            activeSlideAlignment,
        } = this.props;

        if (
            (activeSlideAlignment === "start" && !opposite) ||
            (activeSlideAlignment === "end" && opposite)
        ) {
            return 0;
        } else if (
            (activeSlideAlignment === "end" && !opposite) ||
            (activeSlideAlignment === "start" && opposite)
        ) {
            return vertical ? sliderHeight - itemHeight : sliderWidth - itemWidth;
        } else {
            return vertical
                ? (sliderHeight - itemHeight) / 2
                : (sliderWidth - itemWidth) / 2;
        }
    }

    _getViewportOffset() {
        const {
            sliderWidth,
            sliderHeight,
            itemWidth,
            itemHeight,
            vertical,
            activeSlideAlignment,
        } = this.props;

        if (activeSlideAlignment === "start") {
            return vertical ? itemHeight / 2 : itemWidth / 2;
        } else if (activeSlideAlignment === "end") {
            return vertical
                ? sliderHeight - itemHeight / 2
                : sliderWidth - itemWidth / 2;
        } else {
            return vertical ? sliderHeight / 2 : sliderWidth / 2;
        }
    }

    _getCenter(offset) {
        return offset + this._getViewportOffset() - this._getContainerInnerMargin();
    }

    _getActiveItem(offset) {
        const { activeSlideOffset, swipeThreshold } = this.props;
        const center = this._getCenter(offset);
        const centerOffset = activeSlideOffset || swipeThreshold;

        for (let i = 0; i < this._positions.length; i++) {
            const { start, end } = this._positions[i];
            if (center + centerOffset >= start && center - centerOffset <= end) {
                return i;
            }
        }

        const lastIndex = this._positions.length - 1;
        if (
            this._positions[lastIndex] &&
            center - centerOffset > this._positions[lastIndex].end
        ) {
            return lastIndex;
        }

        return 0;
    }

    _initPositionsAndInterpolators(props = this.props) {
        const { data, itemWidth, itemHeight, scrollInterpolator, vertical } = props;
        const sizeRef = vertical ? itemHeight : itemWidth;

        if (!data || !data.length) {
            return;
        }

        const interpolators = [];
        this._positions = [];

        this._getCustomData(props).forEach((itemData, index) => {
            const _index = this._getCustomIndex(index, props);
            let animatedValue;

            this._positions[index] = {
                start: index * sizeRef,
                end: index * sizeRef + sizeRef,
            };

            if (!this._shouldAnimateSlides(props)) {
                animatedValue = new Animated.Value(1);
            } else if (this._shouldUseCustomAnimation()) {
                animatedValue = new Animated.Value(_index === this._activeItem ? 1 : 0);
            } else {
                let interpolator;

                if (scrollInterpolator) {
                    interpolator = scrollInterpolator(_index, props);
                } else if (this._shouldUseStackLayout()) {
                    interpolator = stackScrollInterpolator(_index, props);
                } else if (this._shouldUseTinderLayout()) {
                    interpolator = tinderScrollInterpolator(_index, props);
                }

                if (
                    !interpolator ||
                    !interpolator.inputRange ||
                    !interpolator.outputRange
                ) {
                    interpolator = defaultScrollInterpolator(_index, props);
                }

                animatedValue = this._scrollPos.interpolate({
                    ...interpolator,
                    extrapolate: "clamp",
                });
            }

            interpolators.push(animatedValue);
        });

        this.setState({ interpolators });
    }

    _getSlideAnimation(index, toValue) {
        const { interpolators } = this.state;
        const { activeAnimationType, activeAnimationOptions } = this.props;

        const animatedValue = interpolators && interpolators[index];

        if (!animatedValue && animatedValue !== 0) {
            return null;
        }

        const animationCommonOptions = {
            isInteraction: false,
            useNativeDriver: true,
            ...activeAnimationOptions,
            toValue: toValue,
        };

        return Animated.parallel([
            Animated["timing"](animatedValue, {
                ...animationCommonOptions,
                easing: Easing.linear,
            }),
            Animated[activeAnimationType](animatedValue, {
                ...animationCommonOptions,
            }),
        ]);
    }

    _playCustomSlideAnimation(current, next) {
        const { interpolators } = this.state;
        const itemsLength = this._getCustomDataLength();
        const _currentIndex = this._getCustomIndex(current);
        const _currentDataIndex = this._getDataIndex(_currentIndex);
        const _nextIndex = this._getCustomIndex(next);
        const _nextDataIndex = this._getDataIndex(_nextIndex);
        const animations = [];

        // Keep animations in sync when looping
        if (this._enableLoop()) {
            for (let i = 0; i < itemsLength; i++) {
                if (this._getDataIndex(i) === _currentDataIndex && interpolators[i]) {
                    animations.push(this._getSlideAnimation(i, 0));
                } else if (
                    this._getDataIndex(i) === _nextDataIndex &&
                    interpolators[i]
                ) {
                    animations.push(this._getSlideAnimation(i, 1));
                }
            }
        } else {
            if (interpolators[current]) {
                animations.push(this._getSlideAnimation(current, 0));
            }
            if (interpolators[next]) {
                animations.push(this._getSlideAnimation(next, 1));
            }
        }

        Animated.parallel(animations, { stopTogether: false }).start();
    }

    _hackActiveSlideAnimation(index, goTo, force = false) {
        const { data } = this.props;

        if (
            !this._mounted ||
            !this._carouselRef ||
            !this._positions[index] ||
            (!force && this._enableLoop())
        ) {
            return;
        }

        const offset = this._positions[index] && this._positions[index].start;

        if (!offset && offset !== 0) {
            return;
        }

        const itemsLength = data && data.length;
        const direction = goTo || itemsLength === 1 ? "start" : "end";

        this._scrollTo(offset + (direction === "start" ? -1 : 1), false);

        clearTimeout(this._hackSlideAnimationTimeout);
        this._hackSlideAnimationTimeout = setTimeout(() => {
            this._scrollTo(offset, false);
        }, 50); // works randomly when set to '0'
    }

    _lockScroll() {
        const { lockScrollTimeoutDuration } = this.props;
        clearTimeout(this._lockScrollTimeout);
        this._lockScrollTimeout = setTimeout(() => {
            this._releaseScroll();
        }, lockScrollTimeoutDuration);
        this._setScrollEnabled(false);
    }

    _releaseScroll() {
        clearTimeout(this._lockScrollTimeout);
        this._setScrollEnabled(true);
    }

    _repositionScroll(index) {
        const { data, loopClonesPerSide } = this.props;
        const dataLength = data && data.length;

        if (
            !this._enableLoop() ||
            !dataLength ||
            (index >= loopClonesPerSide && index < dataLength + loopClonesPerSide)
        ) {
            return;
        }

        let repositionTo = index;

        if (index >= dataLength + loopClonesPerSide) {
            repositionTo = index - dataLength;
        } else if (index < loopClonesPerSide) {
            repositionTo = index + dataLength;
        }

        this._snapToItem(repositionTo, false, false, false, false);
    }

    _scrollTo(offset, animated = true) {
        const { vertical } = this.props;
        const wrappedRef = this._getWrappedRef();

        if (!this._mounted || !wrappedRef) {
            return;
        }

        const specificOptions = this._needsScrollView()
            ? {
                x: vertical ? 0 : offset,
                y: vertical ? offset : 0,
            }
            : {
                offset,
            };
        const options = {
            ...specificOptions,
            animated,
        };

        if (this._needsScrollView()) {
            wrappedRef.scrollTo(options);
        } else {
            wrappedRef.scrollToOffset(options);
        }
    }

    _onScroll(event?) {
        const { callbackOffsetMargin, enableMomentum, onScroll } = this.props;

        const scrollOffset = event
            ? this._getScrollOffset(event)
            : this._currentContentOffset;
        const nextActiveItem = this._getActiveItem(scrollOffset);
        const itemReached = nextActiveItem === this._itemToSnapTo;
        const scrollConditions =
            scrollOffset >= this._scrollOffsetRef - callbackOffsetMargin &&
            scrollOffset <= this._scrollOffsetRef + callbackOffsetMargin;

        this._currentContentOffset = scrollOffset;
        this._onScrollTriggered = true;
        this._lastScrollDate = Date.now();

        if (
            this._activeItem !== nextActiveItem &&
            this._shouldUseCustomAnimation()
        ) {
            this._playCustomSlideAnimation(this._activeItem, nextActiveItem);
        }

        if (enableMomentum) {
            clearTimeout(this._snapNoMomentumTimeout);

            if (this._activeItem !== nextActiveItem) {
                this._activeItem = nextActiveItem;
            }

            if (itemReached) {
                if (this._canFireBeforeCallback) {
                    this._onBeforeSnap(this._getDataIndex(nextActiveItem));
                }

                if (scrollConditions && this._canFireCallback) {
                    this._onSnap(this._getDataIndex(nextActiveItem));
                }
            }
        } else if (this._activeItem !== nextActiveItem && itemReached) {
            if (this._canFireBeforeCallback) {
                this._onBeforeSnap(this._getDataIndex(nextActiveItem));
            }

            if (scrollConditions) {
                this._activeItem = nextActiveItem;

                if (this._canLockScroll()) {
                    this._releaseScroll();
                }

                if (this._canFireCallback) {
                    this._onSnap(this._getDataIndex(nextActiveItem));
                }
            }
        }

        if (
            nextActiveItem === this._itemToSnapTo &&
            scrollOffset === this._scrollOffsetRef
        ) {
            this._repositionScroll(nextActiveItem);
        }

        if (typeof onScroll === "function" && event) {
            onScroll(event);
        }
    }

    _onStartShouldSetResponderCapture(event) {
        const { onStartShouldSetResponderCapture } = this.props;

        if (onStartShouldSetResponderCapture) {
            onStartShouldSetResponderCapture(event);
        }

        return this._getScrollEnabled();
    }

    _onTouchStart() {
        const { onTouchStart } = this.props;

        // `onTouchStart` is fired even when `scrollEnabled` is set to `false`
        if (this._getScrollEnabled() !== false && this._autoplaying) {
            this.pauseAutoPlay();
        }

        if (onTouchStart) {
            onTouchStart();
        }
    }

    _onTouchEnd() {
        const { onTouchEnd } = this.props;

        if (
            this._getScrollEnabled() !== false &&
            this._autoplay &&
            !this._autoplaying
        ) {
            // This event is buggy on Android, so a fallback is provided in _onScrollEnd()
            this.startAutoplay();
        }

        if (onTouchEnd) {
            onTouchEnd();
        }
    }

    // Used when `enableSnap` is ENABLED
    _onScrollBeginDrag(event) {
        const { onScrollBeginDrag } = this.props;

        if (!this._getScrollEnabled()) {
            return;
        }

        this._scrollStartOffset = this._getScrollOffset(event);
        this._scrollStartActive = this._getActiveItem(this._scrollStartOffset);
        this._ignoreNextMomentum = false;
        // this._canFireCallback = false;

        if (onScrollBeginDrag) {
            onScrollBeginDrag(event);
        }
    }

    // Used when `enableMomentum` is DISABLED
    _onScrollEndDrag(event) {
        const { onScrollEndDrag } = this.props;

        if (this._carouselRef) {
            this._onScrollEnd && this._onScrollEnd();
        }

        if (onScrollEndDrag) {
            onScrollEndDrag(event);
        }
    }

    // Used when `enableMomentum` is ENABLED
    _onMomentumScrollEnd(event) {
        const { onMomentumScrollEnd } = this.props;

        if (this._carouselRef) {
            this._onScrollEnd && this._onScrollEnd();
        }

        if (onMomentumScrollEnd) {
            onMomentumScrollEnd(event);
        }
    }

    _onScrollEnd(event?) {
        const { autoplayDelay, enableSnap } = this.props;

        if (this._ignoreNextMomentum) {
            // iOS fix
            this._ignoreNextMomentum = false;
            return;
        }

        if (this._currentContentOffset === this._scrollEndOffset) {
            return;
        }

        this._scrollEndOffset = this._currentContentOffset;
        this._scrollEndActive = this._getActiveItem(this._scrollEndOffset);

        if (enableSnap) {
            this._snapScroll(this._scrollEndOffset - this._scrollStartOffset);
        }

        // The touchEnd event is buggy on Android, so this will serve as a fallback whenever needed
        // https://github.com/facebook/react-native/issues/9439
        if (this._autoplay && !this._autoplaying) {
            clearTimeout(this._enableAutoplayTimeout);
            this._enableAutoplayTimeout = setTimeout(() => {
                this.startAutoplay();
            }, autoplayDelay + 50);
        }
    }

    // Due to a bug, this event is only fired on iOS
    // https://github.com/facebook/react-native/issues/6791
    // it's fine since we're only fixing an iOS bug in it, so ...
    _onTouchRelease(event) {
        const { enableMomentum } = this.props;

        if (enableMomentum && IS_IOS) {
            clearTimeout(this._snapNoMomentumTimeout);
            this._snapNoMomentumTimeout = setTimeout(() => {
                this._snapToItem(this._activeItem);
            }, 100);
        }
    }

    _onLayout(event) {
        const { onLayout } = this.props;

        // Prevent unneeded actions during the first 'onLayout' (triggered on init)
        if (this._onLayoutInitDone) {
            this._initPositionsAndInterpolators();
            this._snapToItem(this._activeItem, false, false, false, false);
        } else {
            this._onLayoutInitDone = true;
        }

        if (onLayout) {
            onLayout(event);
        }
    }

    _snapScroll(delta) {
        const { swipeThreshold } = this.props;

        // When using momentum and releasing the touch with
        // no velocity, scrollEndActive will be undefined (iOS)
        if (!this._scrollEndActive && this._scrollEndActive !== 0 && IS_IOS) {
            this._scrollEndActive = this._scrollStartActive;
        }

        if (this._scrollStartActive !== this._scrollEndActive) {
            // Snap to the new active item
            this._snapToItem(this._scrollEndActive);
        } else {
            // Snap depending on delta
            if (delta > 0) {
                if (delta > swipeThreshold) {
                    this._snapToItem(this._scrollStartActive + 1);
                } else {
                    this._snapToItem(this._scrollEndActive);
                }
            } else if (delta < 0) {
                if (delta < -swipeThreshold) {
                    this._snapToItem(this._scrollStartActive - 1);
                } else {
                    this._snapToItem(this._scrollEndActive);
                }
            } else {
                // Snap to current
                this._snapToItem(this._scrollEndActive);
            }
        }
    }

    _snapToItem(
        index,
        animated = true,
        fireCallback = true,
        initial = false,
        lockScroll = true
    ) {
        const { enableMomentum, onSnapToItem, onBeforeSnapToItem } = this.props;
        const itemsLength = this._getCustomDataLength();
        const wrappedRef = this._getWrappedRef();

        if (!itemsLength || !wrappedRef) {
            return;
        }

        if (!index || index < 0) {
            index = 0;
        } else if (itemsLength > 0 && index >= itemsLength) {
            index = itemsLength - 1;
        }

        if (index !== this._previousActiveItem) {
            this._previousActiveItem = index;

            // Placed here to allow overscrolling for edges items
            if (lockScroll && this._canLockScroll()) {
                this._lockScroll();
            }

            if (fireCallback) {
                if (onBeforeSnapToItem) {
                    this._canFireBeforeCallback = true;
                }

                if (onSnapToItem) {
                    this._canFireCallback = true;
                }
            }
        }

        this._itemToSnapTo = index;
        this._scrollOffsetRef =
            this._positions[index] && this._positions[index].start;
        this._onScrollTriggered = false;

        if (!this._scrollOffsetRef && this._scrollOffsetRef !== 0) {
            return;
        }

        this._scrollTo(this._scrollOffsetRef, animated);

        this._scrollEndOffset = this._currentContentOffset;

        if (enableMomentum) {
            // iOS fix, check the note in the constructor
            if (!initial) {
                this._ignoreNextMomentum = true;
            }

            // When momentum is enabled and the user is overscrolling or swiping very quickly,
            // 'onScroll' is not going to be triggered for edge items. Then callback won't be
            // fired and loop won't work since the scrollview is not going to be repositioned.
            // As a workaround, '_onScroll()' will be called manually for these items if a given
            // condition hasn't been met after a small delay.
            // WARNING: this is ok only when relying on 'momentumScrollEnd', not with 'scrollEndDrag'
            if (index === 0 || index === itemsLength - 1) {
                clearTimeout(this._edgeItemTimeout);
                this._edgeItemTimeout = setTimeout(() => {
                    if (
                        !initial &&
                        index === this._activeItem &&
                        !this._onScrollTriggered
                    ) {
                        this._onScroll();
                    }
                }, 250);
            }
        }
    }

    _onBeforeSnap(index) {
        const { onBeforeSnapToItem } = this.props;

        if (!this._carouselRef) {
            return;
        }

        this._canFireBeforeCallback = false;
        onBeforeSnapToItem && onBeforeSnapToItem(index);
    }

    _onSnap(index) {
        const { onSnapToItem } = this.props;

        if (!this._carouselRef) {
            return;
        }

        this._canFireCallback = false;
        onSnapToItem && onSnapToItem(index);
    }

    startAutoplay() {
        const { autoplayInterval, autoplayDelay } = this.props;
        this._autoplay = true;

        if (this._autoplaying) {
            return;
        }

        clearTimeout(this._autoplayTimeout);
        this._autoplayTimeout = setTimeout(() => {
            this._autoplaying = true;
            this._autoplayInterval = setInterval(() => {
                if (this._autoplaying) {
                    this.snapToNext();
                }
            }, autoplayInterval);
        }, autoplayDelay);
    }

    pauseAutoPlay() {
        this._autoplaying = false;
        clearTimeout(this._autoplayTimeout);
        clearTimeout(this._enableAutoplayTimeout);
        clearInterval(this._autoplayInterval);
    }

    stopAutoplay() {
        this._autoplay = false;
        this.pauseAutoPlay();
    }

    snapToItem(index, animated = true, fireCallback = true) {
        if (!index || index < 0) {
            index = 0;
        }

        const positionIndex = this._getPositionIndex(index);

        if (positionIndex === this._activeItem) {
            return;
        }

        this._snapToItem(positionIndex, animated, fireCallback);
    }

    snapToNext(animated = true, fireCallback = true) {
        const itemsLength = this._getCustomDataLength();

        let newIndex = this._activeItem + 1;
        if (newIndex > itemsLength - 1) {
            if (!this._enableLoop()) {
                return;
            }
            newIndex = 0;
        }
        this._snapToItem(newIndex, animated, fireCallback);
    }

    snapToPrev(animated = true, fireCallback = true) {
        const itemsLength = this._getCustomDataLength();

        let newIndex = this._activeItem - 1;
        if (newIndex < 0) {
            if (!this._enableLoop()) {
                return;
            }
            newIndex = itemsLength - 1;
        }
        this._snapToItem(newIndex, animated, fireCallback);
    }

    // https://github.com/facebook/react-native/issues/1831#issuecomment-231069668
    triggerRenderingHack(offset) {
        // Avoid messing with user scroll
        if (Date.now() - this._lastScrollDate < 500) {
            return;
        }

        const scrollPosition = this._currentContentOffset;
        if (!scrollPosition && scrollPosition !== 0) {
            return;
        }

        const scrollOffset = offset || (scrollPosition === 0 ? 1 : -1);
        this._scrollTo(scrollPosition + scrollOffset, false);
    }

    _getSlideInterpolatedStyle(index, animatedValue) {
        const { layoutCardOffset, slideInterpolatedStyle } = this.props;

        if (slideInterpolatedStyle) {
            return slideInterpolatedStyle(index, animatedValue, this.props);
        } else if (this._shouldUseTinderLayout()) {
            return tinderAnimatedStyles(
                index,
                animatedValue,
                this.props,
                layoutCardOffset
            );
        } else if (this._shouldUseStackLayout()) {
            return stackAnimatedStyles(
                index,
                animatedValue,
                this.props,
                layoutCardOffset
            );
        } else if (this._shouldUseShiftLayout()) {
            return shiftAnimatedStyles(index, animatedValue, this.props);
        } else {
            return defaultAnimatedStyles(index, animatedValue, this.props);
        }
    }

    _renderItem({ item, index }) {
        const { interpolators } = this.state;
        const {
            hasParallaxImages,
            itemWidth,
            itemHeight,
            keyExtractor,
            renderItem,
            sliderHeight,
            sliderWidth,
            slideStyle,
            vertical,
        } = this.props;

        const animatedValue = interpolators && interpolators[index];

        if (!animatedValue && animatedValue !== 0) {
            return null;
        }

        const animate = this._shouldAnimateSlides();
        const Component = animate ? Animated.View : View;
        const animatedStyle = animate
            ? this._getSlideInterpolatedStyle(index, animatedValue)
            : {};

        const parallaxProps = hasParallaxImages
            ? {
                scrollPosition: this._scrollPos,
                carouselRef: this._carouselRef,
                vertical,
                sliderWidth,
                sliderHeight,
                itemWidth,
                itemHeight,
            }
            : undefined;

        const mainDimension = vertical
            ? { height: itemHeight }
            : { width: itemWidth };
        const specificProps = this._needsScrollView()
            ? {
                key: keyExtractor
                    // @ts-ignore
                    ? keyExtractor(item, index)
                    : this._getKeyExtractor(item, index),
            }
            : {};

        return (
            <Component
                style={[mainDimension, slideStyle, animatedStyle] as any}
                pointerEvents={"box-none"}
                {...specificProps}
            >
                {renderItem({ item, index }, parallaxProps)}
            </Component>
        );
    }

    _getComponentOverridableProps = (): any => {
        const {
            enableMomentum,
            itemWidth,
            itemHeight,
            loopClonesPerSide,
            sliderWidth,
            sliderHeight,
            vertical,
        } = this.props;

        const visibleItems =
            Math.ceil(
                vertical ? sliderHeight / itemHeight : sliderWidth / itemWidth
            ) + 1;
        const initialNumPerSide = this._enableLoop() ? loopClonesPerSide : 2;
        const initialNumToRender = visibleItems + initialNumPerSide * 2;
        const maxToRenderPerBatch = 1 + initialNumToRender * 2;
        const windowSize = maxToRenderPerBatch;

        const specificProps = !this._needsScrollView()
            ? {
                initialNumToRender: initialNumToRender,
                maxToRenderPerBatch: maxToRenderPerBatch,
                windowSize: windowSize,
                // updateCellsBatchingPeriod
            }
            : {};

        return {
            decelerationRate: enableMomentum ? 0.9 : "fast",
            showsHorizontalScrollIndicator: false,
            showsVerticalScrollIndicator: false,
            overScrollMode: "never",
            automaticallyAdjustContentInsets: false,
            directionalLockEnabled: true,
            pinchGestureEnabled: false,
            scrollsToTop: false,
            removeClippedSubviews: !this._needsScrollView(),
            inverted: this._needsRTLAdaptations(),
            // renderToHardwareTextureAndroid: true,
            ...specificProps,
        };
    }

    _getComponentStaticProps() {
        const { hideCarousel } = this.state;
        const {
            containerCustomStyle,
            contentContainerCustomStyle,
            keyExtractor,
            sliderWidth,
            sliderHeight,
            style,
            vertical,
        } = this.props;

        const containerStyle = [
            containerCustomStyle || style || {},
            hideCarousel ? { opacity: 0 } : {},
            vertical
                ? { height: sliderHeight, flexDirection: "column" }
                : // LTR hack; see https://github.com/facebook/react-native/issues/11960
                // and https://github.com/facebook/react-native/issues/13100#issuecomment-328986423
                {
                    width: sliderWidth,
                    flexDirection: this._needsRTLAdaptations() ? "row-reverse" : "row",
                },
        ];
        const contentContainerStyle = [
            vertical
                ? {
                    paddingTop: this._getContainerInnerMargin(),
                    paddingBottom: this._getContainerInnerMargin(true),
                }
                : {
                    paddingLeft: this._getContainerInnerMargin(),
                    paddingRight: this._getContainerInnerMargin(true),
                },
            contentContainerCustomStyle || {},
        ];

        const specificProps = !this._needsScrollView()
            ? {
                // extraData: this.state,
                renderItem: this._renderItem,
                numColumns: 1,
                keyExtractor: keyExtractor || this._getKeyExtractor,
            }
            : {};

        return {
            ref: c => (this._carouselRef = c),
            data: this._getCustomData(),
            style: containerStyle,
            contentContainerStyle: contentContainerStyle,
            horizontal: !vertical,
            scrollEventThrottle: 1,
            onScroll: this._onScrollHandler,
            onScrollBeginDrag: this._onScrollBeginDrag,
            onScrollEndDrag: this._onScrollEndDrag,
            onMomentumScrollEnd: this._onMomentumScrollEnd,
            onResponderRelease: this._onTouchRelease,
            onStartShouldSetResponderCapture: this._onStartShouldSetResponderCapture,
            onTouchStart: this._onTouchStart,
            onTouchEnd: this._onScrollEnd,
            onLayout: this._onLayout,
            ...specificProps,
        };
    }

    render() {
        const { data, renderItem, useScrollView } = this.props;

        if (!data || !renderItem) {
            return null;
        }

        const props = {
            ...this._getComponentOverridableProps(),
            ...this.props,
            ...this._getComponentStaticProps(),
        };

        const ScrollViewComponent =
            typeof useScrollView === "function" ? useScrollView : AnimatedScrollView;

        return this._needsScrollView() ? (
            // @ts-ignore
            <ScrollViewComponent {...props}>
                {this._getCustomData().map((item, index) => {
                    return this._renderItem({ item, index });
                })}
            </ScrollViewComponent>
        ) : (
        // @ts-ignore
            <AnimatedFlatList {...props} />
        );
    }
}
