/**
 * TEAM: frontend_infra
 * @flow
 */

import * as React from "react";
import moment from "moment-timezone";
import PopupWithClickAway from "latitude/popup/PopupWithClickAway";
// TODO: Fix this eslint issue on next edit. This is an autogenerated comment.
// eslint-disable-next-line flexport/no-legacy-dependencies
import {zIndices} from "latitude/tools/deprecatedZIndices";
import {
  ARROW_DOWN_KEY,
  ARROW_UP_KEY,
  ENTER_KEY,
  isKeyCodeCauseBlur,
} from "./inputUtils";
import {
  addDaysFromCalendarDate,
  today,
  type CalendarDate,
  parseStringToCalendarDate,
  formatCalendarDate,
  shouldBeCalendarDate,
} from "./CalendarDateType";
import TextInput, {type TextAlignment} from "../TextInput";
import {type Size} from "../sizes";
import invariant from "../tools/invariant";
import StackingContext from "../StackingContext";

const DatePicker = React.lazy(
  () =>
    import(/* webpackChunkName: "latitude_date_DatePicker" */ "./DatePicker")
);
export type CalendarDateInputProps = {|
  +disabled: boolean,
  +size: Size,
  +isInvalid: boolean,
  +isPrefilled: boolean,
  +placeholder?: string,
  /**
   * The date format string that is used to display a date to the use.
   * This should be a moment format string, like "MM-DD-YYYY".
   * This date format string will also be added to the moment suite of parser strings for parsing user input.
   */
  +dateFormatString: string,
  /** The minimum date that the user is allowed to view on the calendar. The month navigation will prevent navigating to months outside this range. */
  +minDate: CalendarDate | null,
  /** The maximum date that the user is allowed to view on the calendar. The month navigation will prevent navigating to months outside this range. */
  +maxDate: CalendarDate | null,
  /** If minDate and maxDate aren't enough, filterDate can be used to arbitarily filter dates (i.e. no weekends). */
  +filterDate: (CalendarDate => boolean) | null,
  /** Access to the underlying input field. */
  +inputRef: ((HTMLElement | null) => void) | null,
  /** The value of CalendarDateInput is a CalendarDate type. Check out CalendarDateType.js to learn more about this format. */
  +value: CalendarDate | null,
  +onChange: (CalendarDate | null) => void,
  /** Called when input is focused */
  +onFocus?: () => void,
  /** Called when input is blurred */
  +onBlur?: () => void,
  /** Called when key pressed */
  +onKeyDown?: KeyboardEvent => void,
  /** Sets the alignment of the text in the input field */
  +textAlign?: TextAlignment,
  +showIcon: boolean,
  +openToDate?: CalendarDate | null,
  +noPortal?: boolean,
  /** Display the week number next to each week  */
  +showWeekNumbers?: boolean,
  /** $Hide Adds a data-qa-id attribute on the TextInput for testing */
  +dataQaId?: string,
|};

const VALID_KEY_CODES = [ARROW_DOWN_KEY, ARROW_UP_KEY];
export const DEFAULT_DATE_FORMAT = "MMM D, YYYY";
const DATE_INPUT_DEFAULTS = {
  dateFormatString: DEFAULT_DATE_FORMAT,
  disabled: false,
  showIcon: false,
  size: "m",
  isPrefilled: false,
  isInvalid: false,
  inputRef: null,
  minDate: null,
  maxDate: null,
  filterDate: null,
  showWeekNumbers: false,
};

type CalendarDateInputState = {|
  textValue: string,
|};

/**
 * @category Data Entry
 * @brandStatus V2
 * @status Stable
 * @short CalendarDateInput is a visual calendar and text input for collecting a date from the user.
 * @group Date and Time
 *
 * CalendarDateInput wraps the [react-datepicker](https://github.com/Hacker0x01/react-datepicker) library from HackerOne for
 * displaying a visual calendar, but text input processing is done using the Flexport `<TextInput>` component.
 *
 * Under the hood, it uses Date to parse and format dates. However, CalendarDateInput uses a CalendarDateType in the API,
 * as opposed to the Date object. Date objects have extra information in them, which leads to ambiguity. For instance, what timezone
 * should be used when decided what "day" a Date object is? Instead, CalendarDateInput forces the implementor to thoughtfully convert
 * Date objects into text strings, using the dateToCalendarDate() method in CalendarDateType.
 */
