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

/**
 * DateKeeper keeps track of the date states.
 */
function DateKeeper(props) {
  this.init(props);
}

{
  const DAYS_IN_A_WEEK = 7,
    MONTHS_IN_A_YEAR = 12,
    YEAR_VIEW_SIZE = 200,
    YEAR_BUFFER_SIZE = 10,
    // 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,
    MAX_YEAR = 275760,
    MAX_MONTH = 9,
    // One day in ms since epoch.
    ONE_DAY = 86400000;

  DateKeeper.prototype = {
    get year() {
      return this.state.dateObj.getUTCFullYear();
    },

    get month() {
      return this.state.dateObj.getUTCMonth();
    },

    get selection() {
      return this.state.selection;
    },

    /**
     * Initialize DateKeeper
     *
     * @param  {number} year
     * @param  {number} month
     * @param  {number} day
     * @param  {number} min
     * @param  {number} max
     * @param  {number} step
     * @param  {number} stepBase
     * @param  {number} firstDayOfWeek
     * @param  {Array<number>} weekends
     * @param  {number} calViewSize
     */
    init({
      year,
      month,
      day,
      min,
      max,
      step,
      stepBase,
      firstDayOfWeek = 0,
      weekends = [0],
      calViewSize = 42,
    }) {
      const today = new Date();

      this.state = {
        step,
        firstDayOfWeek,
        weekends,
        calViewSize,
        // min & max are NaN if empty or invalid
        min: new Date(Number.isNaN(min) ? MIN_DATE : min),
        max: new Date(Number.isNaN(max) ? MAX_DATE : max),
        stepBase: new Date(stepBase),
        today: this._newUTCDate(
          today.getFullYear(),
          today.getMonth(),
          today.getDate()
        ),
        weekHeaders: this._getWeekHeaders(firstDayOfWeek, weekends),
        years: [],
        dateObj: new Date(0),
        selection: { year, month, day },
      };

      if (year === undefined) {
        year = today.getFullYear();
      }
      if (month === undefined) {
        month = today.getMonth();
      }

      const minYear = this.state.min.getUTCFullYear();
      const maxYear = this.state.max.getUTCFullYear();

      // Choose a valid year for the value/min/max properties
      const selectedYear = Math.min(Math.max(year, minYear), maxYear);

      // Choose the month that correspond to the selectedYear
      let selectedMonth = 0;

      if (selectedYear === year) {
        selectedMonth = Math.min(
          Math.max(
            month,
            selectedYear === minYear ? this.state.min.getUTCMonth() : 0
          ),
          selectedYear === maxYear ? this.state.max.getUTCMonth() : 11
        );
      } else if (selectedYear === minYear) {
        selectedMonth = this.state.min.getUTCMonth();
      } else if (selectedYear === maxYear) {
        selectedMonth = this.state.max.getUTCMonth();
      }

      this.setCalendarMonth({
        year: selectedYear,
        month: selectedMonth,
      });
    },

    /**
     * Set new calendar month. The year is always treated as full year, so the
     * short-form is not supported.
     *
     * @param {object} date parts
     *        {
     *          {Number} year [optional]
     *          {Number} month [optional]
     *        }
     */
    setCalendarMonth({ year = this.year, month = this.month }) {
      // Make sure the date is valid before setting.
      // Use setUTCFullYear so that year 99 doesn't get parsed as 1999
      if (year > MAX_YEAR || (year === MAX_YEAR && month >= MAX_MONTH)) {
        this.state.dateObj.setUTCFullYear(MAX_YEAR, MAX_MONTH - 1, 1);
      } else if (year < 1 || (year === 1 && month < 0)) {
        this.state.dateObj.setUTCFullYear(1, 0, 1);
      } else {
        this.state.dateObj.setUTCFullYear(year, month, 1);
      }
    },

    /**
     * Set selection date
     *
     * @param {number} year
     * @param {number} month
     * @param {number} day
     */
    setSelection({ year, month, day }) {
      // Select the date only if it is within bounds
      const minYear = this.state.min.getUTCFullYear();
      const minMonth = this.state.min.getUTCMonth();
      const minDate = this.state.min.getUTCDate();
      const maxYear = this.state.max.getUTCFullYear();
      const maxMonth = this.state.max.getUTCMonth();
      const maxDate = this.state.max.getUTCDate();

      if (
        year > maxYear ||
        year < minYear ||
        (year == maxYear && month > maxMonth) ||
        (year == minYear && month < minMonth) ||
        (year == maxYear && month == maxMonth && day > maxDate) ||
        (year == minYear && month == minMonth && day < minDate)
      ) {
        return;
      }

      this.state.selection.year = year;
      this.state.selection.month = month;
      this.state.selection.day = day;
    },

    /**
     * Set month. Makes sure the day is <= the last day of the month
     *
     * @param {number} month
     */
    setMonth(month) {
      this.setCalendarMonth({ year: this.year, month });
    },

    /**
     * Set year. Makes sure the day is <= the last day of the month
     *
     * @param {number} year
     */
    setYear(year) {
      this.setCalendarMonth({ year, month: this.month });
    },

    /**
     * Set month by offset. Makes sure the day is <= the last day of the month
     *
     * @param {number} offset
     */
    setMonthByOffset(offset) {
      this.setCalendarMonth({ year: this.year, month: this.month + offset });
    },

    /**
     * Generate the array of months
     *
     * @return {Array<object>}
     *         {
     *           {Number} value: Month in int
     *           {Boolean} enabled
     *         }
     */
    getMonths() {
      let months = [];

      const currentYear = this.year;

      const minYear = this.state.min.getUTCFullYear();
      const minMonth = this.state.min.getUTCMonth();
      const maxYear = this.state.max.getUTCFullYear();
      const maxMonth = this.state.max.getUTCMonth();

      for (let i = 0; i < MONTHS_IN_A_YEAR; i++) {
        const disabled =
          (currentYear == minYear && i < minMonth) ||
          (currentYear == maxYear && i > maxMonth);
        months.push({
          value: i,
          enabled: !disabled,
        });
      }

      return months;
    },

    /**
     * Generate the array of years
     *
     * @return {Array<object>}
     *         {
     *           {Number} value: Year in int
     *           {Boolean} enabled
     *         }
     */
    getYears() {
      let years = [];

      const firstItem = this.state.years[0];
      const lastItem = this.state.years[this.state.years.length - 1];
      const currentYear = this.year;

      const minYear = Math.max(this.state.min.getUTCFullYear(), 1);
      const maxYear = Math.min(this.state.max.getUTCFullYear(), MAX_YEAR);

      // Generate new years array when the year is outside of the first &
      // last item range. If not, return the cached result.
      if (
        !firstItem ||
        !lastItem ||
        currentYear <= firstItem.value + YEAR_BUFFER_SIZE ||
        currentYear >= lastItem.value - YEAR_BUFFER_SIZE
      ) {
        // The year is set in the middle with items on both directions
        for (let i = -(YEAR_VIEW_SIZE / 2); i < YEAR_VIEW_SIZE / 2; i++) {
          const year = currentYear + i;

          if (year >= minYear && year <= maxYear) {
            years.push({
              value: year,
              enabled: true,
            });
          }
        }
        this.state.years = years;
      }
      return this.state.years;
    },

    /**
     * Get days for calendar
     *
     * @return {Array<object>}
     *         {
     *           {Date} dateObj
     *           {Number} content
     *           {Array<String>} classNames
     *           {Boolean} enabled
     *         }
     */
    getDays() {
      const firstDayOfMonth = this._getFirstCalendarDate(
        this.state.dateObj,
        this.state.firstDayOfWeek
      );
      const month = this.month;
      let days = [];

      for (let i = 0; i < this.state.calViewSize; i++) {
        const dateObj = this._newUTCDate(
          firstDayOfMonth.getUTCFullYear(),
          firstDayOfMonth.getUTCMonth(),
          firstDayOfMonth.getUTCDate() + i
        );

        let classNames = [];
        let enabled = true;

        const isValid =
          dateObj.getTime() >= MIN_DATE && dateObj.getTime() <= MAX_DATE;
        if (!isValid) {
          classNames.push("out-of-range");
          enabled = false;

          days.push({
            classNames,
            enabled,
          });
          continue;
        }

        const isWeekend = this.state.weekends.includes(dateObj.getUTCDay());
        const isCurrentMonth = month == dateObj.getUTCMonth();
        const isSelection =
          this.state.selection.year == dateObj.getUTCFullYear() &&
          this.state.selection.month == dateObj.getUTCMonth() &&
          this.state.selection.day == dateObj.getUTCDate();
        // The date is at 00:00, so if the minimum is that day at e.g. 01:00,
        // we should arguably still be able to select that date. So we need to
        // compare the date at the very end of the day for minimum purposes.
        const isOutOfRange =
          dateObj.getTime() + ONE_DAY - 1 < this.state.min.getTime() ||
          dateObj.getTime() > this.state.max.getTime();
        const isToday = this.state.today.getTime() == dateObj.getTime();
        const isOffStep = this._checkIsOffStep(
          dateObj,
          this._newUTCDate(
            dateObj.getUTCFullYear(),
            dateObj.getUTCMonth(),
            dateObj.getUTCDate() + 1
          )
        );

        if (isWeekend) {
          classNames.push("weekend");
        }
        if (!isCurrentMonth) {
          classNames.push("outside");
        }
        if (isSelection && !isOutOfRange && !isOffStep) {
          classNames.push("selection");
        }
        if (isOutOfRange) {
          classNames.push("out-of-range");
          enabled = false;
        }
        if (isToday) {
          classNames.push("today");
        }
        if (isOffStep) {
          classNames.push("off-step");
          enabled = false;
        }
        days.push({
          dateObj,
          content: dateObj.getUTCDate(),
          classNames,
          enabled,
        });
      }
      return days;
    },

    /**
     * Check if a date is off step given a starting point and the next increment
     *
     * @param  {Date} start
     * @param  {Date} next
     * @return {boolean}
     */
    _checkIsOffStep(start, next) {
      // If the increment is larger or equal to the step, it must not be off-step.
      if (next - start >= this.state.step) {
        return false;
      }
      // Calculate the last valid date
      const lastValidStep = Math.floor(
        (next - 1 - this.state.stepBase) / this.state.step
      );
      const lastValidTimeInMs =
        lastValidStep * this.state.step + this.state.stepBase.getTime();
      // The date is off-step if the last valid date is smaller than the start date
      return lastValidTimeInMs < start.getTime();
    },

    /**
     * Get week headers for calendar
     *
     * @param  {number} firstDayOfWeek
     * @param  {Array<number>} weekends
     * @return {Array<object>}
     *         {
     *           {Number} content
     *           {Array<String>} classNames
     *         }
     */
    _getWeekHeaders(firstDayOfWeek, weekends) {
      let headers = [];
      let dayOfWeek = firstDayOfWeek;

      for (let i = 0; i < DAYS_IN_A_WEEK; i++) {
        headers.push({
          content: dayOfWeek % DAYS_IN_A_WEEK,
          classNames: weekends.includes(dayOfWeek % DAYS_IN_A_WEEK)
            ? ["weekend"]
            : [],
        });
        dayOfWeek++;
      }
      return headers;
    },

    /**
     * Get the first day on a calendar month
     *
     * @param  {Date} dateObj
     * @param  {number} firstDayOfWeek
     * @return {Date}
     */
    _getFirstCalendarDate(dateObj, firstDayOfWeek) {
      const daysOffset = 1 - DAYS_IN_A_WEEK;
      let firstDayOfMonth = this._newUTCDate(
        dateObj.getUTCFullYear(),
        dateObj.getUTCMonth()
      );
      let dayOfWeek = firstDayOfMonth.getUTCDay();

      return this._newUTCDate(
        firstDayOfMonth.getUTCFullYear(),
        firstDayOfMonth.getUTCMonth(),
        // When first calendar date is the same as first day of the week, add
        // another row on top of it.
        firstDayOfWeek == dayOfWeek
          ? daysOffset
          : (firstDayOfWeek - dayOfWeek + daysOffset) % DAYS_IN_A_WEEK
      );
    },

    /**
     * Helper function for creating UTC dates
     *
     * @param  {...[number]} parts
     * @return {Date}
     */
    _newUTCDate(...parts) {
      return new Date(new Date(0).setUTCFullYear(...parts));
    },
  };
}
