/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

/*
 * The spinner is responsible for displaying the items, and does
 * not care what the values represent. The setValue function is called
 * when it detects a change in value triggered by scroll event.
 * Supports scrolling, clicking on up or down, clicking on item, and
 * dragging.
 */

function Spinner(props, context) {
  this.context = context;
  this._init(props);
}

{
  const ITEM_HEIGHT = 2.5,
    VIEWPORT_SIZE = 7,
    VIEWPORT_COUNT = 5;

  Spinner.prototype = {
    /**
     * Initializes a spinner. Set the default states and properties, cache
     * element references, create the HTML markup, and add event listeners.
     *
     * @param  {object} props [Properties passed in from parent]
     *         {
     *           {Function} setValue: Takes a value and set the state to
     *             the parent component.
     *           {Function} getDisplayString: Takes a value, and output it
     *             as localized strings.
     *           {Number} viewportSize [optional]: Number of items in a
     *             viewport.
     *           {Boolean} hideButtons [optional]: Hide Prev & Next buttons
     *           {Number} rootFontSize [optional]: Used to support zoom in/out
     *         }
     */
    _init(props) {
      const {
        id,
        setValue,
        getDisplayString,
        hideButtons,
        rootFontSize = 10,
      } = props;

      const spinnerTemplate = document.getElementById("spinner-template");
      const spinnerElement = document.importNode(spinnerTemplate.content, true);

      // Make sure viewportSize is an odd number because we want to have the selected
      // item in the center. If it's an even number, use the default size instead.
      const viewportSize =
        props.viewportSize % 2 ? props.viewportSize : VIEWPORT_SIZE;

      this.state = {
        items: [],
        isScrolling: false,
      };
      this.props = {
        setValue,
        getDisplayString,
        viewportSize,
        rootFontSize,
        // We can assume that the viewportSize is an odd number. Calculate how many
        // items we need to insert on top of the spinner so that the selected is at
        // the center. Ex: if viewportSize is 5, we need 2 items on top.
        viewportTopOffset: (viewportSize - 1) / 2,
      };
      this.elements = {
        container: spinnerElement.querySelector(".spinner-container"),
        spinner: spinnerElement.querySelector(".spinner"),
        prev: spinnerElement.querySelector(".prev"),
        next: spinnerElement.querySelector(".next"),
        itemsViewElements: [],
      };

      this.elements.spinner.style.height = ITEM_HEIGHT * viewportSize + "rem";

      // Prepares the spinner container to function as a spinbutton and expose
      // its properties to assistive technology
      this.elements.spinner.setAttribute("role", "spinbutton");
      this.elements.spinner.setAttribute("tabindex", "0");
      // Remove Prev/Next buttons from the focus order, because a keyboard-only
      // user can adjust values by pressing Up/Down arrow keys on a spinbutton,
      // otherwise it creates extra, redundant tab order stops for users
      this.elements.prev.setAttribute("tabindex", "-1");
      this.elements.next.setAttribute("tabindex", "-1");

      if (id) {
        this.elements.container.id = id;
      }
      if (hideButtons) {
        this.elements.container.classList.add("hide-buttons");
      }

      this.context.appendChild(spinnerElement);
      this._attachEventListeners();
    },

    /**
     * Only the parent component calls setState on the spinner.
     * It checks if the items have changed and updates the spinner.
     * If only the value has changed, smooth scrolls to the new value.
     *
     * @param {object} newState [The new spinner state]
     *        {
     *          {Number/String} value: The centered value
     *          {Array} items: The list of items for display
     *          {Boolean} isInfiniteScroll: Whether or not the spinner should
     *            have infinite scroll capability
     *          {Boolean} isValueSet: true if user has selected a value
     *        }
     */
    setState(newState) {
      const { value, items } = this.state;
      const {
        value: newValue,
        items: newItems,
        isValueSet,
        isInvalid,
        smoothScroll = true,
      } = newState;

      if (this._isArrayDiff(newItems, items)) {
        this.state = Object.assign(this.state, newState);
        this._updateItems();
        this._scrollTo(newValue, /* centering = */ true, /* smooth = */ false);
      } else if (newValue != value) {
        this.state = Object.assign(this.state, newState);
        this._scrollTo(newValue, /* centering = */ true, smoothScroll);
      }

      this.elements.spinner.setAttribute(
        "aria-valuemin",
        this.state.items[0].value
      );
      this.elements.spinner.setAttribute(
        "aria-valuemax",
        this.state.items.at(-1).value
      );
      this.elements.spinner.setAttribute("aria-valuenow", this.state.value);
      if (!this.elements.spinner.getAttribute("aria-valuetext")) {
        this.elements.spinner.setAttribute(
          "aria-valuetext",
          this.props.getDisplayString(this.state.value)
        );
      }

      // Show selection even if it's passed down from the parent
      if ((isValueSet && !isInvalid) || this.state.index) {
        this._updateSelection();
      } else {
        this._removeSelection();
      }
    },

    /**
     * Whenever scroll event is detected:
     * - Update the index state
     * - If the value has changed, update the [value] state and call [setValue]
     * - If infinite scrolling is on, reset the scrolling position if necessary
     */
    _onScroll() {
      const { items, itemsView, isInfiniteScroll } = this.state;
      const { viewportSize, viewportTopOffset } = this.props;
      const { spinner } = this.elements;

      this.state.index = this._getIndexByOffset(spinner.scrollTop);

      const value = itemsView[this.state.index + viewportTopOffset].value;

      // Call setValue if value has changed
      if (this.state.value != value) {
        this.state.value = value;
        this.props.setValue(value);
      }

      // Do infinite scroll when items length is bigger or equal to viewport
      // and isInfiniteScroll is not false.
      if (items.length >= viewportSize && isInfiniteScroll) {
        // If the scroll position is near the top or bottom, jump back to the middle
        // so user can keep scrolling up or down.
        if (
          this.state.index < viewportSize ||
          this.state.index > itemsView.length - viewportSize
        ) {
          this._scrollTo(this.state.value, true);
        }
      }

      this.elements.spinner.classList.add("scrolling");
    },

    /**
     * Remove the "scrolling" state on scrollend.
     */
    _onScrollend() {
      this.elements.spinner.classList.remove("scrolling");
      this.elements.spinner.setAttribute(
        "aria-valuetext",
        this.props.getDisplayString(this.state.value)
      );
    },

    /**
     * Updates the spinner items to the current states.
     */
    _updateItems() {
      const { viewportSize, viewportTopOffset } = this.props;
      const { items, isInfiniteScroll } = this.state;

      // Prepends null elements so the selected value is centered in spinner
      let itemsView = new Array(viewportTopOffset).fill({}).concat(items);

      if (items.length >= viewportSize && isInfiniteScroll) {
        // To achieve infinite scroll, we move the scroll position back to the
        // center when it is near the top or bottom. The scroll momentum could
        // be lost in the process, so to minimize that, we need at least 2 sets
        // of items to act as buffer: one for the top and one for the bottom.
        // But if the number of items is small ( < viewportSize * viewport count)
        // we should add more sets.
        let count =
          Math.ceil((viewportSize * VIEWPORT_COUNT) / items.length) * 2;
        for (let i = 0; i < count; i += 1) {
          itemsView.push(...items);
        }
      }

      // Reuse existing DOM nodes when possible. Create or remove
      // nodes based on how big itemsView is.
      this._prepareNodes(itemsView.length, this.elements.spinner);
      // Once DOM nodes are ready, set display strings using textContent
      this._setDisplayStringAndClass(
        itemsView,
        this.elements.itemsViewElements
      );

      this.state.itemsView = itemsView;
    },

    /**
     * Make sure the number or child elements is the same as length
     * and keep the elements' references for updating textContent
     *
     * @param {number} length [The number of child elements]
     * @param {DOMElement} parent [The parent element reference]
     */
    _prepareNodes(length, parent) {
      const diff = length - parent.childElementCount;

      if (!diff) {
        return;
      }

      if (diff > 0) {
        // Add more elements if length is greater than current
        let frag = document.createDocumentFragment();

        // Remove margin bottom on the last element before appending
        if (parent.lastChild) {
          parent.lastChild.style.marginBottom = "";
        }

        for (let i = 0; i < diff; i++) {
          let el = document.createElement("div");
          // Spinbutton elements should be hidden from assistive technology:
          el.setAttribute("aria-hidden", "true");
          frag.appendChild(el);
          this.elements.itemsViewElements.push(el);
        }
        parent.appendChild(frag);
      } else if (diff < 0) {
        // Remove elements if length is less than current
        for (let i = 0; i < Math.abs(diff); i++) {
          parent.removeChild(parent.lastChild);
        }
        this.elements.itemsViewElements.splice(diff);
      }

      parent.lastChild.style.marginBottom =
        ITEM_HEIGHT * this.props.viewportTopOffset + "rem";
    },

    /**
     * Set the display string and class name to the elements.
     *
     * @param {Array<object>} items
     *        [{
     *          {Number/String} value: The value in its original form
     *          {Boolean} enabled: Whether or not the item is enabled
     *        }]
     * @param {Array<DOMElement>} elements
     */
    _setDisplayStringAndClass(items, elements) {
      const { getDisplayString } = this.props;

      items.forEach((item, index) => {
        elements[index].textContent =
          item.value != undefined ? getDisplayString(item.value) : "";
        elements[index].className = item.enabled ? "" : "disabled";
      });
    },

    /**
     * Attach event listeners to the spinner and buttons.
     */
    _attachEventListeners() {
      const { spinner, container } = this.elements;

      spinner.addEventListener("scroll", this, { passive: true });
      spinner.addEventListener("scrollend", this, { passive: true });
      spinner.addEventListener("keydown", this);
      container.addEventListener("mouseup", this, { passive: true });
      container.addEventListener("mousedown", this, { passive: true });
      container.addEventListener("keydown", this);
    },

    /**
     * Handle events
     *
     * @param  {DOMEvent} event
     */
    handleEvent(event) {
      const { mouseState = {}, index, itemsView } = this.state;
      const { viewportTopOffset, setValue } = this.props;
      const { spinner, prev, next } = this.elements;

      switch (event.type) {
        case "scroll": {
          this._onScroll();
          break;
        }
        case "scrollend": {
          this._onScrollend();
          break;
        }
        case "mousedown": {
          this.state.mouseState = {
            down: true,
            layerX: event.layerX,
            layerY: event.layerY,
          };
          if (event.target == prev) {
            // An "active" class is needed to simulate :active pseudo-class
            // because element is not focused.
            event.target.classList.add("active");
            this._smoothScrollToIndex(index - 1);
          }
          if (event.target == next) {
            event.target.classList.add("active");
            this._smoothScrollToIndex(index + 1);
          }
          if (event.target.parentNode == spinner) {
            // Listen to dragging events
            spinner.addEventListener("mousemove", this, { passive: true });
            spinner.addEventListener("mouseleave", this, { passive: true });
          }
          break;
        }
        case "mouseup": {
          this.state.mouseState.down = false;
          if (event.target == prev || event.target == next) {
            event.target.classList.remove("active");
          }
          if (event.target.parentNode == spinner) {
            // Check if user clicks or drags, scroll to the item if clicked,
            // otherwise get the current index and smooth scroll there.
            if (
              event.layerX == mouseState.layerX &&
              event.layerY == mouseState.layerY
            ) {
              const newIndex =
                this._getIndexByOffset(event.target.offsetTop) -
                viewportTopOffset;
              if (index == newIndex) {
                // Set value manually if the clicked element is already centered.
                // This happens when the picker first opens, and user pick the
                // default value.
                setValue(itemsView[index + viewportTopOffset].value);
              } else {
                this._smoothScrollToIndex(newIndex);
              }
            } else {
              this._smoothScrollToIndex(
                this._getIndexByOffset(spinner.scrollTop)
              );
            }
            // Stop listening to dragging
            spinner.removeEventListener("mousemove", this, { passive: true });
            spinner.removeEventListener("mouseleave", this, { passive: true });
          }
          break;
        }
        case "mouseleave": {
          if (event.target == spinner) {
            // Stop listening to drag event if mouse is out of the spinner
            this._smoothScrollToIndex(
              this._getIndexByOffset(spinner.scrollTop)
            );
            spinner.removeEventListener("mousemove", this, { passive: true });
            spinner.removeEventListener("mouseleave", this, { passive: true });
          }
          break;
        }
        case "mousemove": {
          // Change spinner position on drag
          spinner.scrollTop -= event.movementY;
          break;
        }
        case "keydown": {
          // Providing keyboard navigation support in accordance with
          // the ARIA Spinbutton design pattern
          if (event.target === spinner) {
            switch (event.key) {
              case "ArrowUp": {
                // While the spinner is focused, selects previous value and centers it
                this._setValueForSpinner(event, index - 1);
                break;
              }
              case "ArrowDown": {
                // While the spinner is focused, selects next value and centers it
                this._setValueForSpinner(event, index + 1);
                break;
              }
              case "PageUp": {
                // While the spinner is focused, selects 5th value above and centers it
                this._setValueForSpinner(event, index - 5);
                break;
              }
              case "PageDown": {
                // While the spinner is focused, selects 5th value below and centers it
                this._setValueForSpinner(event, index + 5);
                break;
              }
              case "Home": {
                // While the spinner is focused, selects the min value and centers it
                let targetValue;
                for (let i = 0; i < this.state.items.length - 1; i++) {
                  if (this.state.items[i].enabled) {
                    targetValue = this.state.items[i].value;
                    break;
                  }
                }
                this._smoothScrollTo(targetValue);
                event.stopPropagation();
                event.preventDefault();
                break;
              }
              case "End": {
                // While the spinner is focused, selects the max value and centers it
                let targetValue;
                for (let i = this.state.items.length - 1; i >= 0; i--) {
                  if (this.state.items[i].enabled) {
                    targetValue = this.state.items[i].value;
                    break;
                  }
                }
                this._smoothScrollTo(targetValue);
                event.stopPropagation();
                event.preventDefault();
                break;
              }
            }
          }
        }
      }
    },

    /**
     * Find the index by offset
     *
     * @param {number} offset: Offset value in pixel.
     * @return {number}  Index number
     */
    _getIndexByOffset(offset) {
      return Math.round(offset / (ITEM_HEIGHT * this.props.rootFontSize));
    },

    /**
     * Find the index of a value that is the closest to the current position.
     * If centering is true, find the index closest to the center.
     *
     * @param {Number/String} value: The value to find
     * @param {boolean} centering: Whether or not to find the value closest to center
     * @return {number} index of the value, returns -1 if value is not found
     */
    _getScrollIndex(value, centering) {
      const { itemsView } = this.state;
      const { viewportTopOffset } = this.props;

      // If index doesn't exist, or centering is true, start from the middle point
      let currentIndex =
        centering || this.state.index == undefined
          ? Math.round((itemsView.length - viewportTopOffset) / 2)
          : this.state.index;
      let closestIndex = itemsView.length;
      let indexes = [];
      let diff = closestIndex;
      let isValueFound = false;

      // Find indexes of items match the value
      itemsView.forEach((item, index) => {
        if (item.value == value) {
          indexes.push(index);
        }
      });

      // Find the index closest to currentIndex
      indexes.forEach(index => {
        let d = Math.abs(index - currentIndex);
        if (d < diff) {
          diff = d;
          closestIndex = index;
          isValueFound = true;
        }
      });

      return isValueFound ? closestIndex - viewportTopOffset : -1;
    },

    /**
     * Scroll to a value based on the index
     *
     * @param  {number} index: Index number
     * @param  {boolean} smooth: Whether or not scroll should be smooth by default
     */
    _scrollToIndex(index, smooth) {
      // Do nothing if the value is not found
      if (index < 0) {
        return;
      }
      this.state.index = index;
      const element = this.elements.spinner.children[index];
      if (!element) {
        return;
      }
      element.scrollIntoView({
        behavior: smooth ? "auto" : "instant",
        block: "start",
      });
    },

    /**
     * Scroll to a value.
     *
     * @param  {Number/String} value: Value to scroll to
     * @param  {boolean} centering: Whether or not to scroll to center location
     * @param  {boolean} smooth: Whether or not scroll should be smooth by default
     */
    _scrollTo(value, centering, smooth) {
      const index = this._getScrollIndex(value, centering);
      this._scrollToIndex(index, smooth);
    },

    _smoothScrollTo(value) {
      this._scrollTo(value, /* centering = */ false, /* smooth = */ true);
    },

    _smoothScrollToIndex(index) {
      this._scrollToIndex(index, /* smooth = */ true);
    },

    /**
     * Update the selection state.
     */
    _updateSelection() {
      const { itemsViewElements, selected } = this.elements;
      const { itemsView, index } = this.state;
      const { viewportTopOffset } = this.props;
      const currentItemIndex = index + viewportTopOffset;

      if (selected && selected != itemsViewElements[currentItemIndex]) {
        this._removeSelection();
      }

      this.elements.selected = itemsViewElements[currentItemIndex];
      if (itemsView[currentItemIndex] && itemsView[currentItemIndex].enabled) {
        this.elements.selected.classList.add("selection");
      }
    },

    /**
     * Remove selection if selected exists and different from current
     */
    _removeSelection() {
      const { selected } = this.elements;

      if (selected) {
        selected.classList.remove("selection");
      }
    },

    /**
     * Compares arrays of objects. It assumes the structure is an array of
     * objects, and objects in a and b have the same number of properties.
     *
     * @param  {Array<object>} a
     * @param  {Array<object>} b
     * @return {boolean}  Returns true if a and b are different
     */
    _isArrayDiff(a, b) {
      // Check reference first, exit early if reference is the same.
      if (a == b) {
        return false;
      }

      if (a.length != b.length) {
        return true;
      }

      for (let i = 0; i < a.length; i++) {
        for (let prop in a[i]) {
          if (a[i][prop] != b[i][prop]) {
            return true;
          }
        }
      }
      return false;
    },

    /**
     * While the spinner is focused and keyboard command is used, selects an
     * appropriate index and centers it, while preventing default behavior and
     * stopping event propagation.
     *
     * @param {object} event: Keyboard event
     * @param {number} index: The index of the expected next item
     */
    _setValueForSpinner(event, index) {
      this._smoothScrollToIndex(index);
      event.stopPropagation();
      event.preventDefault();
    },
  };
}
