import datetime from 'tail.datetime';
import './datepicker.css';
import { Utility } from '../../core/utility';
import moment from 'moment';

import * as defer from 'lodash.defer';

const KEYCODE_ESCAPE = 27;
const Z_INDEX_MODAL = 9999;

// should be the same as ATTR_SUBMIT_LOCKED in async-table util
// TODO move to global config
const ATTR_DATEPICKER_OPEN = 'submit-locked';

// INTERNAL (Uni2work specific) formats for formatting dates and/or times
const FORM_DATE_FORMAT = {
  'date': moment.HTML5_FMT.DATE,
  'time': moment.HTML5_FMT.TIME_SECONDS,
  'datetime-local': moment.HTML5_FMT.DATETIME_LOCAL_SECONDS,
};

// FANCY (tail.datetime specific) formats for displaying dates and/or times
const FORM_DATE_FORMAT_DATE_DT = 'dd.mm.YYYY';
const FORM_DATE_FORMAT_TIME_DT = 'HH:ii:ss';

// FANCY (moment specific) formats for displaying dates and/or times
const FORM_DATE_FORMAT_DATE_MOMENT = 'DD.MM.YYYY';
const FORM_DATE_FORMAT_TIME_MOMENT = 'HH:mm:ss';
const FORM_DATE_FORMAT_MOMENT = {
  'date': FORM_DATE_FORMAT_DATE_MOMENT,
  'time': FORM_DATE_FORMAT_TIME_MOMENT,
  'datetime-local': `${FORM_DATE_FORMAT_DATE_MOMENT} ${FORM_DATE_FORMAT_TIME_MOMENT}`,
};

const DATEPICKER_UTIL_SELECTOR = 'input[type="date"], input[type="time"], input[type="datetime-local"]';

const DATEPICKER_INITIALIZED_CLASS = 'datepicker--initialized';
const DATEPICKER_OPEN_CLASS = 'calendar-open';

const DATEPICKER_CONFIG = {
  'global': {
    // set default time to 00:00:00
    timeHours: 0,
    timeMinutes: 0,
    timeSeconds: 0,

    weekStart: 1,
    dateFormat: FORM_DATE_FORMAT_DATE_DT,
    timeFormat: FORM_DATE_FORMAT_TIME_DT,

    // prevent the instance from closing when selecting a date before selecting a time
    stayOpen: true,

    // hide the close button (we handle closing the datepicker manually by clicking outside)
    closeButton: false,

    // disable the decades view because nobody will ever need it (i.e. cap the switch to the more relevant year view)
    viewDecades: false,
  },
  'datetime-local': {},
  'date': {
    // disable date picker
    timeFormat: false,
  },
  'time': {
    // disable time picker
    dateFormat: false,
  },
};

@Utility({
  selector: DATEPICKER_UTIL_SELECTOR,
})
export class Datepicker {

  // singleton Map that maps a formID to a Map of Datepicker objects
  static datepickerCollections;

  datepickerInstance;
  _element;
  elementType;
  initialValue;
  _locale;

  _unloadIsDueToSubmit = false;