class CalendarDateInput extends React.PureComponent<
  CalendarDateInputProps,
  CalendarDateInputState
> {
  static defaultProps: $Shape<CalendarDateInputProps> = DATE_INPUT_DEFAULTS;

  textInputRef: HTMLElement | null;
  TZ_GUESS: string;
  /**
   * Used to help determine blur and focus state across two separate elements
   * and treat them as one
   */
  isFocused: {
    input: boolean,
    datepicker: boolean,
    ...
  } = {
    input: false,
    datepicker: false,
  };
  /**
   * Used to help determine blur and focus state across two separate elements
   * and treat them as one
   */
  delayedBlurTimeoutId: ?TimeoutID;

  constructor(props: CalendarDateInputProps) {
    super(props);
    this.state = computeState(this.props.value, props.dateFormatString);
    this.TZ_GUESS = moment.tz.guess();
  }

  validateProps() {
    const {value, maxDate, minDate} = this.props;

    if (value) shouldBeCalendarDate(value);

    if (maxDate) shouldBeCalendarDate(maxDate);

    if (minDate) shouldBeCalendarDate(minDate);
  }

  componentDidUpdate(prevProps: CalendarDateInputProps) {
    const {value, dateFormatString} = this.props;
    if (
      prevProps.value !== value ||
      prevProps.dateFormatString !== dateFormatString
    ) {
      // eslint-disable-next-line react/no-did-update-set-state
      this.setState(computeState(value, dateFormatString));
    }
  }

  handleDatePickerChange(newDate: Date | null, closePopup: () => void) {
    if (this.textInputRef) this.textInputRef.focus();

    this.updateDate(newDate != null ? dateToCalendarDate(newDate) : null);

    closePopup();
  }

  handleKeyDown: (event: KeyboardEvent, closePopup: () => void) => void = (
    event: KeyboardEvent,
    closePopup: () => void
  ) => {
    if (isKeyCodeCauseBlur(event.keyCode) || event.keyCode === ENTER_KEY) {
      this.updateDateFromTextInput(this.state.textValue);
      closePopup();
      return;
    }
    if (!VALID_KEY_CODES.includes(event.keyCode)) {
      return;
    }
    const dateOrNow = this.props.value || today(this.TZ_GUESS);
    if (event.keyCode === ARROW_UP_KEY) {
      this.updateDate(addDaysFromCalendarDate(dateOrNow, 1));
    } else if (event.keyCode === ARROW_DOWN_KEY) {
      this.updateDate(addDaysFromCalendarDate(dateOrNow, -1));
    }

    if (this.props.onKeyDown) this.props.onKeyDown(event);
  };

  handleInputFieldChange: (textValue: string) => void = (textValue: string) => {
    this.setState({textValue});
  };

  /**
   * We don't need to call hidePopup here, because if the blur
   * is caused by a click, popupWith... handles it, and if it's
   * initiated from the keyboard, onHandleKeyDown handles it.
   *
   * Why _not_ call it here? If we call hidePopup, we miss any event
   * being performed on it. For instance, if the user clicks a date,
   * handleBlur => hidePopup would get called, but handleDatePickerChange
   * would never get called.
   *
   */
  handleInputBlur: () => void = () => {
    this.updateDateFromTextInput(this.state.textValue);

    this.isFocused.input = false;

    this.delayedBlur();
  };

  handleInputFocus: () => void = () => {
    if (!this.isFocused.input) {
      this.isFocused.input = true;

      // If datepicker wasn't focused prior, this is a fresh focus (not switching
      // from datepicker so call onFocus)
      if (!this.isFocused.datepicker && this.props.onFocus) {
        this.props.onFocus();
      }
    }
  };

  handleDatepickerFocus: () => void = () => {
    setTimeout(() => {
      this.isFocused.datepicker = true;
    }, 0);
  };

  handleDatepickerBlur: () => void = () => {
    setTimeout(() => {
      this.isFocused.datepicker = false;

      this.delayedBlur();
    }, 0);
  };

  delayedBlur: () => void = () => {
    if (this.delayedBlurTimeoutId) clearTimeout(this.delayedBlurTimeoutId);

    this.delayedBlurTimeoutId = setTimeout(() => {
      if (!this.isFocused.input && !this.isFocused.datepicker) {
        if (this.props.onBlur) this.props.onBlur();
      }
    }, 100);
  };

  /**
   * This is called when we actually want to try and parse
   * the user's input. Moment is too sensitive on parsing,
   * so if we did it on every keystroke, we might get it wrong.
   *
   * Instead, we wait for a blur, or attempt to blur (tab, enter).
   */
  updateDateFromTextInput: (textValue: string) => void = (
    textValue: string
  ) => {
    // shortcut if inputText is empty
    // we only want to call onChange if our current value is null
    // if we called it all the time, it could blow away other onChange
    // calls, because the order of onBlur and handleDatePickerChange isn't
    // guaranteed.
    if (this.props.value !== null && textValue.trim() === "") {
      this.updateDate(null);
      return;
    }
    const parseDateFormats = getSupportedDateFormats(
      this.props.dateFormatString
    );
    const newCalDate = parseStringToCalendarDate(textValue, parseDateFormats);

    // we don't want to call onChange if the value is the same
    // however, we'd like to reformat the existing date if it's changed
    // i.e. the user could have added extra spaces etc.
    if (newCalDate === this.props.value) {
      this.setState(
        computeState(this.props.value, this.props.dateFormatString)
      );
    } else {
      this.updateDate(newCalDate);
    }
  };

  /**
   * We only call the onChange when we want to update the
   * caller of CalendarInput. We _don't_ want to do this
   * on every keystroke.
   *
   * Instead, we call it when the user performs certain
   * actions, OR when they blur.
   *
   * The updateDate actions are: clickon on a date in the popup,
   * pressing up or down on their keyboard to iterate between days,
   *
   * If the user blurs OR attempts to blur by typing tab, escape, or return,
   * then we call updateDateFromTextInput.
   *
   * @param {*} result
   */
  updateDate(result: CalendarDate | null) {
    this.props.onChange(result);
  }

  render(): React.Node {
    this.validateProps();

    const currentCalDate = this.props.value;
    const startDate = currentCalDate
      ? calendarDateToDate(currentCalDate)
      : null;
    const openToDate = this.props.openToDate
      ? calendarDateToDate(this.props.openToDate)
      : undefined;

    const placeholder =
      this.props.placeholder == null
        ? this.props.dateFormatString
        : this.props.placeholder;

    return (
      <PopupWithClickAway
        onOpen={() => {
          this.handleDatepickerFocus();
        }}
        onClose={() => {
          this.handleDatepickerBlur();
        }}
      >
        {(Target, Popup, {openPopup, closePopup, scheduleUpdate}) => (
          <>
            <Target>
              <TextInput
                size={this.props.size}
                isInvalid={this.props.isInvalid}
                isPrefilled={this.props.isPrefilled}
                disabled={this.props.disabled}
                placeholder={placeholder}
                value={this.state.textValue}
                onKeyDown={e => {
                  this.handleKeyDown(e, closePopup);
                }}
                onFocus={() => {
                  openPopup();

                  this.handleInputFocus();
                }}
                onBlur={this.handleInputBlur}
                onChange={this.handleInputFieldChange}
                inputRef={textInputRef => {
                  this.textInputRef = textInputRef;
                  // eslint-disable-next-line no-unused-expressions
                  this.props.inputRef && this.props.inputRef(textInputRef);
                }}
                onClick={() => {
                  openPopup();
                }}
                prefix={this.props.showIcon ? {iconName: "calendar"} : null}
                dataQaId={this.props.dataQaId}
              />
            </Target>
            <StackingContext.Consumer>
              {({isDeprecatedZIndexEnabled}) => (
                <Popup
                  placement="bottom-start"
                  zIndex={
                    isDeprecatedZIndexEnabled
                      ? zIndices.zIndex1500AboveModal.zIndex
                      : null
                  }
                  noPortal={this.props.noPortal}
                >
                  <React.Suspense fallback={null}>
                    <DatePicker
                      // Since we're lazy loading this component, we need to
                      // schedule a repositioning update after it mounts
                      onMount={scheduleUpdate}
                      allowSameDay={true}
                      selected={startDate}
                      onMonthChange={() => {
                        // Changing months can change the datepicker's height (due to
                        // varying # of weeks displayed) so we schedule a
                        // repositioning update
                        scheduleUpdate();
                      }}
                      onChange={date => {
                        invariant(
                          !Array.isArray(date),
                          "react-date-picker operating in date range mode when it should only be selecting a single date"
                        );

                        this.handleDatePickerChange(date, closePopup);
                      }}
                      inline={true}
                      // react-datepicker optional arguments are not nullable, they should be undefined
                      minDate={
                        this.props.minDate
                          ? calendarDateToDate(this.props.minDate)
                          : undefined
                      }
                      maxDate={
                        this.props.maxDate
                          ? calendarDateToDate(this.props.maxDate)
                          : undefined
                      }
                      filterDate={
                        this.props.filterDate
                          ? convertCalendarDateFilterToDateFilter(
                              this.props.filterDate
                            )
                          : undefined
                      }
                      openToDate={openToDate}
                      showWeekNumbers={this.props.showWeekNumbers}
                    />
                  </React.Suspense>
                </Popup>
              )}
            </StackingContext.Consumer>
          </>
        )}
      </PopupWithClickAway>
    );
  }
}

