import React, { useState, useEffect, useCallback, useMemo, useImperativeHandle, forwardRef, useRef, } from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import Autoplay from 'embla-carousel-autoplay';
import { ChevronLeftLarge, ChevronRightLarge, ChevronLeftLargeSmall, ChevronRightLargeSmall, ChevronLeftSmall, ChevronRightSmall, } from '@wix/wix-ui-icons-common';
// This is here and not in the test setup because we don't want consumers to need to run it as well
import { isTestEnv, registerWindowMatchMedia, registerIntersectionObserver, registerResizeObserver, } from './utils/match-media-register';
import { st, classes, vars } from './Carousel.st.css.js';
import Pagination from './Pagination';
import SliderArrow from './SliderArrow';
import Loader from '../Loader';
import Proportion from '../Proportion';
import { PREDEFINED_RATIOS } from '../Proportion/Proportion.constants';
import { DATA_HOOKS } from './Carousel.constants';
if (isTestEnv && typeof window !== 'undefined' && !window.matchMedia) {
    registerWindowMatchMedia();
}
if (isTestEnv) {
    registerIntersectionObserver();
    registerResizeObserver();
}
const AUTOPLAY_SPEED = 3000;
const TRANSITION_SPEED = 600;
function inferDirection(oldIndex, newIndex, count, isLoop) {
    if (!isLoop) {
        return newIndex > oldIndex ? 'next' : 'prev';
    }
    const forward = (newIndex - oldIndex + count) % count;
    const backward = (oldIndex - newIndex + count) % count;
    return forward <= backward ? 'next' : 'prev';
}
function getAlignDirection(direction) {
    return direction === 'next' ? 'right' : 'left';
}
function clampToEngineLimits(engine, value) {
    return Math.max(engine.limit.min, Math.min(engine.limit.max, value));
}
function normalizeIndex(index, count) {
    if (count === 0)
        return 0;
    const result = index % count;
    return result < 0 ? result + count : result;
}
function leftIcon(controlSize) {
    switch (controlSize) {
        case 'tiny':
            return React.createElement(ChevronLeftSmall, null);
        case 'small':
            return React.createElement(ChevronLeftLargeSmall, null);
        default:
            return React.createElement(ChevronLeftLarge, null);
    }
}
function rightIcon(controlSize) {
    switch (controlSize) {
        case 'tiny':
            return React.createElement(ChevronRightSmall, null);
        case 'small':
            return React.createElement(ChevronRightLargeSmall, null);
        default:
            return React.createElement(ChevronRightLarge, null);
    }
}
const Carousel = forwardRef((props, ref) => {
    const { dataHook, className, images = [], imagesPosition = 'center top', imagesFit = 'contain', children, buttonSkin = 'standard', showControlsShadow = false, infinite = true, autoplay = false, dots = true, variableWidth = false, initialSlideIndex = 0, afterChange, beforeChange, controlsPosition = 'sides', controlsSize = 'medium', controlsStartEnd = 'disabled', gradientColor, gradient = false, slideSpacing, animationDuration, slidingType = 'align-to-start', align: alignProp, startEndOffset: startEndOffsetProp, } = props;
    // Priority: align > startEndOffset > default align='start'.
    // When the consumer explicitly sets `align`, it takes full control of slide
    // positioning and `startEndOffset` is ignored.  When only `startEndOffset`
    // is provided (without `align`), peeking offsets are applied on top of the
    // default 'start' alignment.
    const align = alignProp ?? 'start';
    const startEndOffset = alignProp !== undefined ? undefined : startEndOffsetProp;
    const [loadedImages, setLoadedImages] = useState([]);
    const slideCount = children ? React.Children.count(children) : images.length;
    const normalizedInitialIndex = normalizeIndex(initialSlideIndex, slideCount);
    const [selectedIndex, setSelectedIndex] = useState(normalizedInitialIndex);
    const currentIndexRef = useRef(normalizedInitialIndex);
    const programmaticTargetRef = useRef(null);
    const shouldLoop = infinite && slideCount > 1;
    const peekOffsetTargetRef = useRef(null);
    const peekOffsetDirectionRef = useRef(null);
    const [prevBtnDisabled, setPrevBtnDisabled] = useState(!shouldLoop && normalizedInitialIndex === 0);
    const [nextBtnDisabled, setNextBtnDisabled] = useState(!shouldLoop && normalizedInitialIndex === slideCount - 1);
    const [slidesInView, setSlidesInView] = useState(
    // In test env, IntersectionObserver is a no-op so embla.slidesInView()
    // stays empty.  Seed with the initial slide so aria-hidden is correct.
    isTestEnv ? [normalizedInitialIndex] : []);
    const emblaDuration = animationDuration
        ? Math.min(Math.max(animationDuration / 25, 20), 60)
        : TRANSITION_SPEED / 25;
    const slidesToScroll = useMemo(() => {
        switch (slidingType) {
            case 'align-to-start':
                return undefined;
            case 'reveal-one':
                return 1;
            case 'reveal-chunk':
                return 'auto';
            default:
                return undefined;
        }
    }, [slidingType]);
    const containScroll = useMemo(() => {
        if (variableWidth && shouldLoop) {
            return undefined;
        }
        return 'trimSnaps';
    }, [shouldLoop, variableWidth]);
    const emblaOptions = useMemo(() => ({
        loop: shouldLoop,
        startIndex: normalizedInitialIndex,
        duration: emblaDuration,
        align,
        slidesToScroll,
        containScroll,
        skipSnaps: false,
    }), [
        align,
        containScroll,
        emblaDuration,
        normalizedInitialIndex,
        shouldLoop,
        slidesToScroll,
    ]);
    const [emblaRef, emblaApi] = useEmblaCarousel(emblaOptions, autoplay
        ? [
            Autoplay({
                delay: AUTOPLAY_SPEED,
                stopOnInteraction: false,
                stopOnMouseEnter: true,
            }),
        ]
        : []);
    // Slides use padding-left for spacing, so at each snap the gap sits at the
    // left viewport edge while the right edge is flush. Peek right needs the
    // extra gap added; peek left does not (the gap is already visible).
    const calculatePeekPosition = useCallback((baseX, direction) => {
        if (!startEndOffset || startEndOffset <= 0) {
            return baseX;
        }
        const alignDir = getAlignDirection(direction);
        const gap = slideSpacing ?? 0;
        return alignDir === 'right'
            ? baseX - (startEndOffset + gap)
            : baseX + startEndOffset;
    }, [startEndOffset, slideSpacing]);
    const transitionSlide = useCallback((fromIndex, toIndex) => {
        if (fromIndex === toIndex)
            return false;
        beforeChange?.(fromIndex, toIndex);
        setSelectedIndex(toIndex);
        currentIndexRef.current = toIndex;
        return true;
    }, [beforeChange]);
    const onSelect = useCallback((api) => {
        if (!api)
            return;
        const emblaIndex = api.selectedScrollSnap();
        const pendingTarget = programmaticTargetRef.current;
        const newIndex = pendingTarget !== null ? pendingTarget : emblaIndex;
        const oldIndex = currentIndexRef.current;
        programmaticTargetRef.current = null;
        if (!shouldLoop) {
            let prevDisabled = !api.canScrollPrev();
            let nextDisabled = !api.canScrollNext();
            // When startEndOffset is active, the peek adjustment may push the
            // scroll position beyond the engine limit at snaps that are close to
            // the boundary.  After clamping, the visual position is identical to
            // the true last/first snap, so we treat the carousel as "at the end"
            // and disable the corresponding button early.
            if (startEndOffset && startEndOffset > 0) {
                const engine = api.internalEngine();
                const currentSnap = engine.scrollSnaps[emblaIndex];
                if (!nextDisabled) {
                    const nextPeek = calculatePeekPosition(currentSnap, 'next');
                    if (nextPeek < engine.limit.min) {
                        nextDisabled = true;
                    }
                }
                if (!prevDisabled) {
                    const prevPeek = calculatePeekPosition(currentSnap, 'prev');
                    if (prevPeek > engine.limit.max) {
                        prevDisabled = true;
                    }
                }
            }
            setPrevBtnDisabled(prevDisabled);
            setNextBtnDisabled(nextDisabled);
        }
        else {
            setPrevBtnDisabled(false);
            setNextBtnDisabled(false);
        }
        if (transitionSlide(oldIndex, newIndex)) {
            // Apply startEndOffset for drag/touch scrolls (programmatic handled in performScroll)
            if (pendingTarget === null &&
                startEndOffset &&
                startEndOffset > 0 &&
                !isTestEnv) {
                const dir = inferDirection(oldIndex, newIndex, slideCount, shouldLoop);
                const engine = api.internalEngine();
                let adjusted = calculatePeekPosition(engine.target.get(), dir);
                if (!shouldLoop) {
                    adjusted = clampToEngineLimits(engine, adjusted);
                }
                engine.target.set(adjusted);
                peekOffsetTargetRef.current = adjusted;
                peekOffsetDirectionRef.current = dir;
            }
        }
    }, [
        transitionSlide,
        shouldLoop,
        startEndOffset,
        slideCount,
        calculatePeekPosition,
    ]);
    const onSettle = useCallback(() => {
        afterChange?.(currentIndexRef.current);
    }, [afterChange]);
    useEffect(() => {
        if (!emblaApi)
            return;
        emblaApi.on('select', onSelect);
        return () => {
            emblaApi.off('select', onSelect);
        };
    }, [emblaApi, onSelect]);
    useEffect(() => {
        if (!emblaApi)
            return;
        emblaApi.on('settle', onSettle);
        return () => {
            emblaApi.off('settle', onSettle);
        };
    }, [emblaApi, onSettle]);
    // On click (no drag), Embla resets target to snap — restore our offset.
    useEffect(() => {
        if (!emblaApi || !startEndOffset || startEndOffset <= 0 || isTestEnv)
            return;
        const onPointerUp = () => {
            const expected = peekOffsetTargetRef.current;
            if (expected === null)
                return;
            emblaApi.internalEngine().target.set(expected);
        };
        emblaApi.on('pointerUp', onPointerUp);
        return () => {
            emblaApi.off('pointerUp', onPointerUp);
        };
    }, [emblaApi, startEndOffset]);
    // Track which slides are currently visible in the viewport for a11y.
    // In test env IntersectionObserver is a no-op, so embla.slidesInView()
    // always returns [].  We skip this effect and rely on the seeded initial
    // state + syncStateForTests to keep slidesInView accurate.
    useEffect(() => {
        if (!emblaApi || isTestEnv)
            return;
        const updateSlidesInView = () => {
            setSlidesInView(emblaApi.slidesInView());
        };
        emblaApi.on('slidesInView', updateSlidesInView);
        // Initial sync
        updateSlidesInView();
        return () => {
            emblaApi.off('slidesInView', updateSlidesInView);
        };
    }, [emblaApi]);
    const syncStateForTests = useCallback((newIndex) => {
        transitionSlide(currentIndexRef.current, newIndex);
        // In tests there is no animation so the settle event won't fire.
        // Fire afterChange synchronously to match production behaviour.
        afterChange?.(newIndex);
        if (!shouldLoop) {
            setPrevBtnDisabled(newIndex === 0);
            setNextBtnDisabled(newIndex === slideCount - 1);
        }
        // IntersectionObserver is a no-op in tests, so keep slidesInView in sync.
        setSlidesInView([newIndex]);
    }, [transitionSlide, afterChange, shouldLoop, slideCount]);
    // Nudges Embla's internal animation target to integrate startEndOffset
    // into the scroll animation as a single smooth motion.
    const performScroll = useCallback((targetIndex, direction) => {
        const hasOffset = startEndOffset !== undefined && startEndOffset > 0 && !!direction;
        if (hasOffset && !isTestEnv && emblaApi) {
            emblaApi.scrollTo(targetIndex, false);
            const engine = emblaApi.internalEngine();
            let adjustedTarget = calculatePeekPosition(engine.target.get(), direction);
            if (!shouldLoop) {
                adjustedTarget = clampToEngineLimits(engine, adjustedTarget);
            }
            engine.target.set(adjustedTarget);
            const snapTarget = engine.scrollSnaps[targetIndex];
            if (Math.abs(adjustedTarget - snapTarget) > 0.5) {
                peekOffsetTargetRef.current = adjustedTarget;
                peekOffsetDirectionRef.current = direction;
            }
            else {
                peekOffsetTargetRef.current = null;
                peekOffsetDirectionRef.current = null;
            }
        }
        else {
            emblaApi?.scrollTo(targetIndex, isTestEnv);
            peekOffsetTargetRef.current = null;
            peekOffsetDirectionRef.current = null;
        }
        if (isTestEnv) {
            syncStateForTests(targetIndex);
        }
    }, [
        emblaApi,
        startEndOffset,
        shouldLoop,
        calculatePeekPosition,
        syncStateForTests,
    ]);
    const scrollTo = useCallback((index) => {
        programmaticTargetRef.current = index;
        const currentIndex = currentIndexRef.current;
        const direction = index > currentIndex
            ? 'next'
            : index < currentIndex
                ? 'prev'
                : undefined;
        performScroll(index, direction);
    }, [performScroll]);
    useImperativeHandle(ref, () => ({
        slideTo: (index) => {
            scrollTo(index);
        },
        leftIconByControlSize: leftIcon,
        rightIconByControlSize: rightIcon,
    }), [scrollTo]);
    const onImageLoad = useCallback((src) => {
        setLoadedImages(prev => [...prev, src]);
    }, []);
    const isLoading = useCallback((src) => !loadedImages.includes(src), [loadedImages]);
    const renderImages = useCallback((imagesList) => {
        return imagesList.map((image, index) => {
            const { src, style, ...imageProps } = image;
            const isInView = slidesInView.includes(index);
            return (React.createElement("div", { key: `${index}${src}`, className: "embla__slide", "data-index": index, role: "group", "aria-roledescription": "slide", "aria-label": `Slide ${index + 1} of ${imagesList.length}`, "aria-hidden": !isInView, tabIndex: isInView ? undefined : -1 },
                React.createElement(Proportion, { aspectRatio: PREDEFINED_RATIOS.landscape },
                    React.createElement("div", { className: st(classes.imageContainer, {
                            isLoading: isLoading(src),
                        }), "data-hook": DATA_HOOKS.imagesContainer },
                        React.createElement("img", { ...imageProps, src: src, "data-hook": DATA_HOOKS.carouselImage, className: classes.image, onLoad: () => onImageLoad(src), style: {
                                objectPosition: imagesPosition,
                                objectFit: imagesFit,
                                ...style,
                            } })),
                    isLoading(src) && (React.createElement("div", { className: classes.loader },
                        React.createElement(Loader, { dataHook: DATA_HOOKS.loader, size: "small" }))))));
        });
    }, [imagesPosition, imagesFit, isLoading, onImageLoad, slidesInView]);
    const scrollPrev = useCallback(() => {
        const currentIndex = currentIndexRef.current;
        if (!shouldLoop && currentIndex <= 0)
            return;
        const target = currentIndex === 0 ? slideCount - 1 : currentIndex - 1;
        programmaticTargetRef.current = target;
        performScroll(target, 'prev');
    }, [performScroll, shouldLoop, slideCount]);
    const scrollNext = useCallback(() => {
        const currentIndex = currentIndexRef.current;
        if (!shouldLoop && currentIndex >= slideCount - 1)
            return;
        const target = currentIndex === slideCount - 1 ? 0 : currentIndex + 1;
        programmaticTargetRef.current = target;
        performScroll(target, 'next');
    }, [performScroll, shouldLoop, slideCount]);
    // Keyboard navigation: ArrowLeft/ArrowRight when focus is inside carousel.
    const handleKeyDown = useCallback((e) => {
        if (e.key === 'ArrowLeft') {
            e.preventDefault();
            scrollPrev();
        }
        else if (e.key === 'ArrowRight') {
            e.preventDefault();
            scrollNext();
        }
    }, [scrollPrev, scrollNext]);
    // When focus moves into a slide that is not in view, scroll it into view.
    const handleFocusCapture = useCallback((e) => {
        if (!emblaApi)
            return;
        const focusedEl = e.target;
        const slideNodes = emblaApi.slideNodes();
        const slideIndex = slideNodes.findIndex(node => node.contains(focusedEl));
        if (slideIndex !== -1 && !slidesInView.includes(slideIndex)) {
            scrollTo(slideIndex);
        }
    }, [emblaApi, slidesInView, scrollTo]);
    const renderControlArrows = () => {
        if (controlsPosition === 'none')
            return null;
        return (React.createElement(React.Fragment, null,
            React.createElement(SliderArrow, { dataHook: DATA_HOOKS.prevButton, buttonSkin: buttonSkin, arrowSize: controlsSize, icon: leftIcon(controlsSize), controlsStartEnd: controlsStartEnd, onClick: scrollPrev, disabled: !shouldLoop && prevBtnDisabled, ariaLabel: "Previous slide", gradientClassName: !!gradient && controlsPosition === 'overlay'
                    ? classes.arrowPrevBackground
                    : undefined }),
            React.createElement(SliderArrow, { dataHook: DATA_HOOKS.nextButton, buttonSkin: buttonSkin, arrowSize: controlsSize, icon: rightIcon(controlsSize), controlsStartEnd: controlsStartEnd, onClick: scrollNext, disabled: !shouldLoop && nextBtnDisabled, ariaLabel: "Next slide", gradientClassName: !!gradient && controlsPosition === 'overlay'
                    ? classes.arrowNextBackground
                    : undefined })));
    };
    const renderDotsNavigation = () => {
        if (!dots)
            return null;
        const dotElements = Array.from({ length: slideCount }, (_, i) => (React.createElement("div", { key: i, className: st(classes.pageNavigation, { active: i === selectedIndex }), "data-hook": DATA_HOOKS.pageNavigation(i), "data-active": i === selectedIndex, onClick: () => scrollTo(i) }, i)));
        return (React.createElement(Pagination, { originalClassName: st(classes.pagination, { controlsPosition }), pages: dotElements }));
    };
    const hasImages = !children && images.length > 0;
    const slideSpacingStyle = slideSpacing !== undefined
        ? { '--slide-spacing': `${slideSpacing}px` }
        : {};
    return (React.createElement("div", { "data-hook": dataHook, role: "region", "aria-roledescription": "carousel", "aria-label": "Carousel", onKeyDown: handleKeyDown, onFocusCapture: handleFocusCapture, className: `${st(classes.root, {
            controlsPosition,
            controlsSize,
            showControlsShadow,
            gradient,
            customGradient: !!gradientColor,
            dots,
            variableWidth,
        }, className)} embla`, style: {
            [vars['carousel-gradient-color']]: gradientColor,
            ...slideSpacingStyle,
        } },
        React.createElement("div", { className: classes.emblaWrapper },
            renderControlArrows(),
            React.createElement("div", { className: "embla__viewport", ref: emblaRef },
                React.createElement("div", { className: "embla__container" }, children
                    ? React.Children.map(children, (child, index) => {
                        const isInView = slidesInView.includes(index);
                        return (React.createElement("div", { key: index, className: "embla__slide", "data-index": index, role: "group", "aria-roledescription": "slide", "aria-label": `Slide ${index + 1} of ${slideCount}`, "aria-hidden": !isInView, tabIndex: isInView ? undefined : -1 }, child));
                    })
                    : hasImages && renderImages(images)))),
        renderDotsNavigation()));
});
Carousel.displayName = 'Carousel';
export default Carousel;
//# sourceMappingURL=Carousel.js.map