/* 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";

/**
 * Initialize the Calendar and generate nodes for week headers and days, and
 * attach event listeners.
 *
 * @param {object} options
 *        {
 *          {Number} calViewSize: Number of days to appear on a calendar view
 *          {Function} getDayString: Transform day number to string
 *          {Function} getWeekHeaderString: Transform day of week number to string
 *          {Function} setSelection: Set selection for dateKeeper
 *          {Function} setCalendarMonth: Update the month shown by the dateView
 *                     to a specific month of a specific year
 *        }
 * @param {object} context
 *        {
 *          {DOMElement} weekHeader
 *          {DOMElement} daysView
 *        }
 */
function Calendar(options, context) {
  this.context = context;
  this.context.DAYS_IN_A_WEEK = 7;
  this.state = {
    days: [],
    weekHeaders: [],
    setSelection: options.setSelection,
    setCalendarMonth: options.setCalendarMonth,
    getDayString: options.getDayString,
    getWeekHeaderString: options.getWeekHeaderString,
    focusedDate: null,
  };
  this.elements = {
    weekHeaders: this._generateNodes(
      this.context.DAYS_IN_A_WEEK,
      context.weekHeader
    ),
    daysView: this._generateNodes(options.calViewSize, context.daysView),
  };

  this._attachEventListeners();
}