const computeState = (value: CalendarDate | null, dateFormatString: string) => {
  const displayText = calendarDateDisplayText(value, dateFormatString);
  return {
    textValue: displayText,
  };
};

function calendarDateDisplayText(
  value: CalendarDate | null,
  dateFormatString: string
) {
  return value ? formatCalendarDate(value, dateFormatString) : "";
}

function getSupportedDateFormats(dateFormatString?: string): Array<string> {
  let parseDateFormats = SUPPORTED_DATE_FORMATS;
  if (dateFormatString && !SUPPORTED_DATE_FORMATS.includes(dateFormatString)) {
    parseDateFormats = [dateFormatString, ...SUPPORTED_DATE_FORMATS];
  }
  return parseDateFormats;
}

// latest supported date formats from:
//  https://github.com/flexport/react-datepicker/blob/master/src/date_input.jsx
const SUPPORTED_DATE_FORMATS = [
  // 1 comma club
  "MMM D, YYYY",
  "MMM D, YY",
  "MMMM D, YYYY",
  "MMMM D, YY",

  // no comma club
  "MMM D YYYY",
  "MMM D YY",
  "MMMM D YYYY",
  "MMMM D YY",

  "MM-DD-YYYY",
  "MM/DD/YYYY",
  "MM-DD-YY",
  "MM/DD/YY",
];