  constructor(element) {
    if (!element) {
      throw new Error('Datepicker utility needs to be passed an element!');
    }

    if (element.classList.contains(DATEPICKER_INITIALIZED_CLASS)) {
      return false;
    }

    this._locale = window.App.i18n.getDatetimeLocale();

    // initialize datepickerCollections singleton if not already done
    if (!Datepicker.datepickerCollections) {
      Datepicker.datepickerCollections = new Map();
    }

    this._element = element;

    // store the previously set type to select the input format
    this.elementType = this._element.getAttribute('type');

    // store initial value prior to changing type
    this.initialValue = this._element.value || this._element.getAttribute('value');

    // manually set the type attribute to text because datepicker handles displaying the date
    this._element.setAttribute('type', 'text');

    // get all relevant config options for this datepicker type
    const datepickerGlobalConfig = DATEPICKER_CONFIG['global'];
    const datepickerConfig = DATEPICKER_CONFIG[this.elementType];

    // additional position config (optional data-datepicker-position attribute in html) that can specialize the global config
    const datepickerPosition = this._element.dataset.datepickerPosition;
    if (datepickerPosition) {
      datepickerGlobalConfig.position = datepickerPosition;
    }

    if (!datepickerConfig || !FORM_DATE_FORMAT[this.elementType]) {
      throw new Error('Datepicker utility called on unsupported element!');
    }

    // FIXME dirty hack below; fix tail.datetime instead

    // get date object from internal format before datetime does nasty things with it
    let parsedMomentDate = moment(this.initialValue, [ FORM_DATE_FORMAT[this.elementType], FORM_DATE_FORMAT_MOMENT[this.elementType] ], true);
    if (parsedMomentDate && parsedMomentDate.isValid()) {
      parsedMomentDate = parsedMomentDate.toDate();
    } else {
      parsedMomentDate = undefined;
    }

    // initialize tail.datetime (datepicker) instance and let it do weird stuff with the element value
    this.datepickerInstance = datetime(this._element, { ...datepickerGlobalConfig, ...datepickerConfig, locale: this._locale });

    // reset date to something sane
    if (parsedMomentDate)
      this.datepickerInstance.selectDate(parsedMomentDate);

    // insert the datepicker element (dt) after the form
    this._element.form.parentNode.insertBefore(this.datepickerInstance.dt, this._element.form.nextSibling);

    // if the input element is in any open modal, increase the z-index of the datepicker
    if (this._element.closest('.modal--open')) {
      this.datepickerInstance.dt.style.zIndex = Z_INDEX_MODAL;
    }

    // register this datepicker instance with the formID of the given element in the datepicker collection
    const formID = this._element.form.id;
    const elemID = this._element.id;
    if (!Datepicker.datepickerCollections.has(formID)) {
      // insert a new key value pair if the formID key is not there already
      Datepicker.datepickerCollections.set(formID, new Map([[elemID, this]]));
    } else {
      // otherwise, insert this instance into the Map
      Datepicker.datepickerCollections.get(formID).set(elemID, this);
    }

    // mark the form input element as initialized
    this._element.classList.add(DATEPICKER_INITIALIZED_CLASS);
  }

