/**
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following
 * conditions:

 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.

 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 */

/**
 * @namespace
 * @alias Sive
 * @description Une simple librairie JS qui permet de détecter l'arrivée ou la disparition d'un élément de la vue du navigateur, permettant d'effectuer des animations de dévoilement par exemple.
 * <br><br><em><b>Note</b>: This library uses document.querySelectorAll() to select the targeted elements</em>
 * @author TIMMatane
 * @version 1.0.0 (Janvier 2018)
 */
class Sive {
  constructor() {
    this._elemEvents = {};
    this._elemEventsIndex = 0;
    this._isListening = false;
    this._shouldAutoTriggerAtFirstScroll = false;
    this._autoTriggerPreviousElements = true;
    this._scrollBuffer = 0;
    this._bypassedScroll = 0;
    this._height = null;
    this._scrollHeight = null;
    this._firstTimeScroll = false;

    // Binding this to methods
    this._onScroll = this._onScroll.bind(this);
  }

  /**
   * @alias Sive.do
   * @type {Function}
   * @description This is the principal way to use this library. Adds a class to all nodes returned by the document.querySelectorAll() method when they become partially visibles in relation to the browser's viewport.<br>
   * <em><b>Note</b>: Will be fired once, then automatically unregistered</em>
   * @param {String} elemQS The document.querySelectorAll() selector string of the targeted element
   * @param {String} className The class name that should be added to the targeted elements when they come into view
   * @returns {undefined} Returns nothing
   * @example <caption>How to easily add a class to a newly visible HTMLElement</caption>
   * Sive.do("#myElementId", "myClassName");
   * Sive.do("div", "myClassName");
   */
  do(elemQS, className) {
    this.doMore(elemQS, null, false, false, undefined, className);
  }

  /**
   * @alias Sive.doMore
   * @type {Function}
   * @description A more complete way to react to in view/out of view events. Adds a Sive for all nodes returned by the document.querySelectorAll() method. It can also trigger a callback ({@link Sive~ScrollEventCallback}) function <b>each time</b> one of the targeted elements becomes visible or invisible in relation to the browser's viewport.
   * <br><em><b>Note</b>: Will be fired each time the element's visibility changes as it is not unregistered automatically.</em>
   * @param {String} elemQS The querySlectorAll string of the target elements
   * @param {ScrollEventCallback} [eventCallback = null] The callback function to call when an element is in or out of the browser's viewport.
   * <br><em><b>Note</b>: If not set, <strong>className</strong> will need to be defined for anything to happen.</em>
   * @see ScrollEventCallback
   * @param {Boolean} [fullyVisibleX = false] Should the element be fully visible on X, or partially?
   * @param {Boolean} [fullyVisibleY = false] Should the element be fully visible on Y, or partially?
   * @param {Object} [offset = {}] Object setting the offset in pixels to use as the viewport
   * @param {Number} [offset.top = 0] Offset in pixels from the top of the browser's viewport
   * @param {Number} [offset.bottom = 0] Offset in pixels from the bottom of the browser's viewport
   * @param {Number} [offset.left = 0] Offset in pixels from the left of the browser's viewport
   * @param {Number} [offset.right = 0] Offset in pixels from the right of the browser's viewport
   * @param {String} [className = ""] The class name that should be added to the elements when they comes into view the first time only.
   * <br><em><b>Note</b>: Will be used only if <strong>eventCallback</strong> is not defined.</em>
   * @returns {undefined} Returns nothing
   * @example <caption>This example uses a callback function to add or remove a border on the element depending of its visibility. It also forces element to be fully visible on both axis to be considered "in view"</caption>
   * Sive.doMore(".myElementsClass", viewCallback, true, true);
   * function viewCallback(event, auto){
   *      if (event.inview){
   *          event.elem.style.border = "2px solid #ff0000";
   *      }else{
   *          event.elem.style.border = "none";
   *      }
   * }
   *
   * @example <caption>This example uses a class name to be added when elements come into view. It also use an object to virtually redefine the browser's viewport dimensions.
   * <br><em><b>Note</b>: This event will be fired only once as a class name is used instead of a callback function</em></caption>
   * Sive.doMore(".myElementsClass", null, false, false, {top:100, left:200, right:200, bottom:20 }, "myClassName");
   */
  doMore(elemQS, eventCallback, fullyVisibleX = false, fullyVisibleY = false, offset = { top: 0, right: 0, bottom: 0, left: 0 }, className = "") {
    const nodes = document.querySelectorAll(elemQS);
    nodes.forEach((item, index) => {
      const internalID = `${elemQS}${this._elemEventsIndex}`;
      this._elemEvents[internalID] = {
        elem: item,
        callback: eventCallback,
        inview: false,
        fullyVisibleX,
        fullyVisibleY,
        offset,
        internalID,
        index: this._elemEventsIndex,
        localIndex: index,
        className,
        querySelector: elemQS,
        scrollEvent: {
          elem: item,
          inview: false,
          internalID,
          index: this._elemEventsIndex,
          localIndex: index,
          autoTriggered: false,
        },
      };
      this._elemEventsIndex++;
    });
  }