/**
 * This method converts the users filter function into a react-datepicker filter function.
 * That API uses native Date instead of CalendarDate.
 *
 * The filter function is called for each date visible to the user on the calendar, typically
 * 1 months worth.
 */
function convertCalendarDateFilterToDateFilter(
  filterFn: CalendarDate => boolean
): Date => boolean {
  return (date: Date) => {
    if (date) {
      const calDate = dateToCalendarDate(date);

      if (!calDate) {
        // changed from devInvariant on 5/24; Sentry search for this phrase yielded 0 results.
        invariant(
          false,
          `JavaScript Date object passed to filter function was not converted to calendar date successfully ${
            date ? date.toISOString() : "null"
          }`
        );
      }
      return filterFn(calDate);
    }

    // While a date should be guaranteed by react-datepicker, there's a
    // possibility it won't be due to a strange bug with three digit years
    // (e.g. '0621'), in that case return false and disallow all dates
    //
    // See:
    // https://github.flexport.io/flexport/flexport/tree/master/slack_messages/CCC39LZ2L_1621443374.004600.md
    return false;
  };
}

export function calendarDateToDate(calDate: CalendarDate): Date {
  return new Date(calDate);
}

export function dateToCalendarDate(date: Date | null): string | null {
  if (date) return date.toISOString();

  return null;
}

export const _test = {
  convertCalendarDateFilterToDateFilter,
  getSupportedDateFormats,
};

export default CalendarDateInput;
