/* 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 timekeeper.js */
/* import-globals-from spinner.js */

"use strict";

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

{
  const DAY_PERIOD_IN_HOURS = 12,
    DAY_IN_MS = 86400000,
    // The min value is 0001-01-01 based on HTML spec:
    // https://html.spec.whatwg.org/#valid-date-string
    MIN_DATE = -62135596800000,
    // The max value is derived from the ECMAScript spec (275760-09-13):
    // http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.1
    MAX_DATE = 8640000000000000;

  TimePicker.prototype = {
    /**
     * Initializes the time picker. Set the default states and properties.
     *
     * @param  {object} props
     *         {
     *           {String} type: "date", "time", or "datetime-local"
     *           {Number} year [optional]
     *           {Number} month [optional]
     *           {Number} day [optional]
     *           {Number} hour [optional]: Hour in 24 hours format (0~23), default is current hour
     *           {Number} minute [optional]: Minute (0~59), default is current minute
     *           {Number} min: Minimum time, in ms
     *           {Number} max: Maximum time, in ms
     *           {Number} step: Step size in ms
     *           {String} format [optional]: "12" for 12 hours, "24" for 24 hours format
     *           {String} locale [optional]: User preferred locale
     *         }
     */
    init(props) {
      if (props.type == "date") {
        return;
      }
      if (props.type == "datetime-local") {
        // When both date and time pickers are shown, we have to adjust the
        // picker panel markup. Otherwise one panel would include two different
        // modal dialogs (which is not appropriate) and would be missing
        // a common title (which is confusing).
        // TODO(bug 1993756): Handle the panel dialog markups in a better location.
        const timepicker = this.context;
        const datetimepicker = timepicker.parentNode;
        const datepicker = datetimepicker.children.namedItem("date-picker");
        // Each date and time picker to become a group instead of a modal:
        timepicker.setAttribute("role", "group");
        timepicker.removeAttribute("aria-modal");
        datepicker.setAttribute("role", "group");
        datepicker.removeAttribute("aria-modal");
        // Parent container to become a modal dialog container for both groups:
        datetimepicker.setAttribute("role", "dialog");
        datetimepicker.setAttribute("aria-modal", "true");
        datetimepicker.setAttribute("data-l10n-id", "datetime-picker-label");
      }
      this.context.hidden = false;
      this.props = props || {};
      this._setDefaultState();
      this._createComponents();
      this._setComponentStates();
      // TODO(bug 1828721): This is a bit sad.
      window.PICKER_READY = true;
      document.dispatchEvent(new CustomEvent("PickerReady"));
      // Manage focus for a timepicker dialog:
      if (props.type == "time") {
        this.components.hour.elements.spinner.focus();
      }
    },

    /*
     * Set initial time states. If there's no hour & minute, it will
     * use the current time. The Time module keeps track of the time states,
     * and calculates the valid options given the time, min, max, step,
     * and format (12 or 24).
     */
    _setDefaultState() {
      const { type, year, month, day, hour, minute, min, max, step, format } =
        this.props;
      const now = new Date();

      let timerHour = hour == undefined ? now.getHours() : hour;
      let timerMinute = minute == undefined ? now.getMinutes() : minute;
      let defaultMin = 0;
      let defaultMax = DAY_IN_MS - 1;
      if (type == "datetime-local") {
        defaultMin = MIN_DATE;
        defaultMax = MAX_DATE;
      }
      let timeKeeper = new TimeKeeper({
        type,
        year,
        month,
        day,
        min: new Date(Number.isNaN(min) ? defaultMin : min),
        max: new Date(Number.isNaN(max) ? defaultMax : max),
        step,
        format: format || "12",
      });
      timeKeeper.setState({ hour: timerHour, minute: timerMinute });
      if (timeKeeper.state.isInvalid) {
        // Value is set to min if it's first opened and time state is invalid
        // Work from largest to smallest component to find the lowest valid time
        const validPeriods = timeKeeper.ranges.dayPeriod.filter(m => m.enabled);
        if (validPeriods.length) {
          timeKeeper.setDayPeriod(validPeriods[0].value);
        }
        const validHours = timeKeeper.ranges.hours.filter(h => h.enabled);
        if (validHours.length) {
          timeKeeper.setHour(validHours[0].value);
        }
        const validMinutes = timeKeeper.ranges.minutes.filter(m => m.enabled);
        if (validMinutes.length) {
          timeKeeper.setMinute(validMinutes[0].value);
        }
      }

      this.state = { timeKeeper };
    },

    /**
     * Initalize the spinner components.
     */
    _createComponents() {
      const { locale, format } = this.props;
      const { timeKeeper } = this.state;

      const wrapSetValueFn = setTimeFunction => {
        return value => {
          setTimeFunction(value);
          this._setComponentStates();
          this._dispatchState();
        };
      };
      const numberFormat = new Intl.NumberFormat(locale).format;

      this.components = {
        hour: new Spinner(
          {
            setValue: wrapSetValueFn(value => {
              timeKeeper.setHour(value);
              this.state.isHourSet = true;
            }),
            getDisplayString: hour => {
              if (format == "24") {
                return numberFormat(hour);
              }
              // Hour 0 in 12 hour format is displayed as 12.
              const hourIn12 = hour % DAY_PERIOD_IN_HOURS;
              return hourIn12 == 0 ? numberFormat(12) : numberFormat(hourIn12);
            },
          },
          this.context
        ),
        minute: new Spinner(
          {
            setValue: wrapSetValueFn(value => {
              timeKeeper.setMinute(value);
              this.state.isMinuteSet = true;
            }),
            getDisplayString: minute => numberFormat(minute),
          },
          this.context
        ),
      };

      this._insertLayoutElement({
        tag: "div",
        textContent: ":",
        className: "colon",
        insertBefore: this.components.minute.elements.container,
      });

      // The AM/PM spinner is only available in 12hr mode
      // TODO: Replace AM & PM string with localized string
      if (format == "12") {
        this.components.dayPeriod = new Spinner(
          {
            setValue: wrapSetValueFn(value => {
              timeKeeper.setDayPeriod(value);
              this.state.isDayPeriodSet = true;
            }),
            getDisplayString: dayPeriod => (dayPeriod == 0 ? "AM" : "PM"),
            hideButtons: true,
          },
          this.context
        );

        this._insertLayoutElement({
          tag: "div",
          className: "spacer",
          insertBefore: this.components.dayPeriod.elements.container,
        });
      }
      this._updateButtonIds();
    },

    /**
     * Insert element for layout purposes.
     *
     * @param {object}
     *        {
     *          {String} tag: The tag to create
     *          {DOMElement} insertBefore: The DOM node to insert before
     *          {String} className [optional]: Class name
     *          {String} textContent [optional]: Text content
     *        }
     */
    _insertLayoutElement({ tag, insertBefore, className, textContent }) {
      let el = document.createElement(tag);
      el.textContent = textContent;
      el.className = className;
      this.context.insertBefore(el, insertBefore);
    },

    /**
     * Set component states.
     */
    _setComponentStates() {
      const { timeKeeper, isHourSet, isMinuteSet, isDayPeriodSet } = this.state;
      const isInvalid = timeKeeper.state.isInvalid;

      this.components.hour.setState({
        value: timeKeeper.hour,
        items: timeKeeper.ranges.hours,
        isInfiniteScroll: true,
        isValueSet: isHourSet,
        isInvalid,
      });

      this.components.minute.setState({
        value: timeKeeper.minute,
        items: timeKeeper.ranges.minutes,
        isInfiniteScroll: true,
        isValueSet: isMinuteSet,
        isInvalid,
      });

      // The AM/PM spinner is only available in 12hr mode
      if (this.props.format == "12") {
        this.components.dayPeriod.setState({
          value: timeKeeper.dayPeriod,
          items: timeKeeper.ranges.dayPeriod,
          isInfiniteScroll: false,
          isValueSet: isDayPeriodSet,
          isInvalid,
        });
      }
    },

    /**
     * Dispatch CustomEvent to pass the state of picker to the panel.
     */
    _dispatchState() {
      const { hour, minute } = this.state.timeKeeper;
      const { isHourSet, isMinuteSet, isDayPeriodSet } = this.state;
      // 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: {
            hour,
            minute,
            isHourSet,
            isMinuteSet,
            isDayPeriodSet,
          },
        },
        "*"
      );
    },

    /**
     * Dispatch CustomEvent to ask the panel to close picker.
     */
    _closePopup() {
      // The panel is listening to window for postMessage event, so we
      // do postMessage to itself to close the panel without sending new data
      window.postMessage(
        {
          name: "ClosePopup",
        },
        "*"
      );
    },
    _attachEventListeners() {
      window.addEventListener("message", this);
      document.addEventListener("mousedown", this);
      document.addEventListener("keydown", this);
    },

    /**
     * Move the keyboard focus between spinners of the picker.
     *
     * @param {boolean} isReverse: Does the navigation expected to be following
     *                           the focus order (false) or not (true/isReverse)
     */
    focusNextSpinner(isReverse) {
      let focusedSpinner = document.activeElement;
      let spinners =
        focusedSpinner.parentNode.parentNode.querySelectorAll(".spinner");
      spinners = [...spinners];

      let next = isReverse
        ? spinners[spinners.indexOf(focusedSpinner) - 1]
        : spinners[spinners.indexOf(focusedSpinner) + 1];

      next?.focus();
    },

    /**
     * Handle events.
     *
     * @param  {Event} event
     */
    handleEvent(event) {
      switch (event.type) {
        case "message": {
          this.handleMessage(event);
          break;
        }
        case "mousedown": {
          // Use preventDefault to keep focus on input boxes
          event.preventDefault();
          event.target.setPointerCapture(event.pointerId);
          break;
        }
        case "keydown": {
          if (
            this.context.parentNode.id == "datetime-picker" &&
            !event.target.closest("#time-picker")
          ) {
            // The target was not a timepicker (likely a datepicker)
            break;
          }
          switch (event.key) {
            case "Enter":
            case " ": {
              // Update the value and close the picker panel
              event.stopPropagation();
              event.preventDefault();
              this._dispatchState();
              this._closePopup();
              break;
            }
            case "Escape": {
              // Close the time picker on Escape from within the panel
              event.stopPropagation();
              event.preventDefault();
              // TODO: Revert the input value to it's state before the timepicker was opened
              this._closePopup();
              break;
            }
            case "ArrowLeft":
            case "ArrowRight": {
              const isReverse = event.key == "ArrowLeft";
              this.focusNextSpinner(isReverse);
              break;
            }
          }
          break;
        }
      }
    },

    /**
     * Handle postMessage events.
     *
     * @param {Event} event
     */
    handleMessage(event) {
      switch (event.data.name) {
        case "PickerInit": {
          this.init(event.data.detail);
          break;
        }
        case "PickerPopupChanged": {
          // For datetime-local pickers, if the date is changed, notify the
          // timekeeper so it can provide updated valid ranges.
          if (this.props?.type != "datetime-local") {
            break;
          }
          if (
            event.data.detail?.year === undefined ||
            event.data.detail?.month === undefined ||
            event.data.detail?.day === undefined
          ) {
            break;
          }
          this.state.timeKeeper?.setState({
            year: event.data.detail.year,
            month: event.data.detail.month,
            day: event.data.detail.day,
          });
          this._setComponentStates();
          break;
        }
      }
    },

    /**
     * Update attributes, localizable IDs of spinners and their Prev/Next buttons:
     */
    _updateButtonIds() {
      const buttons = [
        [
          this.components.hour.elements.prev,
          "spinner-hour-previous",
          "time-spinner-hour-previous",
        ],
        [
          this.components.hour.elements.spinner,
          "spinner-hour",
          "time-spinner-hour-label",
        ],
        [
          this.components.hour.elements.next,
          "spinner-hour-next",
          "time-spinner-hour-next",
        ],
        [
          this.components.minute.elements.prev,
          "spinner-minute-previous",
          "time-spinner-minute-previous",
        ],
        [
          this.components.minute.elements.spinner,
          "spinner-minute",
          "time-spinner-minute-label",
        ],
        [
          this.components.minute.elements.next,
          "spinner-minute-next",
          "time-spinner-minute-next",
        ],
        [
          this.components.dayPeriod.elements.prev,
          "spinner-time-previous",
          "time-spinner-day-period-previous",
        ],
        [
          this.components.dayPeriod.elements.spinner,
          "spinner-time",
          "time-spinner-day-period-label",
        ],
        [
          this.components.dayPeriod.elements.next,
          "spinner-time-next",
          "time-spinner-day-period-next",
        ],
      ];

      for (const [btn, id, l10nId] of buttons) {
        btn.setAttribute("id", id);
        document.l10n.setAttributes(btn, l10nId);
      }
    },
  };
}

document.addEventListener("DOMContentLoaded", () => {
  // Create a TimePicker instance and prepare to be initialized
  // by the "PickerInit" message.
  new TimePicker(document.getElementById("time-picker"));
});
