/**
 * @module tgam-patterns-photo-slider
 */

import ee from "./event-emitter";
import reqTimeout from "./request-timeout";
import lazyConfig from "./lazysizes-config";
import { toArray } from "./utils";
import Hammer from "hammerjs";

const eventNamespace = "tgam-photo-slider";

// component photo slider
const slideHasInfoClass = "c-photo-slider--has-info";
const slideHasNavClass = "c-photo-slider--has-nav";
const slideHasShareToolsClass = "c-photo-slider--has-share-tools";

const slideCanvasClass = "c-photo-slider__canvas";
const slideListClass = "c-photo-slider__list";
const slideItemClass = "c-photo-slider__item";

const slideNavLeftClass = "c-photo-slider__nav--left";
const slideNavRightClass = "c-photo-slider__nav--right";

const slideNavButtonLeftClass = "c-photo-slider__nav-button--left";
const slideNavButtonRightClass = "c-photo-slider__nav-button--right";

const slideSharedCaption = "c-photo-slider__caption";
const slideShareToolsClass = "c-photo-slider__share-tools";

// component article media image
const slideMediaClass = "c-article-media"; // figure
const slideMediaLinkClass = "c-article-media__link"; // image link
const slideMediaInfoClass = "c-article-media__info"; // figcaption
const slideMediaCenterClass = "c-article-media__center";

// component image
const slideImageClass = "c-image";

