text: import {ActionTrust_Enum} from '#core/constants/action-constants';
  import {Keys_Enum} from '#core/constants/key-codes';
  import {bezierCurve} from '#core/data-structures/curve';
  import {dispatchCustomEvent} from '#core/dom';
  import {isLayoutSizeDefined} from '#core/dom/layout';
  import {
    observeContentSize,
    unobserveContentSize,
  } from '#core/dom/layout/size-observer';
  import {observeIntersections} from '#core/dom/layout/viewport-observer';
  import {closestAncestorElementBySelector} from '#core/dom/query';
  import {getStyle, setStyle} from '#core/dom/style';
  import {numeric} from '#core/dom/transition';
  import {isFiniteNumber} from '#core/types';
  
  import {isExperimentOn} from '#experiments';
  
  import {Services} from '#service';
  
  import {triggerAnalyticsEvent} from '#utils/analytics';
  import {Animation} from '#utils/animation';
  import {createCustomEvent, listen} from '#utils/event-helper';
  import {dev, user, userAssert} from '#utils/log';
  
  import {
    ClassNames,
    buildDom,
    getNextButtonTitle,
    getPrevButtonTitle,
  } from './build-dom';
  import {CarouselControls} from './carousel-controls';
  
  /** @const {string} */
  const SHOWN_CSS_CLASS = 'i-amphtml-slide-item-show';
  
  /** @const {number} */
  const NATIVE_SNAP_TIMEOUT = 200;
  
  /** @const {number} */
  const IOS_CUSTOM_SNAP_TIMEOUT = 45;
  
  /** @const {number} */
  const NATIVE_TOUCH_TIMEOUT = 100;
  
  /** @const {number} */
  const IOS_TOUCH_TIMEOUT = 45;
  
  /** @const {number} */
  const CUSTOM_SNAP_TIMEOUT = 100;
  
  const TAG = 'AMP-CAROUSEL';
  
  export class AmpSlideScroll extends AMP.BaseElement {
    /** @param {AmpElement} element */
    constructor(element) {
      super(element);
  
      /** @private {?../../../src/service/vsync-impl.Vsync} */
      this.vsync_ = null;
  
      /** @private {boolean} */
      this.hasNativeSnapPoints_ = false;
  
      /** @private {Array<Element>} */
      this.slides_ = [];
  
      /** @private {number} */
      this.noOfSlides_ = 0;
  
      /** @private {?Element} */
      this.slidesContainer_ = null;
  
      /** @private {Array<Element>} */
      this.slideWrappers_ = [];
  
      /** @private {boolean} */
      this.snappingInProgress_ = false;
  
      /** @private {?number} */
      this.scrollTimeout_ = null;
  
      /** @private {boolean} */
      this.isTouching_ = false;
  
      /** @private {?number} */
      this.autoplayTimeoutId_ = null;
  
      /** @private {boolean} */
      this.hasLoop_ = false;
  
      /** @private {boolean} */
      this.loopAdded_ = false;
  
      /** @private {boolean} */
      this.hasAutoplay_ = false;
  
      /** @private {number} */
      this.autoplayDelay_ = 5000;
  
      /** @private {?number} */
      this.autoplayLoops_ = null;
  
      /** @private {number} */
      this.loopsMade_ = 0;
  
      /** @private {boolean} */
      this.shouldLoop_ = false;
  
      /** @private {boolean} */
      this.shouldAutoplay_ = false;
  
      /**
       * 0 - not in an elastic state.
       * -1 - elastic scrolling (back) to the left of scrollLeft 0.
       * 1 - elastic scrolling (fwd) to the right of the max scrollLeft possible.
       * @private {number}
       */
      this.elasticScrollState_ = 0;
  
      /**
       * If not laid out yet, null. Otherwise, index of current displayed slide.
       * @private {?number}
       */
      this.slideIndex_ = null;
  
      /**
       * The slide index that should be shown on first layout.
       * @private {number}
       */
      this.initialSlideIndex_ = 0;
  
      /** @private {number} */
      this.slideWidth_ = 0;
  
      /** @private {number} */
      this.previousScrollLeft_ = 0;
  
      /** @private {Array<?string>} */
      this.dataSlideIdArr_ = [];
  
      const platform = Services.platformFor(this.win);
  
      /** @private @const {boolean} */
      this.isIos_ = platform.isIos();
  
      /** @private @const {boolean} */
      this.isSafari_ = platform.isSafari();
  
      /** @private {?../../../src/service/action-impl.ActionService} */
      this.action_ = null;
  
      // Keep CSS Scroll Snap points turned on for the following:
      // - All iOS devices except for 10.3
      // - All places where the experiment flag is deliberately set.
      // Conversely turn CSS Scroll Snap points off for the following:
      // - iOS devices on version 10.3
      // - Non iOS devices with the flag turned off.
      /** @private {boolean} */
      this.shouldDisableCssSnap_ = Services.platformFor(this.win)
        .getIosVersionString()
        .startsWith('10.3')
        ? true
        : this.isIos_
        ? false
        : !isExperimentOn(this.win, 'amp-carousel-chrome-scroll-snap');
  
      /** @private {boolean} */
      this.hasFirstResizedOccured_ = false;
  
      this.onResized_ = this.onResized_.bind(this);
  
      /** @private {?UnlistenDef} */
      this.unobserveIntersections_ = null;
  
      /** @private {CarouselControls} */
      this.controls_ = null;
    }
  
    /** @override */
    isLayoutSupported(layout) {
      return isLayoutSizeDefined(layout);
    }
  
    /** @override */
    isRelayoutNeeded() {
      return true;
    }
  
    /**
     * Attaches event handlers
     * @private
     */
    setupBehavior_() {
      const autoplayVal = this.element.getAttribute('autoplay');
      if (autoplayVal) {
        this.autoplayLoops_ = parseInt(autoplayVal, 10);
        userAssert(isFiniteNumber(this.autoplayLoops_));
      }
      if (this.shouldAutoplay_ && this.autoplayLoops_ != 0) {
        this.setupAutoplay_();
      }
  
      this.registerAction(
        'toggleAutoplay',
        (invocation) => {
          const {args} = invocation;
          if (args && args['toggleOn'] !== undefined) {
            this.toggleAutoplay_(args['toggleOn']);
          } else {
            this.toggleAutoplay_(!this.hasAutoplay_);
          }
        },
        ActionTrust_Enum.LOW
      );
    }
  
    /**
     * Attaches event listeners for slides.
     * Also creates client-specific DOM for various bugfixes.
     */
    setupSlideBehavior_() {
      this.vsync_ = this.getVsync();
      this.action_ = Services.actionServiceForDoc(this.element);
      /** If the element is in an email document, allow its `goToSlide` action. */
      this.action_.addToAllowlist(TAG, 'goToSlide', ['email']);
  
      this.hasNativeSnapPoints_ =
        getStyle(this.element, 'scrollSnapType') != undefined;
  
      if (this.shouldDisableCssSnap_) {
        this.hasNativeSnapPoints_ = false;
      }
  
      // Snap point is buggy in IOS 10.3 (beta), so it is disabled in beta.
      // https://bugs.webkit.org/show_bug.cgi?id=169800
      this.slidesContainer_.classList.toggle(
        ClassNames.SLIDES_CONTAINER_NOSNAP,
        this.shouldDisableCssSnap_
      );
  
      // Workaround - https://bugs.webkit.org/show_bug.cgi?id=158821
      if (this.hasNativeSnapPoints_) {
        const start = this.win.document.createElement('div');
        start.classList.add('i-amphtml-carousel-start-marker');
        this.slidesContainer_.appendChild(start);
  
        const end = this.win.document.createElement('div');
        end.classList.add('i-amphtml-carousel-end-marker');
        this.slidesContainer_.appendChild(end);
      }
  
      this.slides_.forEach((slide, index) => {
        const id = slide.getAttribute('data-slide-id') || index.toString();
        this.dataSlideIdArr_.push(id);
        Services.ownersForDoc(this.element).setOwner(slide, this.element);
      });
  
      this.cancelTouchEvents_();
  
      this.slidesContainer_.addEventListener(
        'scroll',
        this.scrollHandler_.bind(this)
      );
      this.slidesContainer_.addEventListener(
        'keydown',
        this.keydownHandler_.bind(this)
      );
  
      listen(
        this.slidesContainer_,
        'touchmove',
        this.touchMoveHandler_.bind(this),
        {passive: true}
      );
  
      listen(
        this.slidesContainer_,
        'touchend',
        this.touchEndHandler_.bind(this),
        {passive: true}
      );
  
      this.registerAction(
        'goToSlide',
        (invocation) => {
          const {args} = invocation;
          if (args) {
            this.goToSlide(args['index'], ActionTrust_Enum.HIGH);
          }
        },
        ActionTrust_Enum.LOW
      );
    }
  
    /** @override */
    attachedCallback() {
      observeContentSize(this.element, this.onResized_);
    }
  
    /** @override */
    detachedCallback() {
      unobserveContentSize(this.element, this.onResized_);
    }
  
    /**
     * Checks if a carousel is eligible to loop, regardless of the loop attribute.
     * @return {boolean}
     */
    isLoopingEligible() {
      return this.noOfSlides_ > 1;
    }
  
    /** @override */
    mutatedAttributesCallback(mutations) {
      const slide = mutations['slide'];
      if (slide !== undefined) {
        this.goToSlide(slide, ActionTrust_Enum.HIGH);
      }
    }
  
    /**
     * Handles touchmove event.
     * @private
     */
    touchMoveHandler_() {
      this.clearAutoplayTimer_();
      this.isTouching_ = true;
    }
  
    /**
     * Handles when carousel comes into and out of viewport.
     * @param {boolean} inViewport
     */
    viewportCallback(inViewport) {
      if (inViewport) {
        this.autoplay_();
        this.controls_?.hintControls();
      } else {
        this.clearAutoplayTimer_();
      }
    }
  
    /** Used by amp-lightbox-gallery */
    interactionNext() {
      this.controls_.handleNext();
    }
  
    /** Used by amp-lightbox-gallery */
    interactionPrev() {
      this.controls_.handlePrev();
    }
  
    /**
     * Does all the work needed to proceed to next
     * desired direction.
     * @param {number} dir -1 or 1
     * @param {boolean} animate
     * @param {boolean=} opt_autoplay
     */
    goCallback(dir, animate, opt_autoplay) {
      this.go(dir, animate, opt_autoplay);
    }
  
    /**
     * Does all the work needed to proceed to next
     * desired direction.
     * @param {number} dir -1 or 1
     * @param {boolean} animate
     * @param {boolean=} opt_autoplay
     */
    go(dir, animate, opt_autoplay) {
      const trust = opt_autoplay ? ActionTrust_Enum.LOW : ActionTrust_Enum.HIGH;
      this.moveSlide(dir, animate, trust);
      if (opt_autoplay) {
        this.autoplay_();
      } else {
        this.clearAutoplayTimer_();
      }
    }
  
    /**
     *
     * @param {number} timeout The timeout to wait for before considering scroll
     *    settled, unless this method is called again.
     */
    waitForScrollSettled_(timeout) {
      if (this.scrollTimeout_) {
        Services.timerFor(this.win).cancel(this.scrollTimeout_);
      }
  
      this.scrollTimeout_ = /** @type {number} */ (
        Services.timerFor(this.win).delay(() => {
          this.scrollTimeout_ = null;
  
          if (this.snappingInProgress_ || this.isTouching_) {
            return;
          }
  
          const currentScrollLeft = this.slidesContainer_./*OK*/ scrollLeft;
  
          if (this.hasNativeSnapPoints_) {
            this.updateOnScroll_(currentScrollLeft, ActionTrust_Enum.LOW);
          } else {
            this.customSnap_(currentScrollLeft, undefined, ActionTrust_Enum.LOW);
          }
        }, timeout)
      );
    }
  
    /**
     * Handles touchend event.
     * @private
     */
    touchEndHandler_() {
      const timeout = this.shouldDisableCssSnap_
        ? IOS_TOUCH_TIMEOUT
        : NATIVE_TOUCH_TIMEOUT;
      this.isTouching_ = false;
      this.waitForScrollSettled_(timeout);
    }
  
    /**
     * @param {import('#core/dom/layout/rect').LayoutSizeDef} size
     * @private
     */
    onResized_(size) {
      this.slideWidth_ = size.width;
      this.hasFirstResizedOccured_ = true;
    }
  
    /** @override */
    buildCallback() {
      const {nextButton, prevButton, slideWrappers, slides, slidesContainer} =
        buildDom(this.element);
      this.slides_ = slides;
      this.slidesContainer_ = slidesContainer;
      this.slideWrappers_ = slideWrappers;
      this.noOfSlides_ = this.slides_.length;
      this.hasLoop_ = this.element.hasAttribute('loop');
      this.hasAutoplay_ = this.element.hasAttribute('autoplay');
      this.shouldLoop_ = this.hasLoop_ && this.isLoopingEligible();
      this.shouldAutoplay_ = this.hasAutoplay_ && this.isLoopingEligible();
  
      this.controls_ = new CarouselControls({
        element: this.element,
        go: this.go.bind(this),
        nextButton,
        prevButton,
      });
      this.controls_.updateButtonTitles(
        this.getPrevButtonTitle(),
        this.getNextButtonTitle()
      );
      this.setupBehavior_();
      this.setupSlideBehavior_();
    }
  
    /** @override */
    layoutCallback() {
      this.unobserveIntersections_ = observeIntersections(
        this.element,
        ({isIntersecting}) => this.viewportCallback(isIntersecting)
      );
  
      // TODO(sparhami) #19259 Tracks a more generic way to do this. Remove once
      // we have something better.
      const isScaled = closestAncestorElementBySelector(
        this.element,
        '[i-amphtml-scale-animation]'
      );
      if (isScaled) {
        return Promise.resolve();
      }
  
      // Account for race when onResized_ has not fired before layoutCallback,
      // since we need slideWidth_ to proceed.
      if (!this.hasFirstResizedOccured_) {
        this.slideWidth_ = this.slidesContainer_./*OK*/ clientWidth;
      }
  
      if (this.slideIndex_ === null) {
        this.showSlide_(this.initialSlideIndex_);
      } else {
        const index = user().assertNumber(
          this.slideIndex_,
          'E#19457 this.slideIndex_'
        );
        const scrollLeft = this.getScrollLeftForIndex_(index);
        // When display is toggled on a partcular media or element resizes,
        // it will need to be re-laid-out. This is only needed when the slide
        // does not change (example when browser window size changes,
        // or orientation changes)
        Services.ownersForDoc(this.element).scheduleLayout(
          this.element,
          this.slides_[index]
        );
        // Reset scrollLeft on orientationChange or anything that changes the
        // size of the carousel.
        this.slidesContainer_./*OK*/ scrollLeft = scrollLeft;
        this.previousScrollLeft_ = scrollLeft;
      }
      return Promise.resolve();
    }
  
    /** @override */
    unlayoutCallback() {
      this.unobserveIntersections_?.();
      this.unobserveIntersections_ = null;
      this.slideIndex_ = null;
      return true;
    }
  
    /**
     * @return  {boolean}
     * @private
     */
    hasPrev_() {
      return this.shouldLoop_ || this.slideIndex_ > 0;
    }
  
    /**
     * @return  {boolean}
     * @private
     */
    hasNext_() {
      return this.shouldLoop_ || this.slideIndex_ < this.slides_.length - 1;
    }
  
    /**
     * Proceeds to the next slide in the desired direction.
     * @param {number} dir -1 or 1
     * @param {boolean} animate
     * @param {ActionTrust_Enum} trust
     */
    moveSlide(dir, animate, trust) {
      if (this.slideIndex_ !== null) {
        const hasNext = this.hasNext_();
        const hasPrev = this.hasPrev_();
        if ((dir == 1 && hasNext) || (dir == -1 && hasPrev)) {
          let newIndex = dev().assertNumber(this.slideIndex_) + dir;
          if (newIndex == -1) {
            newIndex = this.noOfSlides_ - 1;
          } else if (newIndex >= this.noOfSlides_) {
            newIndex = 0;
          }
          if (animate) {
            const currentScrollLeft = dir == 1 && !hasPrev ? 0 : this.slideWidth_;
            this.customSnap_(currentScrollLeft, dir, trust);
          } else {
            this.showSlideAndTriggerAction_(newIndex, trust);
          }
        }
      }
    }
  
    /**
     * Handles scroll on the slides container.
     * @param {Event} unusedEvent Event object.
     * @private
     */
    scrollHandler_(unusedEvent) {
      const currentScrollLeft = this.slidesContainer_./*OK*/ scrollLeft;
  
      if (!this.isIos_ && !this.isSafari_) {
        this.handleCustomElasticScroll_(currentScrollLeft);
      }
  
      const timeout = this.hasNativeSnapPoints_
        ? NATIVE_SNAP_TIMEOUT
        : this.isIos_
        ? IOS_CUSTOM_SNAP_TIMEOUT
        : CUSTOM_SNAP_TIMEOUT;
      // Timer that detects scroll end and/or end of snap scroll.
      this.waitForScrollSettled_(timeout);
  
      this.previousScrollLeft_ = currentScrollLeft;
    }
  
    /**
     * Escapes Left and Right arrow key events on the carousel container.
     * This is to prevent them from doubly interacting with surrounding viewer
     * contexts such as email clients when interacting with the amp-carousel.
     * @param {KeyboardEvent} event
     * @private
     */
    keydownHandler_(event) {
      const {key} = event;
      if (key == Keys_Enum.LEFT_ARROW || key == Keys_Enum.RIGHT_ARROW) {
        event.stopPropagation();
      }
    }
  
    /**
     * Handles custom elastic scroll (snap points polyfill).
     * @param {number} currentScrollLeft scrollLeft value of the slides container.
     */
    handleCustomElasticScroll_(currentScrollLeft) {
      const scrollWidth = this.slidesContainer_./*OK*/ scrollWidth;
      if (
        this.elasticScrollState_ == -1 &&
        currentScrollLeft >= this.previousScrollLeft_
      ) {
        // Elastic Scroll is reversing direction take control.
        this.customSnap_(currentScrollLeft).then(() => {
          this.elasticScrollState_ = 0;
        });
      } else if (
        this.elasticScrollState_ == 1 &&
        currentScrollLeft <= this.previousScrollLeft_
      ) {
        // Elastic Scroll is reversing direction take control.
        this.customSnap_(currentScrollLeft).then(() => {
          this.elasticScrollState_ = 0;
        });
      } else if (currentScrollLeft < 0) {
        // Direction = -1.
        this.elasticScrollState_ = -1;
      } else if (currentScrollLeft + this.slideWidth_ > scrollWidth) {
        // Direction = +1.
        this.elasticScrollState_ = 1;
      } else {
        this.elasticScrollState_ = 0;
      }
    }
  
    /**
     * Animate and snap to the correct slide for a given scrollLeft.
     * @param {number} currentScrollLeft scrollLeft value of the slides container.
     * @param {number=} opt_forceDir if a valid direction is given force it to
     * move 1 slide in that direction.
     * @param {ActionTrust_Enum=} opt_trust
     * @return {Promise}
     */
    customSnap_(currentScrollLeft, opt_forceDir, opt_trust) {
      this.snappingInProgress_ = true;
      const newIndex = this.getNextSlideIndex_(currentScrollLeft);
      // Default behavior should be stays on current slide
      let diff = newIndex - this.slideIndex_;
      const hasPrev = this.hasPrev_();
      let toScrollLeft = hasPrev ? this.slideWidth_ : 0;
  
      if (diff == 0 && (opt_forceDir == 1 || opt_forceDir == -1)) {
        diff = opt_forceDir;
      }
  
      if (diff == 1 || (diff != -1 && diff == -1 * (this.noOfSlides_ - 1))) {
        // Move fwd.
        toScrollLeft = hasPrev ? this.slideWidth_ * 2 : this.slideWidth_;
      } else if (diff == -1 || diff == this.noOfSlides_ - 1) {
        // Move backward.
        toScrollLeft = 0;
      }
      return this.animateScrollLeft_(currentScrollLeft, toScrollLeft).then(() => {
        this.updateOnScroll_(toScrollLeft, opt_trust);
      });
    }
  
    /**
     * Gets the slideIndex of the potential next slide based on the
     *    current scrollLeft.
     * @param {number} currentScrollLeft scrollLeft value of the slides container.
     * @return {number} a number representing the next slide index.
     */
    getNextSlideIndex_(currentScrollLeft) {
      // Addresses race where slideWidth is 0, due to being hidden
      // while snapping is occuring.
      if (!currentScrollLeft && !this.slideWidth_) {
        return 0;
      }
      // This can be only 0, 1 or 2, since only a max of 3 slides are shown at
      // a time.
      const scrolledSlideIndex = Math.round(currentScrollLeft / this.slideWidth_);
      // Update value can be -1, 0 or 1 depending upon the index of the current
      // shown slide.
      let updateValue = 0;
  
      const hasPrev = this.hasPrev_();
      const hasNext = this.hasNext_();
  
      if (hasPrev && hasNext) {
        updateValue = scrolledSlideIndex - 1;
      } else if (hasNext) {
        // Has next and does not have a prev. (slideIndex 0)
        updateValue = scrolledSlideIndex;
      } else if (hasPrev) {
        // Has prev and no next slide (last slide)
        updateValue = scrolledSlideIndex - 1;
      }
  
      let newIndex = this.slideIndex_ + updateValue;
  
      if (this.shouldLoop_) {
        newIndex =
          newIndex < 0
            ? this.noOfSlides_ - 1
            : newIndex >= this.noOfSlides_
            ? 0
            : newIndex;
      } else {
        newIndex =
          newIndex < 0
            ? 0
            : newIndex >= this.noOfSlides_
            ? this.noOfSlides_ - 1
            : newIndex;
      }
      return newIndex;
    }
  
    /** @return {string} */
    getPrevButtonTitle() {
      const prevIndex = this.getPrevIndex_(this.slideIndex_);
      const index = (prevIndex == null ? 0 : prevIndex) + 1;
      return getPrevButtonTitle(this.element, {
        index: String(index),
        total: String(this.noOfSlides_),
      });
    }
  
    /** @return {string} */
    getNextButtonTitle() {
      const nextIndex = this.getNextIndex_(this.slideIndex_);
      const index = (nextIndex == null ? this.noOfSlides_ - 1 : nextIndex) + 1;
      return getNextButtonTitle(this.element, {
        index: String(index),
        total: String(this.noOfSlides_),
      });
    }
  
    /**
     * Updates to the right state of the new index on scroll.
     * @param {number} currentScrollLeft scrollLeft value of the slides container.
     * @param {ActionTrust_Enum=} opt_trust
     */
    updateOnScroll_(currentScrollLeft, opt_trust) {
      if (!isFiniteNumber(currentScrollLeft) || this.slideIndex_ === null) {
        return;
      }
      this.snappingInProgress_ = true;
      const newIndex = this.getNextSlideIndex_(currentScrollLeft);
      this.vsync_.mutate(() => {
        // Scroll to new slide and update scrollLeft to the correct slide.
        this.showSlideAndTriggerAction_(newIndex, opt_trust);
        this.vsync_.mutate(() => {
          this.snappingInProgress_ = false;
        });
      });
    }
  
    /**
     * Parses given value as integer and shows the slide with that index value
     * when element has been laid out.
     * @param {*} value
     * @param {ActionTrust_Enum} trust
     */
    goToSlide(value, trust) {
      const index = parseInt(value, 10);
  
      if (!isFinite(index) || index < 0 || index >= this.noOfSlides_) {
        this.user().error(TAG, 'Invalid [slide] value: ', value);
        return;
      }
  
      // If we haven't been laid out, set `initialSlideIndex_` for layout time.
      if (this.slideIndex_ === null) {
        this.initialSlideIndex_ = index;
        return;
      }
  
      this.showSlideAndTriggerAction_(index, trust);
    }
  
    /**
     * @param {?number} currentIndex
     * @return {?number} The previous index that would be navigated to, or null
     *    if at the start and not looping.
     * @private
     */
    getPrevIndex_(currentIndex) {
      return currentIndex - 1 >= 0
        ? currentIndex - 1
        : this.shouldLoop_
        ? this.noOfSlides_ - 1
        : null;
    }
  
    /**
     * @param {?number} currentIndex
     * @return {?number} The next index that would be navigated to, or null if at
     *    the end and not looping.
     * @private
     */
    getNextIndex_(currentIndex) {
      return currentIndex + 1 < this.noOfSlides_
        ? currentIndex + 1
        : this.shouldLoop_
        ? 0
        : null;
    }
  
    /**
     * Makes the slide corresponding to the given index and the slides surrounding
     *     it available for display.
     * Note: Element must be laid out.
     * @param {number} newIndex Index of the slide to be displayed.
     * @return {boolean} true if the slide changed, otherwise false.
     * @private
     */
    showSlide_(newIndex) {
      const {noOfSlides_} = this;
      newIndex = dev().assertNumber(newIndex);
      if (
        newIndex < 0 ||
        newIndex >= noOfSlides_ ||
        this.slideIndex_ == newIndex
      ) {
        return false;
      }
      const prevIndex = this.getPrevIndex_(newIndex);
      const nextIndex = this.getNextIndex_(newIndex);
  
      const showIndexArr = [];
      if (prevIndex != null) {
        showIndexArr.push(prevIndex);
      }
      showIndexArr.push(newIndex);
      if (nextIndex != null && nextIndex !== prevIndex) {
        showIndexArr.push(nextIndex);
      }
      const newSlideInView = this.slides_[newIndex];
  
      if (newSlideInView === undefined) {
        dev().error(
          TAG,
          'Attempting to access a non-existant slide %s / %s',
          newIndex,
          noOfSlides_
        );
        return false;
      }
      showIndexArr.forEach((showIndex, loopIndex) => {
        if (this.shouldLoop_) {
          setStyle(this.slideWrappers_[showIndex], 'order', loopIndex + 1);
        }
        this.slideWrappers_[showIndex].classList.add(SHOWN_CSS_CLASS);
        const owners = Services.ownersForDoc(this.element);
        if (showIndex == newIndex) {
          owners.scheduleLayout(this.element, this.slides_[showIndex]);
          owners.scheduleResume(this.element, this.slides_[showIndex]);
          this.slides_[showIndex].setAttribute('aria-hidden', 'false');
        } else {
          owners.schedulePreload(this.element, this.slides_[showIndex]);
          this.slides_[showIndex].setAttribute('aria-hidden', 'true');
        }
      });
      this.slidesContainer_./*OK*/ scrollLeft =
        this.getScrollLeftForIndex_(newIndex);
      this.triggerAnalyticsEvent_(newIndex);
      this.slideIndex_ = newIndex;
      // If we have a specified number of autoplay loops and
      // we have reached the last slide in the set
      // carry out removing autoplay logic.
      // This only works because the initial Slide is always the first slide.
      if (this.autoplayLoops_ && this.slideIndex_ === this.noOfSlides_ - 1) {
        this.loopsMade_++;
        if (this.loopsMade_ == this.autoplayLoops_) {
          this.removeAutoplay_();
        }
      }
      this.hideRestOfTheSlides_(showIndexArr);
      this.controls_?.setControlsState({
        prev: this.hasPrev_(),
        next: this.hasNext_(),
      });
      this.controls_?.updateButtonTitles(
        this.getPrevButtonTitle(),
        this.getNextButtonTitle()
      );
      return true;
    }
  
    /**
     * Shows the slide at the given index and triggers a `slideChange` event.
     * @param {number} newIndex
     * @param {ActionTrust_Enum=} opt_trust LOW by default.
     * @private
     */
    showSlideAndTriggerAction_(newIndex, opt_trust = ActionTrust_Enum.LOW) {
      const slideChanged = this.showSlide_(newIndex);
  
      if (slideChanged) {
        const name = 'slideChange';
        const event = createCustomEvent(this.win, `slidescroll.${name}`, {
          'index': newIndex,
        });
        this.action_.trigger(this.element, name, event, opt_trust);
  
        dispatchCustomEvent(this.element, name, {
          index: newIndex,
          actionTrust: opt_trust,
        });
      }
    }
  
    /**
     * Returns the scrollLeft position for a given slide index.
     * @param {number} index Index of the slide to be displayed.
     * @return {number}
     * @private
     */
    getScrollLeftForIndex_(index) {
      // A max of 3 slides are displayed at a time - we show the first slide
      // (which is at scrollLeft 0) when slide 0 is requested - for all other
      // instances we show the second slide (middle slide at
      // scrollLeft = slide's width).
      let newScrollLeft = this.slideWidth_;
      if ((!this.shouldLoop_ && index == 0) || this.slides_.length <= 1) {
        newScrollLeft = 0;
      }
      return newScrollLeft;
    }
  
    /**
     * Given an index, hides rest of the slides that are not needed.
     * @param {Array<number>} indexArr Array of indices that
     *    should not be hidden.
     * @private
     */
    hideRestOfTheSlides_(indexArr) {
      const {noOfSlides_} = this;
      for (let i = 0; i < noOfSlides_; i++) {
        if (!this.slideWrappers_[i].classList.contains(SHOWN_CSS_CLASS)) {
          continue;
        }
        // Hide if not shown anymore
        if (!indexArr.includes(i)) {
          if (this.shouldLoop_) {
            setStyle(this.slideWrappers_[i], 'order', '');
          }
          dev()
            .assertElement(this.slideWrappers_[i])
            .classList.remove(SHOWN_CSS_CLASS);
          this.slides_[i].removeAttribute('aria-hidden');
        }
        // Pause if not the current slide
        if (this.slideIndex_ != i) {
          Services.ownersForDoc(this.element).schedulePause(
            this.element,
            this.slides_[i]
          );
        }
      }
    }
  
    /**
     * Animate scrollLeft of the container.
     * @param {number} fromScrollLeft
     * @param {number} toScrollLeft
     * @return {Promise}
     * @private
     */
    animateScrollLeft_(fromScrollLeft, toScrollLeft) {
      if (fromScrollLeft == toScrollLeft) {
        return Promise.resolve();
      }
      /** @const {TransitionDef<number>} */
      const interpolate = numeric(fromScrollLeft, toScrollLeft);
      const curve = bezierCurve(0.8, 0, 0.6, 1); // ease-in
      const duration = 80;
      const slidesContainer = dev().assertElement(this.slidesContainer_);
      return Animation.animate(
        slidesContainer,
        (pos) => {
          this.slidesContainer_./*OK*/ scrollLeft = interpolate(pos);
        },
        duration,
        curve
      ).thenAlways();
    }
  
    /**
     * Cancels the touchmove events for the element so that viewer does not
     * consider the swipes in the carousel as swipes for changing AMP documents.
     * @private
     */
    cancelTouchEvents_() {
      // TODO(aghassemi, #4754): Ideally we only stop propagation of horizontal
      // touchmove events.
      listen(this.element, 'touchmove', (event) => event.stopPropagation(), {
        passive: true,
      });
    }
  
    /**
     * @param {number} newSlideIndex
     * @private
     */
    triggerAnalyticsEvent_(newSlideIndex) {
      let direction = newSlideIndex - this.slideIndex_;
      if (direction == 0) {
        return;
      } else if (Math.abs(direction) !== 1) {
        // When the direction is not +1 or -1 (happens with loops or when
        // this.slideIndex_ is null (first slide))
        // Set the correct direction.
        direction = direction < 0 ? 1 : -1;
        if (this.slideIndex_ === null) {
          direction = 1;
        }
      }
      const fromSlide =
        this.slideIndex_ === null
          ? 'null'
          : this.dataSlideIdArr_[dev().assertNumber(this.slideIndex_)];
  
      const vars = {
        'fromSlide': fromSlide,
        'toSlide': this.dataSlideIdArr_[newSlideIndex],
      };
      this.analyticsEvent_('amp-carousel-change', vars);
      // At this point direction can be only +1 or -1.
      if (direction == 1) {
        this.analyticsEvent_('amp-carousel-next', vars);
      } else {
        this.analyticsEvent_('amp-carousel-prev', vars);
      }
    }
  
    /**
     * @param {string} eventType
     * @param {JsonObject} vars A map of vars and their values.
     * @private
     */
    analyticsEvent_(eventType, vars) {
      triggerAnalyticsEvent(this.element, eventType, vars);
    }
  
    /**
     * Sets up the `autoplay` configuration.
     * @private
     */
    setupAutoplay_() {
      const delayValue = Number(this.element.getAttribute('delay'));
      // If it isn't a number and is not greater than 0 then don't assign
      // and use the default.
      if (delayValue > 0) {
        // Guard against autoplayValue that is lower than 1s to prevent
        // people from crashing the runtime with providing very low delays.
        this.autoplayDelay_ = Math.max(1000, delayValue);
      }
  
      // By default `autoplay` should also mean that the current carousel slide
      // is looping. (to be able to advance past the last item)
      if (!this.hasLoop_) {
        this.element.setAttribute('loop', '');
        this.loopAdded_ = true;
        this.hasLoop_ = true;
        this.shouldLoop_ = true;
      }
    }
  
    /**
     * Starts the autoplay delay if allowed.
     * @private
     */
    autoplay_() {
      if (!this.shouldAutoplay_ || this.autoplayLoops_ == 0) {
        return;
      }
      this.clearAutoplayTimer_();
      this.autoplayTimeoutId_ = /** @type {number} */ (
        Services.timerFor(this.win).delay(
          this.go.bind(
            this,
            /* dir */ 1,
            /* animate */ true,
            /* autoplay */ true
          ),
          this.autoplayDelay_
        )
      );
    }
  
    /**
     * Called by toggleAutoplay action to toggle the autoplay feature.
     * @param {boolean} toggleOn
     * @private
     */
    toggleAutoplay_(toggleOn) {
      if (toggleOn == this.shouldAutoplay_) {
        return;
      }
  
      const prevAutoplayStatus = this.shouldAutoplay_;
  
      this.hasAutoplay_ = toggleOn;
      this.shouldAutoplay_ = this.hasAutoplay_ && this.isLoopingEligible();
  
      if (!prevAutoplayStatus && this.shouldAutoplay_) {
        this.setupAutoplay_();
      }
  
      if (this.shouldAutoplay_) {
        this.autoplay_();
      } else {
        this.clearAutoplayTimer_();
      }
    }
  
    /**
     * Clear the autoplay timer.
     * @private
     */
    clearAutoplayTimer_() {
      if (this.autoplayTimeoutId_ !== null) {
        Services.timerFor(this.win).cancel(this.autoplayTimeoutId_);
        this.autoplayTimeoutId_ = null;
      }
    }
  
    /**
     * Remove autoplay.
     * @private
     */
    removeAutoplay_() {
      this.clearAutoplayTimer_();
      if (this.loopAdded_) {
        // Only remove if specified due to the `autoplay` attribute
        this.element.removeAttribute('loop');
        this.loopAdded_ = false;
        this.hasLoop_ = false;
        this.shouldLoop_ = false;
      }
      this.hasAutoplay_ = false;
      this.shouldAutoplay_ = this.hasAutoplay_ && this.isLoopingEligible();
    }
  }
  