Calendar.prototype = {
  /**
   * Set new properties and render them.
   *
   * @param {object} props
   *        {
   *          {Boolean} isVisible: Whether or not the calendar is in view
   *          {Array<Object>} days: Data for days
   *          {
   *            {Date} dateObj
   *            {Number} content
   *            {Array<String>} classNames
   *            {Boolean} enabled
   *          }
   *          {Array<Object>} weekHeaders: Data for weekHeaders
   *          {
   *            {Number} content
   *            {Array<String>} classNames
   *          }
   *        }
   */
  setProps(props) {
    if (props.isVisible) {
      // Transform the days and weekHeaders array for rendering
      const days = props.days.map(
        ({ dateObj, content, classNames, enabled }) => {
          return {
            dateObj,
            textContent: this.state.getDayString(content),
            className: classNames.join(" "),
            enabled,
          };
        }
      );
      const weekHeaders = props.weekHeaders.map(({ content, classNames }) => {
        return {
          textContent: this.state.getWeekHeaderString(content),
          className: classNames.join(" "),
        };
      });
      // Update the DOM nodes states
      this._render({
        elements: this.elements.daysView,
        items: days,
        prevState: this.state.days,
      });
      this._render({
        elements: this.elements.weekHeaders,
        items: weekHeaders,
        prevState: this.state.weekHeaders,
      });
      // Update the state to current and place keyboard focus
      this.state.days = days;
      this.state.weekHeaders = weekHeaders;
      this.focusDay();
    }
  },

  /**
   * Render the items onto the DOM nodes
   *
   * @param  {object}
   *         {
   *           {Array<DOMElement>} elements
   *           {Array<Object>} items
   *           {Array<Object>} prevState: state of items from last render
   *         }
   */
  _render({ elements, items, prevState }) {
    let selected = {};
    let today = {};
    let sameDay = {};
    let firstDay = {};

    for (let i = 0, l = items.length; i < l; i++) {
      let el = elements[i];

      // Check if state from last render has changed, if so, update the elements
      if (!prevState[i] || prevState[i].textContent != items[i].textContent) {
        el.textContent = items[i].textContent;
      }
      if (!prevState[i] || prevState[i].className != items[i].className) {
        el.className = items[i].className;
      }

      if (el.tagName === "td") {
        el.setAttribute("role", "gridcell");

        // Flush states from the previous view
        el.removeAttribute("tabindex");
        el.removeAttribute("aria-disabled");
        el.removeAttribute("aria-selected");
        el.removeAttribute("aria-current");

        // Set new states and properties
        if (
          this.state.focusedDate &&
          this._isSameDayOfMonth(items[i].dateObj, this.state.focusedDate) &&
          !el.classList.contains("outside")
        ) {
          // When any other date was focused previously, send the focus
          // to the same day of month, but only within the current month
          sameDay.el = el;
          sameDay.dateObj = items[i].dateObj;
        }
        if (el.classList.contains("today")) {
          // Current date/today is communicated to assistive technology
          el.setAttribute("aria-current", "date");
          if (!el.classList.contains("outside")) {
            today.el = el;
            today.dateObj = items[i].dateObj;
          }
        }
        if (el.classList.contains("selection")) {
          // Selection is communicated to assistive technology
          // and may be included in the focus order when from the current month
          el.setAttribute("aria-selected", "true");

          if (!el.classList.contains("outside")) {
            selected.el = el;
            selected.dateObj = items[i].dateObj;
          }
        } else if (el.classList.contains("out-of-range")) {
          // Dates that are outside of the range are not selected and cannot be
          el.setAttribute("aria-disabled", "true");
          el.removeAttribute("aria-selected");
        } else {
          // Other dates are not selected, but could be
          el.setAttribute("aria-selected", "false");
        }
        if (el.textContent === "1" && !firstDay.el) {
          // When no previous day, no selection, or no current day/today
          // is present, make the first of the month focusable
          firstDay.dateObj = items[i].dateObj;
          firstDay.dateObj.setUTCDate("1");

          if (this._isSameDay(items[i].dateObj, firstDay.dateObj)) {
            firstDay.el = el;
            firstDay.dateObj = items[i].dateObj;
          }
        }
      }
    }

    // The previously focused date (if the picker is updated and the grid still
    // contains the date) is always focusable. The selected date on init is also
    // always focusable. If neither exist, we make the current day or the first
    // day of the month focusable.
    if (sameDay.el) {
      sameDay.el.setAttribute("tabindex", "0");
      this.state.focusedDate = new Date(sameDay.dateObj);
    } else if (selected.el) {
      selected.el.setAttribute("tabindex", "0");
      this.state.focusedDate = new Date(selected.dateObj);
    } else if (today.el) {
      today.el.setAttribute("tabindex", "0");
      this.state.focusedDate = new Date(today.dateObj);
    } else if (firstDay.el) {
      firstDay.el.setAttribute("tabindex", "0");
      this.state.focusedDate = new Date(firstDay.dateObj);
    }
  },

  /**
   * Generate DOM nodes with HTML table markup
   *
   * @param  {number} size: Number of nodes to generate
   * @param  {DOMElement} context: Element to append the nodes to
   * @return {Array<DOMElement>}
   */
  _generateNodes(size, context) {
    let frag = document.createDocumentFragment();
    let refs = [];

    // Create table row to present a week:
    let rowEl = document.createElement("tr");
    for (let i = 0; i < size; i++) {
      // Create table cell for a table header (weekday) or body (date)
      let el;
      if (context.classList.contains("week-header")) {
        el = document.createElement("th");
        el.setAttribute("scope", "col");
        // Explicitly assigning the role as a workaround for the bug 1711273:
        el.setAttribute("role", "columnheader");
      } else {
        el = document.createElement("td");
      }

      el.dataset.id = i;
      refs.push(el);
      rowEl.appendChild(el);

      // Ensure each table row (week) has only
      // seven table cells (days) for a Gregorian calendar
      if ((i + 1) % this.context.DAYS_IN_A_WEEK === 0) {
        frag.appendChild(rowEl);
        rowEl = document.createElement("tr");
      }
    }
    context.appendChild(frag);

    return refs;
  },

  /**
   * Handle events
   *
   * @param  {DOMEvent} event
   */
  handleEvent(event) {
    switch (event.type) {
      case "click": {
        if (this.context.daysView.contains(event.target)) {
          let targetId = event.target.dataset.id;
          let targetObj = this.state.days[targetId];
          if (targetObj.enabled) {
            this.state.setSelection(targetObj.dateObj);
          }
        }
        break;
      }

      case "keydown": {
        // Providing keyboard navigation support in accordance with
        // the ARIA Grid and Dialog design patterns
        if (this.context.daysView.contains(event.target)) {
          // If RTL, the offset direction for Right/Left needs to be reversed
          const direction = Services.locale.isAppLocaleRTL ? -1 : 1;

          switch (event.key) {
            case "Enter":
            case " ": {
              let targetId = event.target.dataset.id;
              let targetObj = this.state.days[targetId];
              if (targetObj.enabled) {
                this.state.setSelection(targetObj.dateObj);
              }
              break;
            }

            case "ArrowRight": {
              // Moves focus to the next day. If the next day is
              // out-of-range, update the view to show the next month
              this._handleKeydownEvent(1 * direction);
              break;
            }
            case "ArrowLeft": {
              // Moves focus to the previous day. If the next day is
              // out-of-range, update the view to show the previous month
              this._handleKeydownEvent(-1 * direction);
              break;
            }
            case "ArrowUp": {
              // Moves focus to the same day of the previous week. If the next
              // day is out-of-range, update the view to show the previous month
              this._handleKeydownEvent(-1 * this.context.DAYS_IN_A_WEEK);
              break;
            }
            case "ArrowDown": {
              // Moves focus to the same day of the next week. If the next
              // day is out-of-range, update the view to show the previous month
              this._handleKeydownEvent(1 * this.context.DAYS_IN_A_WEEK);
              break;
            }
            case "Home": {
              // Moves focus to the first day (ie. Sunday) of the current week
              if (event.ctrlKey) {
                // Moves focus to the first day of the current month
                this.state.focusedDate.setUTCDate(1);
                this._updateKeyboardFocus();
              } else {
                this._handleKeydownEvent(
                  this.state.focusedDate.getUTCDay() * -1
                );
              }
              break;
            }
            case "End": {
              // Moves focus to the last day (ie. Saturday) of the current week
              if (event.ctrlKey) {
                // Moves focus to the last day of the current month
                let lastDateOfMonth = new Date(
                  this.state.focusedDate.getUTCFullYear(),
                  this.state.focusedDate.getUTCMonth() + 1,
                  0
                );
                this.state.focusedDate = lastDateOfMonth;
                this._updateKeyboardFocus();
              } else {
                this._handleKeydownEvent(
                  this.context.DAYS_IN_A_WEEK -
                    1 -
                    this.state.focusedDate.getUTCDay()
                );
              }
              break;
            }
            case "PageUp": {
              // Changes the view to the previous month/year
              // and sets focus on the same day.
              // If that day does not exist, then moves focus
              // to the same day of the same week.
              if (event.shiftKey) {
                // Previous year
                let prevYear = this.state.focusedDate.getUTCFullYear() - 1;
                this.state.focusedDate.setUTCFullYear(prevYear);
              } else {
                // Previous month
                let prevMonth = this.state.focusedDate.getUTCMonth() - 1;
                this.state.focusedDate.setUTCMonth(prevMonth);
              }
              this.state.setCalendarMonth(
                this.state.focusedDate.getUTCFullYear(),
                this.state.focusedDate.getUTCMonth()
              );
              this._updateKeyboardFocus();
              break;
            }
            case "PageDown": {
              // Changes the view to the next month/year
              // and sets focus on the same day.
              // If that day does not exist, then moves focus
              // to the same day of the same week.
              if (event.shiftKey) {
                // Next year
                let nextYear = this.state.focusedDate.getUTCFullYear() + 1;
                this.state.focusedDate.setUTCFullYear(nextYear);
              } else {
                // Next month
                let nextMonth = this.state.focusedDate.getUTCMonth() + 1;
                this.state.focusedDate.setUTCMonth(nextMonth);
              }
              this.state.setCalendarMonth(
                this.state.focusedDate.getUTCFullYear(),
                this.state.focusedDate.getUTCMonth()
              );
              this._updateKeyboardFocus();
              break;
            }
          }
        }
        break;
      }
    }
  },

  /**
   * Attach event listener to daysView
   */
  _attachEventListeners() {
    this.context.daysView.addEventListener("click", this);
    this.context.daysView.addEventListener("keydown", this);
  },

  /**
   * Find Data-id of the next element to focus on the daysView grid
   *
   * @param {object} nextDate: Data object of the next element to focus
   */
  _calculateNextId(nextDate) {
    for (let i = 0; i < this.state.days.length; i++) {
      if (this._isSameDay(this.state.days[i].dateObj, nextDate)) {
        return i;
      }
    }
    return null;
  },

  /**
   * Comparing two date objects to ensure they produce the same date
   *
   * @param  {Date} dateObj1: Date object from the updated state
   * @param  {Date} dateObj2: Date object from the previous state
   * @return {boolean} If two date objects are the same day
   */
  _isSameDay(dateObj1, dateObj2) {
    return (
      dateObj1.getUTCFullYear() == dateObj2.getUTCFullYear() &&
      dateObj1.getUTCMonth() == dateObj2.getUTCMonth() &&
      dateObj1.getUTCDate() == dateObj2.getUTCDate()
    );
  },

  /**
   * Comparing two date objects to ensure they produce the same day of the month,
   * while being on different months
   *
   * @param  {Date} dateObj1: Date object from the updated state
   * @param  {Date} dateObj2: Date object from the previous state
   * @return {boolean} If two date objects are the same day of the month
   */
  _isSameDayOfMonth(dateObj1, dateObj2) {
    return dateObj1.getUTCDate() == dateObj2.getUTCDate();
  },

  /**
   * Manage focus for the keyboard navigation for the daysView grid
   *
   * @param  {number} offsetDays: The direction and the number of days to move
   *                            the focus by, where a negative number (i.e. -1)
   *                            moves the focus to the previous day
   */
  _handleKeydownEvent(offsetDays) {
    let newFocusedDay = this.state.focusedDate.getUTCDate() + offsetDays;
    let newFocusedDate = new Date(this.state.focusedDate);
    newFocusedDate.setUTCDate(newFocusedDay);

    // Update the month, if the next focused element is outside
    if (newFocusedDate.getUTCMonth() !== this.state.focusedDate.getUTCMonth()) {
      this.state.setCalendarMonth(
        newFocusedDate.getUTCFullYear(),
        newFocusedDate.getUTCMonth()
      );
    }
    this.state.focusedDate.setUTCDate(newFocusedDate.getUTCDate());
    this._updateKeyboardFocus();
  },

  /**
   * Update the daysView grid and send focus to the next day
   * based on the current state fo the Calendar
   */
  _updateKeyboardFocus() {
    this._render({
      elements: this.elements.daysView,
      items: this.state.days,
      prevState: this.state.days,
    });
    this.focusDay();
  },

  /**
   * Place keyboard focus on the calendar grid, when the datepicker is initiated or updated.
   * A "tabindex" attribute is provided to only one date within the grid
   * by the "render()" method and this focusable element will be focused.
   */
  focusDay() {
    const focusable = this.context.daysView.querySelector('[tabindex="0"]');
    if (focusable) {
      focusable.focus();
    }
  },
};