  start() {
    const setDatepickerDate = () => {
      // try to parse the current input element value with fancy and internal format string
      const parsedMomentDate = moment(this._element.value, FORM_DATE_FORMAT_MOMENT[this.elementType]);
      const parsedMomentDateInternal = moment(this._element.value, FORM_DATE_FORMAT[this.elementType]);

      // only set the datepicker date if the input is either in valid fancy format or in valid internal format
      if (parsedMomentDate.isValid()) {
        this.datepickerInstance.selectDate(parsedMomentDate.toDate());
      } else if (parsedMomentDateInternal.isValid()) {
        this.datepickerInstance.selectDate(parsedMomentDateInternal.toDate());
      }

      // reregister change event to prevent event loop
      this._element.addEventListener('change', setDatepickerDate, { once: true });
    };
    // change the selected date in the tail.datetime instance if the value of the input element is changed
    this._element.addEventListener('change', setDatepickerDate, { once: true });


    // create a mutation observer that observes the datepicker instance class and sets
    // the datepicker-open DOM attribute of the input element if the datepicker has been opened
    const datepickerInstanceObserver = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (!mutation.oldValue.includes(DATEPICKER_OPEN_CLASS) && this.datepickerInstance.dt.getAttribute('class').includes(DATEPICKER_OPEN_CLASS)) {
          this._element.setAttribute(ATTR_DATEPICKER_OPEN, true);
          break;
        }
      }
    });
    datepickerInstanceObserver.observe(this.datepickerInstance.dt, {
      attributes: true,
      attributeFilter: ['class'],
      attributeOldValue: true,
    });


    // close the instance on focusout of any element if another input is focussed that is neither the timepicker nor _element
    window.addEventListener('focusout', event => {
      const hasFocus                = event.relatedTarget !== null;
      const focussedIsNotTimepicker = !this.datepickerInstance.dt.contains(event.relatedTarget);
      const focussedIsNotElement    = event.relatedTarget !== this._element;
      const focussedIsInDocument    = window.document.contains(event.relatedTarget);
      if (hasFocus && focussedIsNotTimepicker && focussedIsNotElement && focussedIsInDocument)
        this.closeDatepickerInstance();
    });
    
    // close the instance on click on any element outside of the datepicker (except the input element itself)
    window.addEventListener('click', event => {
      const targetIsOutside    = !this.datepickerInstance.dt.contains(event.target)
                                 && event.target !== this.datepickerInstance.dt;
      const targetIsInDocument = window.document.contains(event.target);
      const targetIsNotElement = event.target !== this._element;
      if (targetIsOutside && targetIsInDocument && targetIsNotElement)
        this.closeDatepickerInstance();
    });

    // close the instance on escape keydown events
    this._element.addEventListener('keydown', event => {
      if (event.keyCode === KEYCODE_ESCAPE) {
        this.closeDatepickerInstance();
      }
    });

    // format the date value of the form input element of this datepicker before form submission
    this._element.form.addEventListener('submit', this._submitHandler.bind(this));
  }

  destroy() {
    this.datepickerInstance.remove();
  }

  
  //  DATEPICKER INSTANCE CONTROL

  /**
   * Closes the datepicker instance, releasing the lock on the input element.
   */
  closeDatepickerInstance() {
    if (!this._element.datepicker-open) {
      throw new Error('Cannot close already closed datepicker instance!');
    }

    this._element.setAttribute(ATTR_DATEPICKER_OPEN, false);
    this.datepickerInstance.close();
  }

  
  //  FORMAT METHODS

  /**
   * Formats the value of this input element from datepicker format (i.e. DATEPICKER_CONFIG.dateFormat + " " + datetime.defaults.timeFormat) to Uni2work internal date format (i.e. FORM_DATE_FORMAT) required for form submission
   * @param {*} toFancy optional target format switch (boolean value; default is false). If set to a truthy value, formats the element value to fancy instead of internal date format.
   */
  formatElementValue(toFancy) {
    if (this._element.value) {
      this._element.value = this.unformat(toFancy);
    }
  }

  _submitHandler() {
    this._unloadIsDueToSubmit = true;
    this.formatElementValue(false);

    defer(() => { // Restore state after event loop is settled
      this._unloadIsDueToSubmit = false;
      this.formatElementValue(true);
    });
  }

  /**
   * Returns a datestring in internal format from the current state of the input element value.
   * @param {*} toFancy Format date from internal to fancy or vice versa. When omitted, toFancy is falsy and results in fancy -> internal
   */
  unformat(toFancy) {
    const formatIn = toFancy ? FORM_DATE_FORMAT[this.elementType] : FORM_DATE_FORMAT_MOMENT[this.elementType];
    const formatOut = toFancy ? FORM_DATE_FORMAT_MOMENT[this.elementType] : FORM_DATE_FORMAT[this.elementType];
    return reformatDateString(this._element.value, formatIn, formatOut);
  }

  /**
   * Takes a Form and a FormData and returns a new FormData with all dates formatted to uni2work date format. This function will not change the value of the date input elements.
   * @param {*} form Form for which all dates will be formatted in the FormData
   * @param {*} formData Initial FormData
   */
  static unformatAll(form, formData) {
    // only proceed if there are any datepickers and if both form and formData are defined
    if (Datepicker.datepickerCollections && form && formData) {
      // if the form has no id, assign one randomly
      if (!form.id) {
        form.id = `f${Math.floor(Math.random() * 100000)}`;
      }

      const formId = form.id;

      if (Datepicker.datepickerCollections.has(formId)) {
        const datepickerInstances = Datepicker.datepickerCollections.get(formId);
        datepickerInstances.forEach(instance => {
          formData.set(instance._element.name, instance.unformat());
        });
      }
    }

    // return the (possibly changed) FormData
    return formData;
  }
}


//  HELPER FUNCTIONS

/**
 * Takes a string representation of a date, an input ('previous') format and a desired output format and returns a reformatted date string.
 * If the date string is not valid (i.e. cannot be parsed with the given input format string), returns the original date string;
 * @param {*} dateStr string representation of a date (needs to be in format formatIn)
 * @param {*} formatIn input format string
 * @param {*} formatOut format string of the desired output date string
 */
function reformatDateString(dateStr, formatIn, formatOut) {
  const parsedMomentDate = moment(dateStr, [formatIn, formatOut]);
  return parsedMomentDate.isValid() ? parsedMomentDate.format(formatOut) : dateStr;
}