export default {

  init: function(element, rootClass, slideStart = 0, params = {}) {
    if (!element) {
      return;
    }

    // default options
    const defaults = {
      enableInfo: true, // true, if user can single tap to toggle photo caption
      enableZoom: true, // true, if user can pinch zoom the photos
      enableDismiss: true, // true, if user can close the slider via swipe up/down
      descFullWidth: false, // true, if photo desc width will be 100%, instead of matching photo width
      infiniteLoop: false // true, if last slide loops back to first slide and vice-versa
    };
    this.options = this.merge(defaults, params);

    // determine vendor prefix for CSS transform
    this.styleTransform = this.vendorPrefix("Transform");

    this.rootElement = element;
    this.rootClass = rootClass; // js hook class name
    this.slideActive = slideStart; // current slide shown
    this.sliding = false; // block overlaping slide animations

    this.slideSpeed = 200; // ms: speed when animating between slides
    this.slideMargin = 20; // px: margin between slides

    this.landscapeMax = 1020; // px: only applied to landscape photos
    this.portraitMax = 940; // px: only applied to portrait photos
    this.baseScale = 100; // px: default width of photos, then scaled up or down

    this.lockAxis = 0; // enum: x-axis = 1, y-axis = -1, neither = 0
    this.velocityThreshold = 0.5; // swipe velocity threshold for axis - for flick swipes
    this.distanceThreshold = 200; // px: swipe distance threshold for axis - for slow swipes
    this.yAxisMultiplier = 2; // make it require more effort to do y-axis flick/pan swipes

    // unique element id for this slider (that will be prefixed with various string)
    this.uniqueId = (0 | Math.random() * 9e6).toString(36); // e.g. "1p7ym"

    this.slideCanvas = this.rootElement.getElementsByClassName(slideCanvasClass).item(0);
    this.slideList = this.rootElement.getElementsByClassName(slideListClass).item(0);
    this.slideShareTools = this.rootElement.getElementsByClassName(slideShareToolsClass).item(0);
    this.slideCaption = this.rootElement.getElementsByClassName(slideSharedCaption).item(0);
    this.slideItems = toArray(this.rootElement.getElementsByClassName(slideItemClass));
    this.slides = []; // primary array of slides
    this.lengths = {}; // total counts by slide type group

    if (!this.slideList || !this.slideItems) {
      return;
    }

    // Create array of "slide" objects
    this.slideItems.forEach((item) => {
      const slideType = toArray(item.classList).reduce((accumulator, className) => {
        if (className.indexOf("c-photo-slider__item--") !== -1) {
          return className.split("--")[1];
        }
      });
      // count the slide types
      if (!this.lengths[slideType]) {
        this.lengths[slideType] = 0;
      }
      this.lengths[slideType] += 1;
      // create the slides
      this.slides.push({
        root: item,
        type: slideType,
        placement: this.lengths[slideType], // index within slide type group
        image: item.getElementsByClassName(slideImageClass).item(0),
        imageLink: item.getElementsByClassName(slideMediaLinkClass).item(0),
        figure: item.getElementsByClassName(slideMediaClass).item(0),
        info: item.getElementsByClassName(slideMediaInfoClass).item(0),
        media: item.getElementsByClassName(slideMediaCenterClass).item(0),
        // grab all images, we don't want any ghosting for any of them
        ghosts: toArray(item.getElementsByTagName("img"))
      });
    });

    // target of the left/right button aria-controls
    this.slideList.id = `c-photo-slider-${this.uniqueId}`;
    this.slideCaption.id = `c-photo-slider-caption-${this.uniqueId}`;

    this.rootElement.addEventListener("lazybeforesizes", this.onLazyBeforeSizes.bind(this));

    this.setupZoom(); // reset all pinch zoom related vars

    // this will cause placeholder image to be positioned
    if (this.slides[this.slideActive].type === "photo") {
      const placeholderScale = this.scaleImage(this.slideActive, true, false, null);
      this.scaleBase = this.scaleZoom = this.scaleMin = placeholderScale;
    }

    // trigger this manually to get intial measurements
    this.onSliderWindowResize({
      width: this.rootElement.clientWidth
    });

    // go to default slide
    this.slideTo(this.slideActive, false, this.slideWidth);

    window.requestAnimationFrame(this.onHideGhostImages.bind(this));

    // setup aria attributes for links
    this.slides.forEach((slide) => {
      if (slide.imageLink) {
        // prevent default anchor click, so that hash is not added to URL
        // this would mess up the history popstate when slider is in dialog
        slide.imageLink.addEventListener("click", (e) => e.preventDefault());

        // image link is labelledby shared caption
        slide.image.setAttribute("aria-labelledby", this.slideCaption.id);
        slide.imageLink.setAttribute("role", "presentation");
      }
    });

    // previous slide button
    this.slideNavLeft = this.rootElement.getElementsByClassName(slideNavLeftClass).item(0);
    this.slideNavButtonLeft = this.rootElement.getElementsByClassName(slideNavButtonLeftClass).item(0);
    if (this.slideNavLeft && this.slideNavButtonLeft) {
      this.slideNavButtonLeft.setAttribute("aria-controls", this.slideList.id);
      this.boundOnSlideNavLeft = this.onSlideNavLeft.bind(this);
      this.slideNavLeft.addEventListener("keydown", this.boundOnSlideNavLeft);
      this.slideNavLeft.addEventListener("click", this.boundOnSlideNavLeft);
    }

    // next slide button
    this.slideNavRight = this.rootElement.getElementsByClassName(slideNavRightClass).item(0);
    this.slideNavButtonRight = this.rootElement.getElementsByClassName(slideNavButtonRightClass).item(0);
    if (this.slideNavRight && this.slideNavButtonRight) {
      this.slideNavButtonRight.setAttribute("aria-controls", this.slideList.id);
      this.boundOnSlideNavRight = this.onSlideNavRight.bind(this);
      this.slideNavRight.addEventListener("keydown", this.boundOnSlideNavRight);
      this.slideNavRight.addEventListener("click", this.boundOnSlideNavRight);
    }

    // setup initial left/right button states
    window.requestAnimationFrame(this.updateButtonStates.bind(this));

    // keep slider container width updated
    ee.addListener("windowResize.throttled", this.onSliderWindowResize.bind(this));

    // setup hammer time (aka. party time) ᕕ(⌐■_■)ᕗ ♪
    // attached to root element, to prevent touch blocking by other ui elements.
    this.HammerSlide = new Hammer.Manager(this.rootElement);
    this.HammerTouch = null; // only used for modal slider, and not in-page slider

    this.HammerSlide.add(new Hammer.Pan({
      threshold: 5, // minimum distance required
      direction: (this.options.enableDismiss ? Hammer.DIRECTION_ALL : Hammer.DIRECTION_HORIZONTAL)
    }));
    if (this.options.enableZoom) {
      this.HammerSlide.add(new Hammer.Pinch());
    }

    this.HammerSlide.on("panstart", this.onSliderPanStart.bind(this));
    this.HammerSlide.on("panmove", this.onSliderPanMove.bind(this));
    this.HammerSlide.on("panend", this.onSliderPanEnd.bind(this));

    if (this.options.enableZoom) {
      this.HammerSlide.on("pinchstart", this.onSliderPinchStart.bind(this));
      this.HammerSlide.on("pinchmove", this.onSliderPinchMove.bind(this));
      this.HammerSlide.on("pinchend", this.onSliderPinchEnd.bind(this));
    }

    // seperate hammer time to handle taps only, attached to list element.
    // to prevent unintended tap events bubbling from other ui elements.
    if (this.options.enableInfo || this.options.enableZoom) {
      this.HammerTouch = new Hammer.Manager(this.slideCanvas);
    }

    if (this.options.enableZoom) {
      // doubletap is used to reset the zoom
      this.HammerTouch.add(new Hammer.Tap({
        event: "doubletap", // default values
        interval: 300, // time deplay between taps
        threshold: 20, // minimum distance between points
        posThreshold: 40, // minimum distance between points
        taps: 2
      }));
    }
    if (this.options.enableInfo) {
      this.HammerTouch.add(new Hammer.Tap({
        event: "singletap"
      }));
    }

    // Keep track of whether or not the listeners are
    // active to prevent code from running unnecessarily
    this.tapEventsEnabled = true;

    if (this.options.enableZoom) {
      this.HammerTouch.get("doubletap").recognizeWith("singletap");
      this.HammerTouch.get("singletap").requireFailure("doubletap");
    }

    if (this.options.enableInfo) {
      this.HammerTouch.on("singletap", this.onSliderSingleTap.bind(this));
    }
    if (this.options.enableZoom) {
      this.HammerTouch.on("doubletap", this.onSliderDoubleTap.bind(this));
    }

    // keypress events for navigation
    this.rootElement.addEventListener("keydown", this.onRootElementKeyDown.bind(this));
  },

  enableTapEvents: function() {
    if (!this.tapEventsEnabled && this.HammerTouch) {
      this.HammerTouch.get("singletap").set({ enable: true });
      this.options.enableZoom && this.HammerTouch.get("doubletap").set({ enable: true });
      this.tapEventsEnabled = true;
    }
  },

  disableTapEvents: function() {
    if (this.tapEventsEnabled && this.HammerTouch) {
      this.HammerTouch.get("singletap").set({ enable: false });
      this.options.enableZoom && this.HammerTouch.get("doubletap").set({ enable: false });
      this.tapEventsEnabled = false;
    }
  },

  // attempt to scale slide image, if within safe index range
  scaleSlide: function(index) {
    const safeIndex = this.safeIndex(this.slides, index);
    if (safeIndex === index) {
      this.scaleImage(safeIndex, true, false, null);
    }
  },

  // intitialize all pinch zoom related vars to defaults
  setupZoom: function() {
    this.zoomed = false; // is the current photo zoomed or not?

    this.scaleBase = 0; // base zoom scale when loaded, and after zoom (completed)
    this.scaleZoom = 0; // current zoomed scale during pinch zoom (uncompleted)

    this.scaleMin = 1; // dynamic minimum zoom scale value (set to initial unzoomed scale of image)
    this.scaleMax = 100; // constant maximum zoom scale value (because reasons)

    this.offsetX = 0; // x-axis offset of image position - current
    this.offsetY = 0; // y-axis offset of image position - current

    this.oldsetX = 0; // x-axis offset of image position - previous
    this.oldsetY = 0; // y-axis offset of image position - previous

    this.centerX = 0; // x-axis position of initial pinch center point
    this.centerY = 0; // y-axis position of initial pinch center point

    this.minsetX = 0; // x-axis minimum permitted offset value
    this.minsetY = 0; // y-axis maximum permitted offset value

    this.maxsetX = 0; // x-axis minimum permitted offset value
    this.maxsetY = 0; // y-axis maximum permitted offset value
  },

  // reset vars and set current image scale
  resetZoom: function() {
    this.setupZoom();
    const imageScale = this.scaleImage(this.slideActive, true, true, null);
    this.scaleBase = this.scaleZoom = this.scaleMin = imageScale;
    this.updateLazyElement(this.slideActive, imageScale);
  },

  onLazyBeforeSizes: function(e) {
    if (e.defaultPrevented) {
      return;
    }
    // find the slide element and object that are associated with this image
    const slideElement = e.target.closest("." + slideItemClass);
    if (!slideElement) {
      return;
    }
    let slideIndex = null;
    this.slides.forEach((slide, index) => {
      if (slide.root === slideElement) {
        slideIndex = index;
      }
    });
    if (slideIndex === this.slideActive && !this.zoomed && !!e.detail.dataAttr) {
      this.scaleImage(slideIndex, false, true, e);
    }
  },

  scaleImage: function(index, imageWidth = false, mediaShow = false, lazyEvent = null) {
    const slide = this.slides[index];
    if (slide.type !== "photo") {
      return 1;
    }
    const aspectRatio = parseFloat(slide.image.dataset.aspectRatio || 100); //  portrait [_] < 100 > [___] landscape
    const newWidth = this.calculateSize(slide.imageLink, aspectRatio);
    const newScale = this.calculateScale(newWidth);

    // must set this to bubble up into lazysizes event capture,
    // so that the image "sizes" attribute value is correctly set,
    // and not just a scaled up version of the image loaded based on the viewport width alone.
    if (lazyEvent) {
      lazyEvent.detail.width = Math.round(newWidth);
    }
    window.requestAnimationFrame(() => {
      if (imageWidth) {
        slide.image.style.width = `${this.baseScale}px`; // retain this value
      }
      slide.image.style[this.styleTransform] = `scale(${newScale})`;

      // update figure caption container to match image width
      if (slide.media && mediaShow) {
        slide.media.style.width = this.options.descFullWidth ? "100%" : `${newWidth}px`;
        slide.media.classList.remove("u-invisible"); // caption is hidden until image is loaded
        this.updateSlideCaption(slide);
      }
    });
    return newScale;
  },

  updateSlideCaption: function(slide) {
    this.slideCaption.innerHTML = slide.type !== "photo" ? "" : slide.info.innerHTML;
  },

  calculateScale: function(width) {
    return !width ? 1 : (width / this.baseScale);
  },

  calculateWidth: function(scale) {
    return !scale ? this.baseScale : (scale * this.baseScale);
  },

  // update image style width to match detail width, which also matches sizes attribute
  calculateSize: function(container, aspectRatio = 100) {
    if (!container) {
      return 0;
    }
    let maxHeight = container.clientHeight;
    let maxWidth = container.clientWidth;

    // maximum values depending on image orientation
    if (aspectRatio > 100) {
      maxWidth = Math.min(maxWidth, this.landscapeMax); // for landscape images
    } else {
      maxHeight = Math.min(maxHeight, this.portraitMax); // for portrait images
    }
    let ret = maxWidth; // return new width value
    const containerRatio = (maxWidth / maxHeight) * 100; // same formula as back-end

    // basically if display is wider than image, and
    // the image height is no longer contained within display height
    if (containerRatio > aspectRatio) {
      ret = (aspectRatio / containerRatio) * maxWidth;
    }
    return ret;
  },

  // toggle the image figure caption section
  toggleInterface: function() {
    if (!this.options.enableInfo) {
      return;
    }
    window.requestAnimationFrame(function onSliderToggleInterfaceAnimation() {
      if (this.hasInfoShown()) {
        this.hideInfo();
      } else {
        this.showInfo();
      }
    }.bind(this));
  },

  hasInfoShown: function() {
    return this.rootElement.classList.contains(slideHasInfoClass);
  },

  hasNavShown: function() {
    return this.rootElement.classList.contains(slideHasNavClass);
  },

  showNav: function() {
    this.rootElement.classList.add(slideHasNavClass);
  },

  hideNav: function() {
    this.rootElement.classList.remove(slideHasNavClass);
  },

  showInfo: function() {
    this.rootElement.classList.add(slideHasInfoClass);
    this.slides.map(slide => {
      if (slide.info) {
        this.slideCaption.removeAttribute("aria-hidden");
      }
    });
    ee.emitEvent(`${eventNamespace}:toggleinfo`, [this]);
  },

  hideInfo: function() {
    this.rootElement.classList.remove(slideHasInfoClass);
    this.slides.map(slide => {
      if (slide.info) {
        this.slideCaption.setAttribute("aria-hidden", "true");
      }
    });
    ee.emitEvent(`${eventNamespace}:toggleinfo`, [this]);
  },

  hideShareTools: function() {
    this.rootElement.classList.remove(slideHasShareToolsClass);
    this.slideShareTools && this.slideShareTools.classList.add("u-invisible");
  },

  updateButtonStates: function() {
    // cannot slide from first to last
    if (this.slideActive === 0) {
      this.slideNavButtonLeft.setAttribute("aria-disabled", "true");
      this.slideNavButtonLeft.setAttribute("disabled", "disabled");
      this.slideNavLeft.setAttribute("disabled", "disabled");
    } else {
      this.slideNavButtonLeft.removeAttribute("aria-disabled");
      this.slideNavButtonLeft.removeAttribute("disabled");
      this.slideNavLeft.removeAttribute("disabled");
    }
    // could slide from last to first
    if ((!this.options.infiniteLoop && this.slideActive === this.getLastIndex()) ||
      (this.options.infiniteLoop && this.slides.length <= 1)) {
      this.slideNavButtonRight.setAttribute("aria-disabled", "true");
      this.slideNavButtonRight.setAttribute("disabled", "disabled");
      this.slideNavRight.setAttribute("disabled", "disabled");
    } else {
      this.slideNavButtonRight.removeAttribute("aria-disabled");
      this.slideNavButtonRight.removeAttribute("disabled");
      this.slideNavRight.removeAttribute("disabled");
    }
  },

  isKeySpaceOrEnter: function(e) {
    switch (e.keyCode) {
      case 32: // spacebar
      case 13: // enter
        if (!e.metaKey) {
          return true;
        }
        break;
    }
    return false;
  },

  // public method
  // recalc widths and setup slides with new widths, then reset the zoom
  onSliderWindowResize: function() {
    // get the slider width
    const dimensions = this.rootElement.getBoundingClientRect();
    // transition is slowest for touch input
    this.slideWidth = dimensions.width + this.slideMargin;
    // transition is faster for mouse input
    this.clickWidth = this.slideWidth / 10;
    // transition is fastest (instant) for keyboard input
    this.keyWidth = 0;
    // default to slide width when setting up other slide positions
    this.setupSlides(this.slideActive, this.slideWidth);
    this.resetZoom();
  },

  onSliderPanStart: function(e) {
    if (!this.zoomed) {
      this.onSliderPanStartRegular(e);
    }
  },

  onSliderPanMove: function(e) {
    if (this.zoomed) {
      this.onSliderPanMoveZoomed(e);
    } else {
      this.onSliderPanMoveRegular(e);
    }
  },

  onSliderPanEnd: function(e) {
    if (this.zoomed) {
      this.onSliderPanEndZoomed(e);
    } else {
      this.onSliderPanEndRegular(e);
    }
  },

  onSliderPanMoveZoomed: function(e) {
    if (e.direction === Hammer.DIRECTION_NONE) {
      return;
    }
    // continue pan action from previous offset values
    const offsetX = this.oldsetX + (e.deltaX / this.scaleZoom);
    const offsetY = this.oldsetY + (e.deltaY / this.scaleZoom);
    this.positionImage(this.scaleZoom, offsetX, offsetY, true);
  },

  onSliderPanEndZoomed: function() {
    this.positionImage(this.scaleZoom, this.offsetX, this.offsetY, true);
    this.oldsetX = this.offsetX;
    this.oldsetY = this.offsetY;
  },

  positionImage: function(scale = 1, offsetX = 0, offsetY = 0, restrict = false) {
    let oX = offsetX;
    let oY = offsetY;

    // do not allow user to pan off of image region
    // aka: do not allow viewport to leave image region
    if (restrict) {
      if (offsetX > this.minsetX && offsetX < this.maxsetX) {
        // nothing
      } else if (offsetX <= this.minsetX) {
        oX = this.minsetX;
      } else if (offsetX >= this.maxsetX) {
        oX = this.maxsetX;
      }
      if (offsetY > this.minsetY && offsetY < this.maxsetY) {
        // nothing
      } else if (offsetY <= this.minsetY) {
        oY = this.minsetY;
      } else if (offsetY >= this.maxsetY) {
        oY = this.maxsetY;
      }
    }

    this.scaleZoom = scale;
    this.offsetX = oX;
    this.offsetY = oY;

    window.requestAnimationFrame(() => {
      this.imageZoom.style[this.styleTransform] = `scale(${this.scaleZoom}) translate(${this.offsetX}px, ${this.offsetY}px)`;
    });
  },

  onSliderPanStartRegular: function() {
    this.setupSlides(this.slideActive, this.slideWidth);
  },

  onSliderPanMoveRegular: function(e) {
    const absoluteX = Math.abs(e.deltaX);
    const absoluteY = Math.abs(e.deltaY);
    const distX = (absoluteX > (this.distanceThreshold / 8)) ? e.deltaX : 0;
    const distY = (absoluteY > (this.distanceThreshold / 2)) ? e.deltaY : 0;

    this.lockAxis = 0; // no movement
    if (e.direction === Hammer.DIRECTION_LEFT || e.direction === Hammer.DIRECTION_RIGHT) {
      this.lockAxis = 1; // x-axis (user intends to change slide)
    } else if (this.options.enableDismiss &&
      (e.direction === Hammer.DIRECTION_UP || e.direction === Hammer.DIRECTION_DOWN)) {
      this.lockAxis = -1; // y-axis (user intends to dismiss/close slider)
    }

    if (this.lockAxis !== 0) {
      window.requestAnimationFrame(function onSliderTouchMoveAnimation() {
        this.translateSlide(this.slideActive, this.lockAxis, distX, distY, this.slideWidth);
      }.bind(this));
    }
  },

  onSliderPanEndRegular: function(e) {
    const absoluteX = Math.abs(e.deltaX);
    const absoluteY = Math.abs(e.deltaY);
    const speedX = Math.abs(e.velocityX);
    const speedY = Math.abs(e.velocityY);

    if (absoluteX > absoluteY) {
      if (e.deltaX > 0) {
        // cannot slide from first to last
        if (this.slideActive >= 1 &&
          (absoluteX > this.distanceThreshold || speedX > this.velocityThreshold)) {
          this.slideToTouchEvent(this.getPrevIndex()); // left
        } else {
          this.slideToTouchEvent(this.slideActive); // cancel
        }
      } else if (e.deltaX < 0) {
        // could slide from last to first
        if (this.slideActive <= this.slides.length - (this.options.infiniteLoop ? 1 : 2) &&
          (absoluteX > this.distanceThreshold || speedX > this.velocityThreshold)) {
          this.slideToTouchEvent(this.getNextIndex()); // right
        } else {
          this.slideToTouchEvent(this.slideActive); // cancel
        }
      }
    } else {
      if (e.deltaY < 0) {
        if (absoluteY > (this.distanceThreshold * this.yAxisMultiplier) || speedY > this.velocityThreshold * this.yAxisMultiplier) {
          this.closeAnimation(this.slideActive, e.deltaX, true); // up
        } else {
          this.slideToTouchEvent(this.slideActive); // cancel
        }
      } else if (e.deltaY > 0) {
        if (absoluteY > (this.distanceThreshold * this.yAxisMultiplier) || speedY > this.velocityThreshold * this.yAxisMultiplier) {
          this.closeAnimation(this.slideActive, e.deltaX, false); // down
        } else {
          this.slideToTouchEvent(this.slideActive); // cancel
        }
      }
    }
  },

  // toggle the visibility of the caption section
  onSliderSingleTap: function(e) {
    if (e.target === this.slideCanvas) {
      this.toggleInterface();
    }
  },

  // reset the slide photo to default position
  onSliderDoubleTap: function() {
    this.slideToTouchEvent(this.slideActive);
  },

  onSliderPinchStart: function(e) {
    this.imageZoom = this.slides[this.slideActive].image;

    // determine pinch center point relative to center of viewport
    const halfWidth = this.rootElement.clientWidth / 2;
    const halfHeight = this.rootElement.clientHeight / 2;

    // pinch center x-axis point relative to center of viewport
    if (e.center.x > halfWidth) {
      this.centerX = e.center.x - halfWidth;
    } else {
      this.centerX = -(halfWidth - e.center.x);
    }

    // pinch center y-axis point relative to center of viewport
    if (e.center.y > halfHeight) {
      this.centerY = e.center.y - halfHeight;
    } else {
      this.centerY = -(halfHeight - e.center.y);
    }
  },

  onSliderPinchMove: function(e) {
    // continue from previous position
    let offsetX = this.oldsetX;
    let offsetY = this.oldsetY;
    let newScale = this.scaleBase * e.scale;

    // use a different scaleMin value depending on pinchout or pinchin
    let scaleMinDirection = (e.additionalEvent === "pinchout") ? this.scaleMin : (this.scaleMin * 1.2);

    // prevent scale from being too small or large
    if (newScale < scaleMinDirection) {
      newScale = this.scaleMin;
      // re-center image, since it is no longer zoomed
      this.slideToTouchEvent(this.slideActive);
      return;
    } else if (newScale > this.scaleMax) {
      newScale = this.scaleMax;
    }

    // do not process if scale did not change
    if ((newScale - this.scaleBase) !== 0) {
      // determine where pinch center point would be at for new zoom scale,
      // relative to the center of the viewport/slider
      const newCenterX = (this.centerX * newScale) / this.scaleBase;
      const newCenterY = (this.centerY * newScale) / this.scaleBase;

      // calc offset values so that new pinch center point location
      // is the same as previous location (relative to center of viewport/slider)
      offsetX += (this.centerX - newCenterX) / newScale;
      offsetY += (this.centerY - newCenterY) / newScale;

      // position image without restricting offset values
      this.positionImage(newScale, offsetX, offsetY, false);
    }
  },

  onSliderPinchEnd: function() {
    this.scaleBase = this.scaleZoom;
    this.zoomed = (this.scaleBase > this.scaleMin);

    // setup min and max values for panning when zoomed
    this.maxsetX = Math.abs((this.imageZoom.width / 2) - ((this.rootElement.clientWidth / this.scaleZoom) / 2));
    this.minsetX = -(this.maxsetX);
    this.maxsetY = Math.abs((this.imageZoom.height / 2) - ((this.rootElement.clientHeight / this.scaleZoom) / 2));
    this.minsetY = -(this.maxsetY);

    // don't recale offset, use previous values, but restrict offset values this time
    this.positionImage(this.scaleZoom, this.offsetX, this.offsetY, true);

    // save offset position
    this.oldsetX = this.offsetX;
    this.oldsetY = this.offsetY;

    // attempt to load a lower/higher quality image, based on the zoom scale
    this.updateLazyElement(this.slideActive, this.scaleZoom);
  },

  // trigger lazysizes update element event
  // will update "sizes" attribute with lazyWidth
  // and display appropriate image from data.srcset
  updateLazyElement: function(index, scale) {
    // trigger lazysizes, to update the image width
    const imageZoom = this.slides[index].image;
    if (imageZoom) {
      const lazyWidth = Math.round(this.calculateWidth(scale));
      // false value is for e.detail.dataAttr value (to be ignored in onLazyBeforeSizes)
      window.lazySizes.autoSizer.updateElem(imageZoom, false, lazyWidth);
    }
  },

  // animate the photo slider to appear that it swipes up or down (dismissed)
  closeAnimation: function(slideActive, offsetX, upward) {
    if (!this.options.enableDismiss) {
      return;
    }

    window.requestAnimationFrame(function onCloseAnimationStart() {
      // hide the ui components
      this.hideShareTools();
      this.hideInfo();
      this.hideNav();
      // perform the translation up or down
      const positionY = (upward ? -1 : 1) * (this.rootElement.clientHeight);
      this.translateSlide(slideActive, -1, offsetX, positionY, this.slideWidth);
    }.bind(this));

    // fire event after above tranlsate is complete
    reqTimeout.setTimeout(function closeAnimationComplete() {
      this.slideList.classList.add("u-invisible");
      this.destroy();
      ee.emitEvent(`${eventNamespace}:swipeup`, [this]);
    }.bind(this), this.slideSpeed / 2);
  },

  // unbind stuff
  destroy: function() {
    this.HammerSlide && this.HammerSlide.destroy();
    this.HammerTouch && this.HammerTouch.destroy();
  },

  // triggered by touch screen input
  slideToTouchEvent: function(index) {
    this.slideTo(index, false, this.slideWidth);
  },

  // triggered by mouse input
  slideToClickEvent: function(index) {
    this.slideTo(index, false, this.clickWidth);
  },

  // triggered by keyboard input
  slideToKeyEvent: function(index) {
    this.slideTo(index, true, this.keyWidth);
  },

  // slide index is zero-based
  // for certain trigger events, you would not want to set focus on the new slide
  // for certain trigger events, animation is instant
  slideTo: function(slideIndex, setFocus, slideWidth) {
    if (this.sliding) {
      return;
    }
    this.sliding = true;

    // validate requested slide index
    this.slideActive = this.safeIndex(this.slides, slideIndex);

    // reset global vars and setup for new image
    this.resetZoom();

    // setup the slide animation move
    window.requestAnimationFrame(function onSlideToAnimation() {
      this.translateSlide(this.slideActive, 0, 0, 0, slideWidth);
      this.updateSlideStates();
      this.updateButtonStates();
      this.scaleSlide(this.getNextIndex());
      this.scaleSlide(this.getPrevIndex());

      // a setTimeout is needed here, since we want to set focus below, after the slide is fully in view.
      // otherwise the focus would cause the viewport to jump ahead of the animation.
      reqTimeout.setTimeout(function onSlideToTimeout() {
        // only set focus when event did not originate from mouse click
        // so set focus for touch input and key input
        if (setFocus) {
          this.setFocus(this.slideActive);
        }
        ee.emitEvent(`${eventNamespace}:selected`, [this]);
        this.setupSlides(this.slideActive, slideWidth);
        this.sliding = false;
      }.bind(this), this.slideSpeed);
    }.bind(this));
  },

  // public method
  // set keyboard focus on slide item link
  setFocus: function(index) {
    const active = index || this.slideActive;
    if (this.slides[active].imageLink) {
      this.slides[active].imageLink.focus();
    } else if (this.slides[active].type === "ad") {
      // Make sure this element has tabindex="-1"
      this.slides[active].root.getElementsByClassName("c-ad__wrapper")[0].focus();
    }
  },

  // setup the initial positions of the neighbouring slides (excluding the current slide)
  setupSlides: function(slideIndex, slideWidth) {
    const nextX = slideWidth;
    const prevX = -(slideWidth);

    this.slides.forEach((slide, index) => {
      let oX = 0;
      // could slide from last to first - this slide is first and last slide is active
      if (this.options.infiniteLoop && this.slides.length > 1 && index === 0 && slideIndex === this.getLastIndex()) {
        oX = nextX; // slide could be next or previous, but favour next.
      } else if (index > slideIndex) { // this slide is after active slide
        oX = nextX;
      } else if (index < slideIndex) { // this slide is before active slide
        oX = prevX;
      }
      slide.root.style.transform = `translate(${oX}px, 0)`;
    });
  },

  // incrementally position the previous/current/next slide(s)
  translateSlide: function(slideIndex, lockAxis, offsetX, offsetY, slideWidth) {
    let oX = (lockAxis === -1) ? 0 : offsetX; // y-axis locked?
    let oY = (lockAxis === 1) ? 0 : offsetY; // x-axis locked?

    // cannot slide from first to last
    const prevIndex = slideIndex - 1;

    // could slide from last to first
    const nextIndex = this.options.infiniteLoop ? this.safeIndex(this.slides, (slideIndex + 1)) : (slideIndex + 1);

    const nextX = slideWidth + oX;
    const prevX = -(slideWidth) + oX;

    // translate current slide
    if (this.slides[slideIndex]) {
      this.slides[slideIndex].root.style.transform = `translate(${oX}px, ${oY}px)`;
    }

    // optionally translate sibiling slides
    if (this.slides.length > 1) {
      if (oX > 0) { // sliding right, to previous slide
        if (this.slides[prevIndex] && prevIndex !== slideIndex) {
          this.slides[prevIndex].root.classList.remove("u-invisible");
          this.slides[prevIndex].root.style.transform = `translate(${prevX}px, ${oY}px)`;
        }
        if (this.slides[nextIndex] && nextIndex !== slideIndex && nextIndex !== prevIndex) {
          this.slides[nextIndex].root.classList.remove("u-invisible");
          this.slides[nextIndex].root.style.transform = `translate(${nextX}px, ${oY}px)`;
        }
      } else if (oX < 0) { // sliding left, to next slide
        if (this.slides[nextIndex] && nextIndex !== slideIndex) {
          this.slides[nextIndex].root.classList.remove("u-invisible");
          this.slides[nextIndex].root.style.transform = `translate(${nextX}px, ${oY}px)`;
        }
        if (this.slides[prevIndex] && prevIndex !== slideIndex && prevIndex !== nextIndex) {
          this.slides[prevIndex].root.classList.remove("u-invisible");
          this.slides[prevIndex].root.style.transform = `translate(${prevX}px, ${oY}px)`;
        }
      }
    }
  },

  updateSlideStates: function() {
    this.slides.forEach((slide, index) => {
      if (index === this.slideActive) {
        slide.root.removeAttribute("aria-hidden");
        slide.root.classList.remove("u-invisible");
        this.updateSlideCaption(slide);
      } else {
        slide.root.setAttribute("aria-hidden", "true");
        slide.root.classList.add("u-invisible");
      }
    });
  },

  onSlideNavLeft: function(e) {
    if (this.hasNavShown()) {
      if (e.type === "click" || (e.type === "keydown" && this.isKeySpaceOrEnter(e))) {
        e.preventDefault();
        e.stopPropagation();
        if (!this.slideNavButtonLeft.hasAttribute("disabled")) {
          this.slideToClickEvent(this.getPrevIndex()); // previous
        }
      }
    }
  },

  onSlideNavRight: function(e) {
    if (this.hasNavShown()) {
      if (e.type === "click" || (e.type === "keydown" && this.isKeySpaceOrEnter(e))) {
        e.preventDefault();
        e.stopPropagation();
        if (!this.slideNavButtonRight.hasAttribute("disabled")) {
          this.slideToClickEvent(this.getNextIndex()); // next
        }
      }
    }
  },

  // prevent all images from showing ghosts when dragged via mouse
  onHideGhostImages: function() {
    this.slides.forEach((slide) => {
      slide.ghosts.forEach((ghost) => {
        ghost.setAttribute("draggable", "false"); // prevent ghost image drag via mouse
      });
      if (slide.imageLink) {
        slide.imageLink.setAttribute("draggable", "false"); // prevent ghost link drag via mouse
      }
    });
  },

  onRootElementKeyDown: function(e) {
    let match = false;
    switch (e.keyCode) {
      case 34: // page down
      case 39: // right
      case 40: // down
        match = true;
        // could slide from last to first
        this.slideToKeyEvent(this.getNextIndex()); // next
        break;

      case 33: // page up
      case 37: // left
      case 38: // up
        match = true;
        // cannot slide from first to last
        this.slideActive >= 1 && this.slideToKeyEvent(this.getPrevIndex()); // previous
        break;

      case 36: // home
        match = true;
        this.slideToKeyEvent(0); // first
        break;

      case 35: // end
        match = true;
        this.slideToKeyEvent(this.getLastIndex()); // last
        break;

      // delegate the slide element events to root element
      case 32: // spacebar
      case 13: // enter
        // allow event to pass through if the focused element
        // is outside of the slide list (i.e. share tools)
        if (document.activeElement.closest(`.${slideListClass}`)) {
          match = true;
          this.toggleInterface();
        }
        break;
    }
    // allow all other key events bubble
    if (match && !e.metaKey) {
      e.preventDefault();
      e.stopPropagation();
    }
  },

  // public method
  // generate HTML for a photo slider component
  generatePhotoSlider: function(galleryPhotos, params) {
    if (!galleryPhotos) {
      return;
    }

    // default options
    const defaults = {
      type: "full", // `c-photo-slider--full` or `c-photo-slider--contain`
      isAdv: false, // true when article is ADV
      jsHookClass: "js-c-photo-slider",
      svgSpritePath: "sprite.svg",
      shareUrlEmail: false,
      shareUrlTwitter: false,
      shareUrlFacebook: false,
      shareUrlLinkedIn: false,
      shareUrlWhatsApp: false,
      analyticsClickDelay: 300,
      eventDataEmail: false,
      eventDataTwitter: false,
      eventDataFacebook: false,
      eventDataLinkedIn: false,
      eventDataWhatsApp: false
    };
    const options = this.merge(defaults, params);

    const shareItems = (options.shareUrlEmail || options.shareUrlTwitter || options.shareUrlFacebook || options.shareUrlLinkedIn || options.shareUrlWhatsApp)
      ? [
        { href: options.shareUrlEmail, code: "email", label: "Email to friend", delay: options.analyticsClickDelay, event: options.eventDataEmail },
        { href: options.shareUrlTwitter, code: "twitter", label: "Share on Twitter", delay: options.analyticsClickDelay, event: options.eventDataTwitter },
        { href: options.shareUrlFacebook, code: "facebook", label: "Share on Facebook", delay: options.analyticsClickDelay, event: options.eventDataFacebook },
        { href: options.shareUrlLinkedIn, code: "linkedin", label: "Share on LinkedIn", delay: options.analyticsClickDelay, event: options.eventDataLinkedIn },
        { href: options.shareUrlWhatsApp, code: "whatsapp", label: "Share on WhatsApp", delay: options.analyticsClickDelay, event: options.eventDataWhatsApp }
      ] : [];

    let slideList = []; // all slide list items
    let adsTotal = galleryPhotos.filter((i) => i.slideType === "ad").length; // total of adverts only
    let slidesTotal = galleryPhotos.length - adsTotal; // total of photos only
    let adsEncountered = 0; // ad slides shouldn't increment the caption counter

    galleryPhotos.forEach((photo, index) => {
      if (photo.slideType && photo.slideType === "ad") {
        adsEncountered++;
        slideList.push(this.generateAd(photo.slideMarkup));
      } else {
        slideList.push(this.generateListItem(
          this.generateImage(index, photo.placeholderSrc, photo.src, photo.srcset, photo.aspectRatio, photo.alt),
          this.generateFigure(index - adsEncountered, slidesTotal, options.isAdv, photo.caption, photo.credit)
        ));
      }
    });

    let shareHTML = [];
    shareItems.forEach((social) => {
      if (social.href) {
        shareHTML.push(this.generateShareLink(options.svgSpritePath,
          social.href, social.code, social.label, social.delay, social.event));
      }
    });

    const caption = this.generateCaption(options.isAdv);
    const shareTools = (shareHTML.length === 0) ? null : this.generateShareTools(shareHTML.join(""));
    const deck = this.generateSlideDeck(options.jsHookClass, options.svgSpritePath, slideList.join(""),
      caption, shareTools, options.type);
    return (options.type === "contain") ? this.generateContainer(deck) : deck;
  },

  // generate HTML for a contained inpage slider
  generateContainer: function(content = "") {
    return `
      <div class="c-contain-slider c-contain-slider--ratio-3-2">${content}</div>`;
  },

  // generate HTML for a single slide image
  generateImage: function(index, placeholderSrc, dataSrc, dataSrcSet, aspectRatio, altText) {
    // base64 encode of 1x1px transparent GIF
    const hasSrc = placeholderSrc ? `src="${placeholderSrc}"` : "src=\"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7\"";
    const hasDataSrc = dataSrc ? `data-src="${dataSrc}"` : "";
    const hasDataSrcSet = dataSrcSet ? `data-srcset="${dataSrcSet}"` : "";
    const hasAspectRatio = `data-aspect-ratio="${aspectRatio}"`;
    const hasAlt = altText ? `alt="${altText}"` : "";
    return `
      <a href="#" class="c-article-media__link">
        <img data-index="${index}"
          class="${slideImageClass} ${lazyConfig.lazyClass}"
          ${hasSrc}
          ${hasDataSrc}
          data-sizes="auto"
          ${hasDataSrcSet}
          ${hasAspectRatio}
          ${hasAlt} />
        <div class="c-spinner c-spinner-lg c-spinner-light"></div>
      </a>`;
  },

  // generate HTML for a single slide advertisement
  generateAd: function(content = "") {
    return `
    <li class="c-photo-slider__item c-photo-slider__item--ad">
      <div class="c-photo-slider__item-content">
        ${content}
      </div>
    </li>`;
  },

  // generate HTML for a single (hidden) slide image figure caption
  generateFigure: function(index, total, isAdv, caption = "", credit = "") {
    const hasMediaCaption = caption ? `<p class="c-article-media__caption">${caption}</p>` : "";
    const hasMediaCredit = credit ? `<p class="c-article-media__credit">${credit}</p>` : "";
    const hasAdv = isAdv ? " c-article-media__info--adv" : "";
    return `
      <figcaption class="c-article-media__info c-article-media__info--line c-article-media__info--dark c-article-media__info--center${hasAdv}">
        <div class="c-article-media__center u-invisible">
          <div class="c-article-media__description">
            ${hasMediaCaption}
            ${hasMediaCredit}
          </div>
          <p class="c-article-media__label">${index + 1} of ${total}</p>
        </div>
      </figcaption>`;
  },

  // generate HTML for the shared active image caption
  generateCaption: function(isAdv) {
    const hasAdv = isAdv ? " c-article-media__info--adv" : "";
    // aria hide this caption, since each slide image already has an accessible figcaption
    return `
      <div class="c-photo-slider__caption c-article-media__info c-article-media__info--line c-article-media__info--dark c-article-media__info--center${hasAdv}"></div>`;
  },

  // generate the HTML for a single slide list item
  generateListItem: function(slideImage = "", slideCaption = "") {
    return `
      <li class="c-photo-slider__item c-photo-slider__item--photo">
        <div class="c-photo-slider__item-content">
          <figure class="c-article-media c-article-media--overlay">
            <div class="c-article-media__image c-article-media__image--full-fit">
              ${slideImage}
            </div>
            ${slideCaption}
          </figure>
        </div>
      </li>`;
  },

  // generate the HTML for the share tools section
  generateShareTools: function(shareItems = "") {
    return `
      <div class="c-photo-slider__share-tools">
        <div class="c-share-tools c-share-tools--dark u-roofless u-floorless">
          ${shareItems}
        </div>
      </div>`;
  },

  // generate the HTML for the share tool link
  generateShareLink: function(svgSpritePath, href = "#", code = "null", label = "Share", delay, event) {
    const hasAnalytics = event ? ` data-analyticsclick='${event}' data-analyticsclickdelay="${delay}"` : "";
    return `
      <span class="c-share-tool c-share-tool--${code}">
        <a href="${href}" class="c-share-tool__link c-share-tool__link--${code}" aria-label="${label}"${hasAnalytics}>
          <svg class="c-share-tool__icon c-share-tool__icon--${code}" role="img" pointer-events="none" focusable="false" aria-hidden="true">
            <use xlink:href="${svgSpritePath}#icon-${code}"></use>
          </svg>
          <span class="u-visually-hidden">${label}</span>
        </a>
      </span>`;
  },

  // generate slide deck with: prev/next buttons, slides, and share tools
  generateSlideDeck: function(jsHookClass, svgSpritePath, slidesHtml, caption, shareTools, type) {
    const hookClass = jsHookClass || "js-c-photo-slider";
    const hasShareTools = shareTools ? "c-photo-slider--has-share-tools" : "";
    return `
      <div class="c-photo-slider ${hookClass} ${hasShareTools} c-photo-slider--has-info c-photo-slider--has-nav c-photo-slider--${type}"
      role="region" aria-label="Photo slideshow" tabindex="0">
        <div class="c-photo-slider__canvas">
          ${this.generateSlideButton(false, svgSpritePath)}
          ${this.generateSlideButton(true, svgSpritePath)}
          <ul class="c-photo-slider__list" aria-live="polite" aria-atomic="true">
            ${slidesHtml}
          </ul>
        </div>
        ${caption || ""}
        ${shareTools || ""}
      </div>`;
  },

  // generate a prev/next button
  generateSlideButton: function(next, svgSpritePath) {
    const direction = next ? "right" : "left";
    const label = next ? "next" : "previous";
    return `
      <div class="c-photo-slider__nav c-photo-slider__nav--${direction}">
        <button class="c-photo-slider__nav-button c-photo-slider__nav-button--${direction}" aria-label="View ${label} photo">
          <svg class="c-photo-slider__nav-icon" role="img" pointer-events="none" focusable="false" aria-hidden="true">
            <use xlink:href="${svgSpritePath}#nav-arrow-${direction}"></use>
          </svg>
        </button>
      </div>`;
  },

  // simple shallow extend/assign method
  merge: function(obj, src) {
    for (let key in src) {
      if (src.hasOwnProperty(key)) {
        obj[key] = src[key];
      }
    }
    return obj;
  },

  // public method
  // return unique identifier class name
  getRootClass: function() {
    return this.rootClass;
  },

  // public method
  // return current zero-based slide index, optionally by slide type group
  getIndex: function(byType = false) {
    if (byType) {
      return this.slides[this.slideActive].placement - 1;
    } else {
      return this.slideActive;
    }
  },

  // public method
  // return next slide index (loop to start)
  getNextIndex: function() {
    let nextIndex = this.slideActive + 1;
    if (!this.slides[nextIndex]) {
      nextIndex = 0;
    }
    return nextIndex;
  },

  // public method
  // return previous slide index (loop to end)
  getPrevIndex: function() {
    let prevIndex = this.slideActive - 1;
    if (prevIndex < 0) {
      prevIndex = this.getLastIndex();
    }
    return prevIndex;
  },

  // public method
  // return current slide type
  getSlideType: function() {
    return this.slides[this.slideActive].type;
  },

  // public method
  // return next slide type
  getNextSlideType: function() {
    return this.slides[this.getNextIndex()].type;
  },

  // public method
  // return previous slide type
  getPrevSlideType: function() {
    return this.slides[this.getPrevIndex()].type;
  },

  // public method - Deprecated!
  getNumberOfAdSlidesBeforeCurrent: function() {
    return this.slides.filter((slide, index) => {
      return index < this.slideActive && slide.type === "ad";
    }).length;
  },

  // return the last slide index
  getLastIndex: function() {
    return this.slides.length - 1;
  },

  // public method
  // return the length by slide type
  getLength: function(slideType) {
    if (slideType && this.lengths[slideType]) {
      return this.lengths[slideType];
    } else {
      return this.slides.length;
    }
  },

  // return a safe index value for an array, within zero and the array length.
  // loop to start or end if infinite option active.
  safeIndex: function(list, index) {
    if (!list || !Array.isArray(list) || index === null || typeof index === "undefined") {
      return 0;
    } else if (index < 0) {
      return 0; // cannot slide from first to last
    } else if (index >= list.length) {
      return this.options.infiniteLoop ? 0 : list.length - 1; // could slide from last to first
    } else {
      return index;
    }
  },

  vendorPrefix: function(property) {
    let div = document.createElement("div");
    let prop = property.toLowerCase();
    if (div.style[prop] == null) {
      let vendors = ["webkit", "moz", "ms", "o"];
      for (let v in vendors) {
        if (div.style[vendors[v] + property] !== undefined) {
          return vendors[v] + property;
        }
      }
    }
    return prop;
  }

};
