/* 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/. */

/* import-globals-from datekeeper.js */
/* import-globals-from calendar.js */
/* import-globals-from spinner.js */

"use strict";

function DatePicker(context) {
  this.context = context;
  this._attachEventListeners();
}

{
  const CAL_VIEW_SIZE = 42;

  DatePicker.prototype = {
    /**
     * Initializes the date picker. Set the default states and properties.
     *
     * @param  {object} props
     *         {
     *           {Number} year [optional]
     *           {Number} month [optional]
     *           {Number} date [optional]
     *           {Number} min
     *           {Number} max
     *           {Number} step
     *           {Number} stepBase
     *           {Number} firstDayOfWeek
     *           {Array<Number>} weekends
     *           {Array<String>} monthStrings
     *           {Array<String>} weekdayStrings
     *           {String} locale [optional]: User preferred locale
     *         }
     */
    init(props = {}) {
      if (props.type == "time") {
        return;
      }
      this.context.root.hidden = false;
      this.props = props;
      this._setDefaultState();
      this._createComponents();
      this._update();
      this.components.calendar.focusDay();
      // TODO(bug 1828721): This is a bit sad.
      window.PICKER_READY = true;
      document.dispatchEvent(new CustomEvent("PickerReady"));
    },

    /*
     * Set initial date picker states.
     */
    _setDefaultState() {
      const {
        year,
        month,
        day,
        min,
        max,
        step,
        stepBase,
        firstDayOfWeek,
        weekends,
        monthStrings,
        weekdayStrings,
        locale,
        dir,
      } = this.props;
      const dateKeeper = new DateKeeper({
        year,
        month,
        day,
        min,
        max,
        step,
        stepBase,
        firstDayOfWeek,
        weekends,
        calViewSize: CAL_VIEW_SIZE,
      });

      document.dir = dir;

      this.state = {
        dateKeeper,
        locale,
        isMonthPickerVisible: false,
        datetimeOrders: new Intl.DateTimeFormat(locale)
          .formatToParts(new Date(0))
          .map(part => part.type),
        getDayString: day =>
          day ? new Intl.NumberFormat(locale).format(day) : "",
        getWeekHeaderString: weekday => weekdayStrings[weekday],
        getMonthString: month => monthStrings[month],
        setSelection: date => {
          dateKeeper.setSelection({
            year: date.getUTCFullYear(),
            month: date.getUTCMonth(),
            day: date.getUTCDate(),
          });
          this._update();
          this._dispatchState();
          if (this.props.type !== "datetime-local") {
            this._closePopup();
          }
        },
        setMonthByOffset: offset => {
          dateKeeper.setMonthByOffset(offset);
          this._update();
        },
        setYear: year => {
          dateKeeper.setYear(year);
          dateKeeper.setSelection({
            year,
            month: dateKeeper.selection.month,
            day: dateKeeper.selection.day,
          });
          this._update();
          this._dispatchState();
        },
        setMonth: month => {
          dateKeeper.setMonth(month);
          dateKeeper.setSelection({
            year: dateKeeper.selection.year,
            month,
            day: dateKeeper.selection.day,
          });
          this._update();
          this._dispatchState();
        },
        toggleMonthPicker: () => {
          this.state.isMonthPickerVisible = !this.state.isMonthPickerVisible;
          this._update();
        },
      };
    },

    /**
     * Initalize the date picker components.
     */
    _createComponents() {
      this.components = {
        calendar: new Calendar(
          {
            calViewSize: CAL_VIEW_SIZE,
            locale: this.state.locale,
            setSelection: this.state.setSelection,
            // Year and month could be changed without changing a selection
            setCalendarMonth: (year, month) => {
              this.state.dateKeeper.setCalendarMonth({
                year,
                month,
              });
              this._update();
            },
            getDayString: this.state.getDayString,
            getWeekHeaderString: this.state.getWeekHeaderString,
          },
          {
            weekHeader: this.context.weekHeader,
            daysView: this.context.daysView,
          }
        ),
        monthYear: new MonthYear(
          {
            setYear: this.state.setYear,
            setMonth: this.state.setMonth,
            getMonthString: this.state.getMonthString,
            datetimeOrders: this.state.datetimeOrders,
            locale: this.state.locale,
          },
          {
            monthYear: this.context.monthYear,
            monthYearView: this.context.monthYearView,
          }
        ),
      };
    },

    /**
     * Update date picker and its components.
     */
    _update(options = {}) {
      const { dateKeeper, isMonthPickerVisible } = this.state;

      const calendarEls = [
        this.context.buttonPrev,
        this.context.buttonNext,
        this.context.weekHeader.parentNode,
        this.context.buttonClear,
      ];
      // Update MonthYear state and toggle visibility for sighted users
      // and for assistive technology:
      this.context.monthYearView.hidden = !isMonthPickerVisible;
      for (let el of calendarEls) {
        el.hidden = isMonthPickerVisible;
      }
      this.context.monthYearNav.toggleAttribute(
        "monthPickerVisible",
        isMonthPickerVisible
      );
      if (isMonthPickerVisible) {
        this.state.months = dateKeeper.getMonths();
        this.state.years = dateKeeper.getYears();
      } else {
        this.state.days = dateKeeper.getDays();
      }

      this.components.monthYear.setProps({
        isVisible: isMonthPickerVisible,
        dateObj: dateKeeper.state.dateObj,
        months: this.state.months,
        years: this.state.years,
        toggleMonthPicker: this.state.toggleMonthPicker,
        noSmoothScroll: options.noSmoothScroll,
      });
      this.components.calendar.setProps({
        isVisible: !isMonthPickerVisible,
        days: this.state.days,
        weekHeaders: dateKeeper.state.weekHeaders,
      });
    },

    /**
     * Use postMessage to close the picker.
     */
    _closePopup(clear = false) {
      window.postMessage(
        {
          name: "ClosePopup",
          detail: clear,
        },
        "*"
      );
    },

    /**
     * Use postMessage to pass the state of picker to the panel.
     */
    _dispatchState() {
      const { year, month, day } = this.state.dateKeeper.selection;

      // The panel is listening to window for postMessage event, so we
      // do postMessage to itself to send data to input boxes.
      window.postMessage(
        {
          name: "PickerPopupChanged",
          detail: {
            year,
            month,
            day,
          },
        },
        "*"
      );
    },

    /**
     * Attach event listeners
     */
    _attachEventListeners() {
      window.addEventListener("message", this);
      document.addEventListener("mouseup", this, { passive: true });
      document.addEventListener("pointerdown", this, { passive: true });
      document.addEventListener("mousedown", this);
      // Only listen to events in #date-picker (not in a timepicker)
      this.context.root.addEventListener("keydown", this);
    },

    /**
     * Handle events.
     *
     * @param  {Event} event
     */
    handleEvent(event) {
      switch (event.type) {
        case "message": {
          this.handleMessage(event);
          break;
        }
        case "keydown": {
          switch (event.key) {
            case "Enter":
            case " ":
            case "Escape": {
              // If the target is a toggle or a spinner on the month-year panel
              const isOnMonthPicker =
                this.context.monthYearView.parentNode.contains(event.target);

              if (this.state.isMonthPickerVisible && isOnMonthPicker) {
                // While a control on the month-year picker panel is focused,
                // keep the spinner's selection and close the month-year dialog
                event.stopPropagation();
                event.preventDefault();
                this.state.toggleMonthPicker();
                this.components.calendar.focusDay();
                break;
              }
              if (event.key == "Escape") {
                // Close the date picker on Escape from within the picker
                this._closePopup();
                break;
              }
              if (event.target == this.context.buttonPrev) {
                event.target.classList.add("active");
                this.state.setMonthByOffset(-1);
                this.context.buttonPrev.focus();
              } else if (event.target == this.context.buttonNext) {
                event.target.classList.add("active");
                this.state.setMonthByOffset(1);
                this.context.buttonNext.focus();
              } else if (event.target == this.context.buttonClear) {
                event.target.classList.add("active");
                this._closePopup(/* clear = */ true);
              }
              break;
            }
            case "Tab": {
              // Manage tab order of a daysView to prevent keyboard trap
              if (event.target.tagName === "td") {
                if (event.shiftKey) {
                  this.context.buttonNext.focus();
                } else if (!event.shiftKey) {
                  this.context.buttonClear.focus();
                }
                event.stopPropagation();
                event.preventDefault();
              }
              break;
            }
          }
          break;
        }
        case "pointerdown": {
          if (event.pointerType == "mouse") {
            event.target.setPointerCapture(event.pointerId);
          }
          break;
        }
        case "mousedown": {
          // Use preventDefault to keep focus on input boxes
          event.preventDefault();

          if (event.target == this.context.buttonClear) {
            event.target.classList.add("active");
            this._closePopup(/* clear = */ true);
          } else if (event.target == this.context.buttonPrev) {
            event.target.classList.add("active");
            this.state.dateKeeper.setMonthByOffset(-1);
            this._update();
          } else if (event.target == this.context.buttonNext) {
            event.target.classList.add("active");
            this.state.dateKeeper.setMonthByOffset(1);
            this._update();
          }
          break;
        }
        case "mouseup": {
          event.target.releasePointerCapture(event.pointerId);

          if (
            event.target == this.context.buttonPrev ||
            event.target == this.context.buttonNext
          ) {
            event.target.classList.remove("active");
          }
          break;
        }
      }
    },

    /**
     * Handle postMessage events.
     *
     * @param {Event} event
     */
    handleMessage(event) {
      switch (event.data.name) {
        case "PickerInit": {
          this.init(event.data.detail);
          break;
        }
      }
    },
  };

  /**
   * MonthYear is a component that handles the month & year spinners
   *
   * @param {object} options
   *        {
   *          {String} locale
   *          {Function} setYear
   *          {Function} setMonth
   *          {Function} getMonthString
   *          {Array<String>} datetimeOrders
   *        }
   * @param {DOMElement} context
   */
  function MonthYear(options, context) {
    const spinnerSize = 5;
    const yearFormat = new Intl.DateTimeFormat(options.locale, {
      year: "numeric",
      timeZone: "UTC",
    }).format;
    const dateFormat = new Intl.DateTimeFormat(options.locale, {
      year: "numeric",
      month: "long",
      timeZone: "UTC",
    }).format;
    const spinnerOrder =
      options.datetimeOrders.indexOf("month") <
      options.datetimeOrders.indexOf("year")
        ? "order-month-year"
        : "order-year-month";

    context.monthYearView.classList.add(spinnerOrder);

    this.context = context;
    this.state = { dateFormat };
    this.props = {};
    this.components = {
      month: new Spinner(
        {
          id: "spinner-month",
          setValue: month => {
            this.state.isMonthSet = true;
            options.setMonth(month);
          },
          getDisplayString: options.getMonthString,
          viewportSize: spinnerSize,
        },
        context.monthYearView
      ),
      year: new Spinner(
        {
          id: "spinner-year",
          setValue: year => {
            this.state.isYearSet = true;
            options.setYear(year);
          },
          getDisplayString: year =>
            yearFormat(new Date(new Date(0).setUTCFullYear(year))),
          viewportSize: spinnerSize,
        },
        context.monthYearView
      ),
    };

    this._updateButtonLabels();
    this._attachEventListeners();
  }

  MonthYear.prototype = {
    /**
     * Set new properties and pass them to components
     *
     * @param {object} props
     *        {
     *          {Boolean} isVisible
     *          {Date} dateObj
     *          {Array<Object>} months
     *          {Array<Object>} years
     *          {Function} toggleMonthPicker
     *         }
     */
    setProps(props) {
      this.context.monthYear.textContent = this.state.dateFormat(props.dateObj);
      const spinnerDialog = this.context.monthYearView.parentNode;

      if (props.isVisible) {
        this.context.monthYear.classList.add("active");
        this.context.monthYear.setAttribute("aria-expanded", "true");
        // To prevent redundancy, as spinners will announce their value on change
        this.context.monthYear.setAttribute("aria-live", "off");
        this.components.month.setState({
          value: props.dateObj.getUTCMonth(),
          items: props.months,
          isInfiniteScroll: true,
          isValueSet: this.state.isMonthSet,
          smoothScroll: !(this.state.firstOpened || props.noSmoothScroll),
        });
        this.components.year.setState({
          value: props.dateObj.getUTCFullYear(),
          items: props.years,
          isInfiniteScroll: false,
          isValueSet: this.state.isYearSet,
          smoothScroll: !(this.state.firstOpened || props.noSmoothScroll),
        });
        this.state.firstOpened = false;

        // Set up spinner dialog container properties for assistive technology:
        spinnerDialog.setAttribute("role", "dialog");
        spinnerDialog.setAttribute("aria-modal", "true");
      } else {
        this.context.monthYear.classList.remove("active");
        this.context.monthYear.setAttribute("aria-expanded", "false");
        // To ensure calendar month's changes are announced:
        this.context.monthYear.setAttribute("aria-live", "polite");
        // Remove spinner dialog container properties to ensure this hidden
        // modal will be ignored by assistive technology, because even though
        // the dialog is hidden, the toggle button is a visible descendant,
        // so we must not treat its container as a dialog:
        spinnerDialog.removeAttribute("role");
        spinnerDialog.removeAttribute("aria-modal");
        this.state.isMonthSet = false;
        this.state.isYearSet = false;
        this.state.firstOpened = true;
      }

      this.props = Object.assign(this.props, props);
    },

    /**
     * Handle events
     *
     * @param  {DOMEvent} event
     */
    handleEvent(event) {
      switch (event.type) {
        case "click": {
          this.props.toggleMonthPicker();
          break;
        }
        case "keydown": {
          if (event.key === "Enter" || event.key === " ") {
            event.stopPropagation();
            event.preventDefault();
            this.props.toggleMonthPicker();
          }
          break;
        }
      }
    },

    /**
     * Update localizable IDs of the spinner and its Prev/Next buttons
     */
    _updateButtonLabels() {
      document.l10n.setAttributes(
        this.components.month.elements.spinner,
        "date-spinner-month"
      );
      document.l10n.setAttributes(
        this.components.year.elements.spinner,
        "date-spinner-year"
      );
      document.l10n.setAttributes(
        this.components.month.elements.prev,
        "date-spinner-month-previous"
      );
      document.l10n.setAttributes(
        this.components.month.elements.next,
        "date-spinner-month-next"
      );
      document.l10n.setAttributes(
        this.components.year.elements.prev,
        "date-spinner-year-previous"
      );
      document.l10n.setAttributes(
        this.components.year.elements.next,
        "date-spinner-year-next"
      );
      document.l10n.translateRoots();
    },

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

document.addEventListener("DOMContentLoaded", () => {
  // Create a DatePicker instance and prepare to be initialized
  // by the "PickerInit" message.
  const root = document.getElementById("date-picker");
  new DatePicker({
    root,
    monthYearNav: root.querySelector(".month-year-nav"),
    monthYear: root.querySelector(".month-year"),
    monthYearView: root.querySelector(".month-year-view"),
    buttonPrev: root.querySelector(".prev"),
    buttonNext: root.querySelector(".next"),
    weekHeader: root.querySelector(".week-header"),
    daysView: root.querySelector(".days-view"),
    buttonClear: document.getElementById("clear-button"),
  });
});