  /**
   * @alias Sive.removeEventForElement
   * @type {Function}
   * @see Sive~ScrollEventCallback
   * @description Unregisters a Sive for a previously registered HTMLElement.
   * <br><em><b>Note: </b> As this library creates an internal ID for each of the targeted elements, this method is only usefull inside a ScrollEventCallback call. The later will give you access to the internal ID created for it.</em>
   * @param {String} elemInternalID The ScrollEventCallback event.internalID parameter
   * @returns {Boolean} Returns true if the Sive was found and unregistered successfully. Otherwise returns false.
   * @throws Will throw a warning in the console if the element is not found while trying to remove the Sive for wich it is associated.
   * @example <caption>This example shows how to use Sive.removeEventForElement inside a ScrollEventCallback call to unregister an element.</caption>
   * Sive.doMore(".myElementsClass", viewCallback);
   * function viewCallback(event, auto){
   *      if (event.inview){
   *          event.elem.style.border = "2px solid #ff0000";
   *      }else{
   *          event.elem.style.border = "none";
   *          Sive.removeEventForElement(event.internalID);
   *      }
   * }
   */
  removeEventForElement(elemInternalID) {
    if (this._elemEvents[elemInternalID]) {
      delete this._elemEvents[elemInternalID];
      this._elemEventsIndex--;
      return true;
    } else {
      console.warn(`Sive.js : The element target with internal ID ${elemInternalID} was not found!`);
      return false;
    }
  }
  /**
   * @alias Sive.removeEventForSelector
   * @type {Function}
   * @description Unregisters a Sive using the same selector used for the previously registered elements
   * @param {String} elemQS The quesrySelectorAll string of the elements to unregister
   * @returns {Boolean} Returns true if the ScrollEvents were found and removed successfully. Otherwise returns false and throws a warning in the console.
   * @example <caption>This example shows how to use Sive.removeEventForSelector to unregister all elements previously targeted by a <b>div</b> selector. </caption>
   * Sive.removeEventForSelector("div");
   */
  removeEventForSelector(elemQS) {
    let success = false;
    Object.keys(this._elemEvents).forEach((key) => {
      if (this._elemEvents[key].querySelector === elemQS) {
        this.removeEventForElement(this._elemEvents[key].internalID);
        success = true;
      }
    });
    if (!success) {
      console.warn(`Sive.js : The element target with selector ${elemQS} was not found!`);
    }
    return success;
  }
  /**
   * @alias Sive.startListening
   * @type {Function}
   * @description Starts listening to scroll event on the window, add a scroll event listener to window object<br>
   * <em><b>Note: </b>If the page was scrolled before a reload of the page, it will trigger window.scroll event when refreshing is done, invaliding need of autoCheck (except when the page is at top).</em>
   * @param {Boolean} [autoCheckAtStart = true] Should the elements trigger visible or hidden event at start (before scrolling)?
   * @param {Boolean} [autoTriggerPreviousElements = true] Should the ScrollEvent for non visible elements higher in Y axis (than the current scroll position) be triggered as if they were visibles?
   * @param {Number} [scrollBuffer = 5] How many window.scroll event to bypass before it is actually taken into account to trigger a Sive? This should be 0 if you want to have the best precision.
   * @returns {undefined} Returns nothing
   */
  startListening(autoCheckAtStart = true, autoTriggerPreviousElements = true, scrollBuffer = 5) {
    this._shouldAutoTriggerAtFirstScroll = autoCheckAtStart;
    this._autoTriggerPreviousElements = autoTriggerPreviousElements;
    this._scrollBuffer = scrollBuffer;

    this._height = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
    this._scrollHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight);

    this._isListening = true;
    window.addEventListener("scroll", this._onScroll, true);

    if (this._shouldAutoTriggerAtFirstScroll && (window.scrollY || document.documentElement.scrollTop || window.pageYOffset) === 0) {
      setTimeout(() => this._loopAllElemEvents(true), 100);
    }
  }
  /**
   * @alias Sive.stopListening
   * @type {Function}
   * @description Stops listening to scroll event on the window, removes the scroll event from the window object
   * @param {Boolean} [forceRemoveAll = false] If true, will also delete all events registered on the page
   * @returns {undefined} Returns nothing
   */
  stopListening(forceRemoveAll = false) {
    if (forceRemoveAll) {
      this._removeAllEvents();
    }
    this._isListening = false;
    window.removeEventListener("scroll", this._onScroll);
  }
  /**
   * @alias Sive.isListening
   * @type {Function}
   * @description Checks if listening to scroll event on the window is active
   * @see Sive.startListening
   * @returns {Boolean} Returns true if Sive lib is currently listening on the page, else false
   */
  isListening() {
    return this._isListening;
  }

  /**
   * Called when scroll happens
   * @private
   * @type {Function}
   * @param {UIEvent} e
   * @returns {undefined}
   */
  _onScroll() {
    const top = window.scrollY || document.documentElement.scrollTop || window.pageYOffset;

    if (!this._firstTimeScroll) {
      this._loopAllElemEvents(false);
      this._firstTimeScroll = true;
    } else {
      this._bypassedScroll++;
      if (this._bypassedScroll >= this._scrollBuffer || top === 0 || top + this._height > this._scrollHeight) {
        this._loopAllElemEvents(false);
        this._bypassedScroll = 0;
      }
    }
  }
  /**
   * _loopAllElemEvents();
   * Loops through all registered HTMLElement to check wich are in view and which are not
   * @private
   * @type {Function}
   * @param {Boolean} isAuto Was the trigger automatically called when reloading the page (as the page was already scrolled)
   * @returns {undefined}
   */
  _loopAllElemEvents(isAuto) {
    Object.values(this._elemEvents).forEach((elemEvent) => {
      const isBeforeInView = this._isBeforeInView(elemEvent.elem);
      const inview = this._isInView(elemEvent.elem, elemEvent.fullyVisibleX, elemEvent.fullyVisibleY, elemEvent.offset);
      console.log(elemEvent.inview, this._autoTriggerPreviousElements, isBeforeInView, elemEvent.className);
      if (elemEvent.inview !== inview) {
        elemEvent.inview = inview;
        if (typeof elemEvent.callback === "function") {
          elemEvent.scrollEvent.inview = inview;
          elemEvent.scrollEvent.autoTriggered = false;
          elemEvent.callback(elemEvent.scrollEvent, isAuto);
        } else if (inview && elemEvent.className !== "") {
          elemEvent.elem.className += elemEvent.elem.className === "" ? elemEvent.className : ` ${elemEvent.className}`;
          this.removeEventForElement(elemEvent.internalID);
        }
      } else if (!elemEvent.inview && this._autoTriggerPreviousElements && isBeforeInView && elemEvent.className !== "") {
        elemEvent.elem.className += elemEvent.elem.className === "" ? elemEvent.className : ` ${elemEvent.className}`;
        this.removeEventForElement(elemEvent.internalID);
      }
    });
  }
  /**
   * _isInView();
   * Will check and return true if an HTMLElement is is view
   * @private
   * @type {Function}
   * @param {HTMLElement} element The element to check
   * @param {Boolean} fullyVisibleX Should the element be fully visible on X
   * @param {Boolean} fullVisibleY Should the element be fully visible on Y
   * @param {Object} offset An object with four properties (top, right, bottom, left) used as virtual viewport
   * @returns {Boolean}
   */
  _isInView(element, fullyVisibleX, fullyVisibleY, offset) {
    const rect = element.getBoundingClientRect();
    const html = document.documentElement;
    const isPartiallyVisibleX = rect.right >= offset.left && rect.left <= (window.innerWidth || html.clientWidth) - offset.right;
    const isPartiallyVisibleY = rect.bottom >= offset.top && rect.top <= (window.innerHeight || html.clientHeight) - offset.bottom;
    const isFullyVisibleX = rect.left >= offset.left && rect.right <= (window.innerWidth || html.clientWidth) - offset.right;
    const isFullyVisibleY = rect.top >= offset.top && rect.bottom <= (window.innerHeight || html.clientHeight) - offset.bottom;
    return (fullyVisibleX ? isFullyVisibleX : isPartiallyVisibleX) && (fullyVisibleY ? isFullyVisibleY : isPartiallyVisibleY);
  }
  /**
   * Will check if an element is higher in Y axis in relation to the highest visible element
   * @private
   * @type {Function}
   * @param {HTMLElement} element
   * @returns {Boolean} Returns true if the element is higher in Y axis in relation to the highest visible element, else returns false
   */
  _isBeforeInView(element) {
    const rect = element.getBoundingClientRect();
    return rect.y <= -rect.height;
  }
  /**
   * Will loop through all registered event on th page and remove them.
   * @private
   * @type {Function}
   * @returns {undefined} Returns nothing
   */
  _removeAllEvents() {
    Object.keys(this._elemEvents).forEach((key) => this.removeEventForElement(key));
    this._elemEvents = {};
  }

  /**
   * The callback function will receive thoses params when an HTMLElement becomes visible/invisible.
   * @callback ScrollEventCallback
   * @see Sive.doMore
   * @param {Object} inViewEvent Event object containing information about the current event.
   * @param {HTMLElement} inViewEvent.elem The reference to the currently processed HTMLElement.
   * @param {Boolean} inViewEvent.inview Is the HTMLElement visible or invisible when the event is processed.
   * @param {String} inViewEvent.internalID The string used as an internal ID for this element. Only usefull with {@link Sive.removeEventForElement} method.
   * @param {Number} inViewEvent.index The numerical index of the current HTMLElement in the global array of all targeted elements in the current page.
   * @param {Number} inViewEvent.localIndex The numerical index of the current HTMLElement in the local array of all targeted elements by the document.querySelectorAll method
   * @param {Boolean} inViewEvent.autoTriggered If the Sive was triggered automatically because the registered element was higher in Y axis than the highest currently visible element.
   * @param {Boolean} isAuto Was the Sive automatically called when reloading the page (as the page was already scrolled, or forced).
   */
}

// Make the class available globally
export default new Sive();