import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
import {
  caretDownSharp,
  caretUpSharp,
  chevronBack,
  chevronDown,
  chevronForward
} from 'ionicons/icons';

import { getIonMode } from '../../global/ionic-global';
import { Color, DatetimeChangeEventDetail, DatetimeParts, Mode, StyleEventDetail } from '../../interface';
import { startFocusVisible } from '../../utils/focus-visible';
import { getElementRoot, raf, renderHiddenInput } from '../../utils/helpers';
import { createColorClasses } from '../../utils/theme';
import { PickerColumnItem } from '../picker-column-internal/picker-column-internal-interfaces';

import {
  generateMonths,
  generateTime,
  getCalendarYears,
  getDaysOfMonth,
  getDaysOfWeek,
  getPickerMonths,
  getToday
} from './utils/data';
import {
  addTimePadding,
  getFormattedHour,
  getFormattedTime,
  getMonthAndDay,
  getMonthAndYear
} from './utils/format';
import {
  is24Hour
} from './utils/helpers';
import {
  calculateHourFromAMPM,
  convertDataToISO,
  getEndOfWeek,
  getInternalHourValue,
  getNextDay,
  getNextMonth,
  getNextWeek,
  getNextYear,
  getPreviousDay,
  getPreviousMonth,
  getPreviousWeek,
  getPreviousYear,
  getStartOfWeek
} from './utils/manipulation';
import {
  convertToArrayOfNumbers,
  getPartsFromCalendarDay,
  parseDate
} from './utils/parse';
import {
  getCalendarDayState,
  isDayDisabled
} from './utils/state';

/**
 * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
 *
 * @slot title - The title of the datetime.
 * @slot buttons - The buttons in the datetime.
 * @slot time-label - The label for the time selector in the datetime.
 */
@Component({
  tag: 'ion-datetime',
  styleUrls: {
    ios: 'datetime.ios.scss',
    md: 'datetime.md.scss'
  },
  shadow: true
})
export class Datetime implements ComponentInterface {

  private inputId = `ion-dt-${datetimeIds++}`;
  private calendarBodyRef?: HTMLElement;
  private popoverRef?: HTMLIonPopoverElement;
  private clearFocusVisible?: () => void;
  private overlayIsPresenting = false;

  private parsedMinuteValues?: number[];
  private parsedHourValues?: number[];
  private parsedMonthValues?: number[];
  private parsedYearValues?: number[];
  private parsedDayValues?: number[];

  private destroyCalendarIO?: () => void;
  private destroyKeyboardMO?: () => void;

  private minParts?: any;
  private maxParts?: any;

  /**
   * Duplicate reference to `activeParts` that does not trigger a re-render of the component.
   * Allows caching an instance of the `activeParts` in between render cycles.
   */
  private activePartsClone!: DatetimeParts;

  @State() showMonthAndYear = false;

  @State() activeParts: DatetimeParts = {
    month: 5,
    day: 28,
    year: 2021,
    hour: 13,
    minute: 52,
    ampm: 'pm'
  }

  @State() workingParts: DatetimeParts = {
    month: 5,
    day: 28,
    year: 2021,
    hour: 13,
    minute: 52,
    ampm: 'pm'
  }

  private todayParts = parseDate(getToday())

  @Element() el!: HTMLIonDatetimeElement;

  @State() isPresented = false;
  @State() isTimePopoverOpen = false;

  /**
   * The color to use from your application's color palette.
   * Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
   * For more information on colors, see [theming](/docs/theming/basics).
   */
  @Prop() color?: Color = 'primary';

  /**
   * The name of the control, which is submitted with the form data.
   */
  @Prop() name: string = this.inputId;

  /**
   * If `true`, the user cannot interact with the datetime.
   */
  @Prop() disabled = false;

  /**
   * If `true`, the datetime appears normal but is not interactive.
   */
  @Prop() readonly = false;

  @Watch('disabled')
  protected disabledChanged() {
    this.emitStyle();
  }

  /**
   * The minimum datetime allowed. Value must be a date string
   * following the
   * [ISO 8601 datetime format standard](https://www.w3.org/TR/NOTE-datetime),
   * such as `1996-12-19`. The format does not have to be specific to an exact
   * datetime. For example, the minimum could just be the year, such as `1994`.
   * Defaults to the beginning of the year, 100 years ago from today.
   */
  @Prop({ mutable: true }) min?: string;

  @Watch('min')
  protected minChanged() {
    this.processMinParts();
  }

  /**
   * The maximum datetime allowed. Value must be a date string
   * following the
   * [ISO 8601 datetime format standard](https://www.w3.org/TR/NOTE-datetime),
   * `1996-12-19`. The format does not have to be specific to an exact
   * datetime. For example, the maximum could just be the year, such as `1994`.
   * Defaults to the end of this year.
   */
  @Prop({ mutable: true }) max?: string;

  @Watch('max')
  protected maxChanged() {
    this.processMaxParts();
  }

  /**
   * Which values you want to select. `'date'` will show
   * a calendar picker to select the month, day, and year. `'time'`
   * will show a time picker to select the hour, minute, and (optionally)
   * AM/PM. `'date-time'` will show the date picker first and time picker second.
   * `'time-date'` will show the time picker first and date picker second.
   */
  @Prop() presentation: 'date-time' | 'time-date' | 'date' | 'time' | 'month' | 'year' | 'month-year' = 'date-time';

  /**
   * The text to display on the picker's cancel button.
   */
  @Prop() cancelText = 'Cancel';

  /**
   * The text to display on the picker's "Done" button.
   */
  @Prop() doneText = 'Done';

  /**
   * The text to display on the picker's "Clear" button.
   */
  @Prop() clearText = 'Clear';

  /**
   * Values used to create the list of selectable years. By default
   * the year values range between the `min` and `max` datetime inputs. However, to
   * control exactly which years to display, the `yearValues` input can take a number, an array
   * of numbers, or string of comma separated numbers. For example, to show upcoming and
   * recent leap years, then this input's value would be `yearValues="2024,2020,2016,2012,2008"`.
   */
  @Prop() yearValues?: number[] | number | string;
  @Watch('yearValues')
  protected yearValuesChanged() {
    this.parsedYearValues = convertToArrayOfNumbers(this.yearValues);
  }

  /**
   * Values used to create the list of selectable months. By default
   * the month values range from `1` to `12`. However, to control exactly which months to
   * display, the `monthValues` input can take a number, an array of numbers, or a string of
   * comma separated numbers. For example, if only summer months should be shown, then this
   * input value would be `monthValues="6,7,8"`. Note that month numbers do *not* have a
   * zero-based index, meaning January's value is `1`, and December's is `12`.
   */
  @Prop() monthValues?: number[] | number | string;
  @Watch('monthValues')
  protected monthValuesChanged() {
    this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues);
  }

  /**
   * Values used to create the list of selectable days. By default
   * every day is shown for the given month. However, to control exactly which days of
   * the month to display, the `dayValues` input can take a number, an array of numbers, or
   * a string of comma separated numbers. Note that even if the array days have an invalid
   * number for the selected month, like `31` in February, it will correctly not show
   * days which are not valid for the selected month.
   */
  @Prop() dayValues?: number[] | number | string;
  @Watch('dayValues')
  protected dayValuesChanged() {
    this.parsedDayValues = convertToArrayOfNumbers(this.dayValues);
  }

  /**
   * Values used to create the list of selectable hours. By default
   * the hour values range from `0` to `23` for 24-hour, or `1` to `12` for 12-hour. However,
   * to control exactly which hours to display, the `hourValues` input can take a number, an
   * array of numbers, or a string of comma separated numbers.
   */
  @Prop() hourValues?: number[] | number | string;
  @Watch('hourValues')
  protected hourValuesChanged() {
    this.parsedHourValues = convertToArrayOfNumbers(this.hourValues);
  }

  /**
   * Values used to create the list of selectable minutes. By default
   * the minutes range from `0` to `59`. However, to control exactly which minutes to display,
   * the `minuteValues` input can take a number, an array of numbers, or a string of comma
   * separated numbers. For example, if the minute selections should only be every 15 minutes,
   * then this input value would be `minuteValues="0,15,30,45"`.
   */
  @Prop() minuteValues?: number[] | number | string;
  @Watch('minuteValues')
  protected minuteValuesChanged() {
    this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues);
  }

  /**
   * The locale to use for `ion-datetime`. This
   * impacts month and day name formatting.
   * The `'default'` value refers to the default
   * locale set by your device.
   */
  @Prop() locale = 'default';

  /**
   * The first day of the week to use for `ion-datetime`. The
   * default value is `0` and represents Sunday.
   */
  @Prop() firstDayOfWeek = 0;

  /**
   * The value of the datetime as a valid ISO 8601 datetime string.
   */
  @Prop({ mutable: true }) value?: string | null;

  /**
   * Update the datetime value when the value changes
   */
  @Watch('value')
  protected valueChanged() {
    if (this.hasValue()) {
      /**
       * Clones the value of the `activeParts` to the private clone, to update
       * the date display on the current render cycle without causing another render.
       *
       * This allows us to update the current value's date/time display without
       * refocusing or shifting the user's display (leaves the user in place).
       */
      const { month, day, year, hour, minute } = parseDate(this.value);
      this.activePartsClone = {
        ...this.activeParts,
        month,
        day,
        year,
        hour,
        minute
      }
    }

    this.emitStyle();
    this.ionChange.emit({
      value: this.value
    });
  }

  /**
   * If `true`, a header will be shown above the calendar
   * picker. On `ios` mode this will include the
   * slotted title, and on `md` mode this will include
   * the slotted title and the selected date.
   */
  @Prop() showDefaultTitle = false;

  /**
   * If `true`, the default "Cancel" and "OK" buttons
   * will be rendered at the bottom of the `ion-datetime`
   * component. Developers can also use the `button` slot
   * if they want to customize these buttons. If custom
   * buttons are set in the `button` slot then the
   * default buttons will not be rendered.
   */
  @Prop() showDefaultButtons = false;

  /**
   * If `true`, a "Clear" button will be rendered alongside
   * the default "Cancel" and "OK" buttons at the bottom of the `ion-datetime`
   * component. Developers can also use the `button` slot
   * if they want to customize these buttons. If custom
   * buttons are set in the `button` slot then the
   * default buttons will not be rendered.
   */
  @Prop() showClearButton = false;

  /**
   * If `true`, the default "Time" label will be rendered
   * for the time selector of the `ion-datetime` component.
   * Developers can also use the `time-label` slot
   * if they want to customize this label. If a custom
   * label is set in the `time-label` slot then the
   * default label will not be rendered.
   */
  @Prop() showDefaultTimeLabel = true;

  /**
   * The hour cycle of the `ion-datetime`. If no value is set, this is
   * specified by the current locale.
   */
  @Prop() hourCycle?: 'h23' | 'h12';

  /**
   * If `cover`, the `ion-datetime` will expand to cover the full width of its container.
   * If `fixed`, the `ion-datetime` will have a fixed width.
   */
  @Prop() size: 'cover' | 'fixed' = 'fixed';

  /**
   * Emitted when the datetime selection was cancelled.
   */
  @Event() ionCancel!: EventEmitter<void>;

  /**
   * Emitted when the value (selected date) has changed.
   */
  @Event() ionChange!: EventEmitter<DatetimeChangeEventDetail>;

  /**
   * Emitted when the datetime has focus.
   */
  @Event() ionFocus!: EventEmitter<void>;

  /**
   * Emitted when the datetime loses focus.
   */
  @Event() ionBlur!: EventEmitter<void>;

  /**
   * Emitted when the styles change.
   * @internal
   */
  @Event() ionStyle!: EventEmitter<StyleEventDetail>;

  /**
   * Confirms the selected datetime value, updates the
   * `value` property, and optionally closes the popover
   * or modal that the datetime was presented in.
   */
  @Method()
  async confirm(closeOverlay = false) {
    /**
     * Prevent convertDataToISO from doing any
     * kind of transformation based on timezone
     * This cancels out any change it attempts to make
     *
     * Important: Take the timezone offset based on
     * the date that is currently selected, otherwise
     * there can be 1 hr difference when dealing w/ DST
     */
    const date = new Date(convertDataToISO(this.workingParts));
    this.workingParts.tzOffset = date.getTimezoneOffset() * -1;

    this.value = convertDataToISO(this.workingParts);

    if (closeOverlay) {
      this.closeParentOverlay();
    }
  }

  /**
   * Resets the internal state of the datetime but does not update the value.
   * Passing a valid ISO-8601 string will reset the state of the component to the provided date.
   * If no value is provided, the internal state will be reset to today.
   */
  @Method()
  async reset(startDate?: string) {
    this.processValue(startDate);
  }

  /**
   * Emits the ionCancel event and
   * optionally closes the popover
   * or modal that the datetime was
   * presented in.
   */
  @Method()
  async cancel(closeOverlay = false) {
    this.ionCancel.emit();

    if (closeOverlay) {
      this.closeParentOverlay();
    }
  }

  private closeParentOverlay = () => {
    const popoverOrModal = this.el.closest('ion-modal, ion-popover') as HTMLIonModalElement | HTMLIonPopoverElement | null;
    if (popoverOrModal) {
      popoverOrModal.dismiss();
    }
  }

  private setWorkingParts = (parts: DatetimeParts) => {
    this.workingParts = {
      ...parts
    }
  }

  private setActiveParts = (parts: DatetimeParts) => {
    this.activeParts = {
      ...parts
    }

    const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null;
    if (hasSlottedButtons || this.showDefaultButtons) { return; }

    this.confirm();
  }

  private initializeKeyboardListeners = () => {
    const { calendarBodyRef } = this;
    if (!calendarBodyRef) { return; }

    const root = this.el!.shadowRoot!;

    /**
     * Get a reference to the month
     * element we are currently viewing.
     */
    const currentMonth = calendarBodyRef.querySelector('.calendar-month:nth-of-type(2)')!;

    /**
     * When focusing the calendar body, we want to pass focus
     * to the working day, but other days should
     * only be accessible using the arrow keys. Pressing
     * Tab should jump between bodies of selectable content.
     */
    const checkCalendarBodyFocus = (ev: MutationRecord[]) => {
      const record = ev[0];

      /**
       * If calendar body was already focused
       * when this fired or if the calendar body
       * if not currently focused, we should not re-focus
       * the inner day.
       */
      if (
        record.oldValue?.includes('ion-focused') ||
        !calendarBodyRef.classList.contains('ion-focused')
      ) {
        return;
      }

      this.focusWorkingDay(currentMonth);
    }
    const mo = new MutationObserver(checkCalendarBodyFocus);
    mo.observe(calendarBodyRef, { attributeFilter: ['class'], attributeOldValue: true });

    this.destroyKeyboardMO = () => {
      mo?.disconnect();
    }

    /**
     * We must use keydown not keyup as we want
     * to prevent scrolling when using the arrow keys.
     */
    this.calendarBodyRef!.addEventListener('keydown', (ev: KeyboardEvent) => {
      const activeElement = root.activeElement;
      if (!activeElement || !activeElement.classList.contains('calendar-day')) { return; }

      const parts = getPartsFromCalendarDay(activeElement as HTMLElement)

      let partsToFocus: DatetimeParts | undefined;
      switch (ev.key) {
        case 'ArrowDown':
          ev.preventDefault();
          partsToFocus = getNextWeek(parts);
          break;
        case 'ArrowUp':
          ev.preventDefault();
          partsToFocus = getPreviousWeek(parts);
          break;
        case 'ArrowRight':
          ev.preventDefault();
          partsToFocus = getNextDay(parts);
          break;
        case 'ArrowLeft':
          ev.preventDefault();
          partsToFocus = getPreviousDay(parts);
          break;
        case 'Home':
          ev.preventDefault();
          partsToFocus = getStartOfWeek(parts);
          break;
        case 'End':
          ev.preventDefault();
          partsToFocus = getEndOfWeek(parts);
          break;
        case 'PageUp':
          ev.preventDefault();
          partsToFocus = ev.shiftKey ? getPreviousYear(parts) : getPreviousMonth(parts);
          break;
        case 'PageDown':
          ev.preventDefault();
          partsToFocus = ev.shiftKey ? getNextYear(parts) : getNextMonth(parts);
          break;
        /**
         * Do not preventDefault here
         * as we do not want to override other
         * browser defaults such as pressing Enter/Space
         * to select a day.
         */
        default:
          return;
      }

      /**
       * If the day we want to move focus to is
       * disabled, do not do anything.
       */
      if (isDayDisabled(partsToFocus, this.minParts, this.maxParts)) {
        return;
      }

      this.setWorkingParts({
        ...this.workingParts,
        ...partsToFocus
      })

      /**
       * Give view a chance to re-render
       * then move focus to the new working day
       */
      requestAnimationFrame(() => this.focusWorkingDay(currentMonth));
    })
  }

  private focusWorkingDay = (currentMonth: Element) => {
    /**
     * Get the number of padding days so
     * we know how much to offset our next selector by
     * to grab the correct calenday-day element.
     */
    const padding = currentMonth.querySelectorAll('.calendar-day-padding');
    const { day } = this.workingParts;

    if (day === null) { return; }

    /**
     * Get the calendar day element
     * and focus it.
     */
    const dayEl = currentMonth.querySelector(`.calendar-day:nth-of-type(${padding.length + day})`) as HTMLElement | null;
    if (dayEl) {
      dayEl.focus();
    }
  }

  private processMinParts = () => {
    if (this.min === undefined) {
      this.minParts = undefined;
      return;
    }

    const { month, day, year, hour, minute } = parseDate(this.min);

    this.minParts = {
      month,
      day,
      year,
      hour,
      minute
    }
  }

  private processMaxParts = () => {
    if (this.max === undefined) {
      this.maxParts = undefined;
      return;
    }

    const { month, day, year, hour, minute } = parseDate(this.max);

    this.maxParts = {
      month,
      day,
      year,
      hour,
      minute
    }
  }

  private initializeCalendarIOListeners = () => {
    const { calendarBodyRef } = this;
    if (!calendarBodyRef) { return; }

    const mode = getIonMode(this);

    /**
     * For performance reasons, we only render 3
     * months at a time: The current month, the previous
     * month, and the next month. We have IntersectionObservers
     * on the previous and next month elements to append/prepend
     * new months.
     *
     * We can do this because Stencil is smart enough to not
     * re-create the .calendar-month containers, but rather
     * update the content within those containers.
     *
     * As an added bonus, WebKit has some troubles with
     * scroll-snap-stop: always, so not rendering all of
     * the months in a row allows us to mostly sidestep
     * that issue.
     */
    const months = calendarBodyRef.querySelectorAll('.calendar-month');

    const startMonth = months[0] as HTMLElement;
    const workingMonth = months[1] as HTMLElement;
    const endMonth = months[2] as HTMLElement;

    /**
     * Before setting up the IntersectionObserver,
     * scroll the middle month into view.
     * scrollIntoView() will scroll entire page
     * if element is not in viewport. Use scrollLeft instead.
     */
    writeTask(() => {
      calendarBodyRef.scrollLeft = startMonth.clientWidth;

      let endIO: IntersectionObserver | undefined;
      let startIO: IntersectionObserver | undefined;
      const ioCallback = (callbackType: 'start' | 'end', entries: IntersectionObserverEntry[]) => {
        const refIO = (callbackType === 'start') ? startIO : endIO;
        const refMonth = (callbackType === 'start') ? startMonth : endMonth;
        const refMonthFn = (callbackType === 'start') ? getPreviousMonth : getNextMonth;

        /**
         * If the month is not fully in view, do not do anything
         */
        const ev = entries[0];
        if (!ev.isIntersecting) { return; }

        /**
         * When presenting an inline overlay,
         * subsequent presentations will cause
         * the IO to fire again (since the overlay
         * is now visible and therefore the calendar
         * months are intersecting).
         */
        if (this.overlayIsPresenting) {
          this.overlayIsPresenting = false;
          return;
        }

        /**
         * On iOS, we need to set pointer-events: none
         * when the user is almost done with the gesture
         * so that they cannot quickly swipe while
         * the scrollable container is snapping.
         * Updating the container while snapping
         * causes WebKit to snap incorrectly.
         */
        if (mode === 'ios') {
          const ratio = ev.intersectionRatio;
          const shouldDisable = Math.abs(ratio - 0.7) <= 0.1;

          if (shouldDisable) {
            calendarBodyRef.style.setProperty('pointer-events', 'none');
            return;
          }
        }

        /**
         * Prevent scrolling for other browsers
         * to give the DOM time to update and the container
         * time to properly snap.
         */
        calendarBodyRef.style.setProperty('overflow', 'hidden');

        /**
         * Remove the IO temporarily
         * otherwise you can sometimes get duplicate
         * events when rubber banding.
         */
        if (refIO === undefined) { return; }
        refIO.disconnect();

        /**
         * Use a writeTask here to ensure
         * that the state is updated and the
         * correct month is scrolled into view
         * in the same frame. This is not
         * typically a problem on newer devices
         * but older/slower device may have a flicker
         * if we did not do this.
         */
        writeTask(() => {
          const { month, year, day } = refMonthFn(this.workingParts);

          this.setWorkingParts({
            ...this.workingParts,
            month,
            day: day!,
            year
          });

          calendarBodyRef.scrollLeft = workingMonth.clientWidth;
          calendarBodyRef.style.removeProperty('overflow');
          calendarBodyRef.style.removeProperty('pointer-events');

          /**
           * Now that state has been updated
           * and the correct month is in view,
           * we can resume the IO.
           */
          // tslint:disable-next-line
          if (refIO === undefined) { return; }
          refIO.observe(refMonth);
        });
      }

      /**
       * Listen on the first month to
       * prepend a new month and on the last
       * month to append a new month.
       * The 0.7 threshold is required on ios
       * so that we can remove pointer-events
       * when adding new months.
       * Adding to a scroll snapping container
       * while the container is snapping does not
       * completely work as expected in WebKit.
       * Adding pointer-events: none allows us to
       * avoid these issues.
       *
       * This should be fine on Chromium, but
       * when you set pointer-events: none
       * it applies to active gestures which is not
       * something WebKit does.
       */
      endIO = new IntersectionObserver(ev => ioCallback('end', ev), {
        threshold: mode === 'ios' ? [0.7, 1] : 1,
        root: calendarBodyRef
      });
      endIO.observe(endMonth);

      startIO = new IntersectionObserver(ev => ioCallback('start', ev), {
        threshold: mode === 'ios' ? [0.7, 1] : 1,
        root: calendarBodyRef
       });
      startIO.observe(startMonth);

      this.destroyCalendarIO = () => {
        endIO?.disconnect();
        startIO?.disconnect();
      }
   });
  }

  connectedCallback() {
    this.clearFocusVisible = startFocusVisible(this.el).destroy;
  }

  disconnectedCallback() {
    if (this.clearFocusVisible) {
      this.clearFocusVisible();
      this.clearFocusVisible = undefined;
    }
  }

  /**
   * Clean up all listeners except for the overlay
   * listener. This is so that we can re-create the listeners
   * if the datetime has been hidden/presented by a modal or popover.
   */
  private destroyListeners = () => {
    const { destroyCalendarIO, destroyKeyboardMO } = this;

    if (destroyCalendarIO !== undefined) {
      destroyCalendarIO();
    }

    if (destroyKeyboardMO !== undefined) {
      destroyKeyboardMO();
    }
  }

  componentDidLoad() {
    /**
     * If a scrollable element is hidden using `display: none`,
     * it will not have a scroll height meaning we cannot scroll elements
     * into view. As a result, we will need to wait for the datetime to become
     * visible if used inside of a modal or a popover otherwise the scrollable
     * areas will not have the correct values snapped into place.
     */
    let visibleIO: IntersectionObserver | undefined;
    const visibleCallback = (entries: IntersectionObserverEntry[]) => {
      const ev = entries[0];
      if (!ev.isIntersecting) { return; }

      this.initializeCalendarIOListeners();
      this.initializeKeyboardListeners();
      this.initializeOverlayListener();

      /**
       * TODO: Datetime needs a frame to ensure that it
       * can properly scroll contents into view. As a result
       * we hide the scrollable content until after that frame
       * so users do not see the content quickly shifting. The downside
       * is that the content will pop into view a frame after. Maybe there
       * is a better way to handle this?
       */
      writeTask(() => {
        this.el.classList.add('datetime-ready');
      });
    }
    visibleIO = new IntersectionObserver(visibleCallback, { threshold: 0.01 });

    /**
     * Use raf to avoid a race condition between the component loading and
     * its display animation starting (such as when shown in a modal). This
     * could cause the datetime to start at a visibility of 0, erroneously
     * triggering the `hiddenIO` observer below.
     */
    raf(() => visibleIO?.observe(this.el));

    /**
     * We need to clean up listeners when the datetime is hidden
     * in a popover/modal so that we can properly scroll containers
     * back into view if they are re-presented. When the datetime is hidden
     * the scroll areas have scroll widths/heights of 0px, so any snapping
     * we did originally has been lost.
     */
    let hiddenIO: IntersectionObserver | undefined;
    const hiddenCallback = (entries: IntersectionObserverEntry[]) => {
      const ev = entries[0];
      if (ev.isIntersecting) { return; }

      this.destroyListeners();

      writeTask(() => {
        this.el.classList.remove('datetime-ready');
      });
    }
    hiddenIO = new IntersectionObserver(hiddenCallback, { threshold: 0 });
    raf(() => hiddenIO?.observe(this.el));

    /**
     * Datetime uses Ionic components that emit
     * ionFocus and ionBlur. These events are
     * composed meaning they will cross
     * the shadow dom boundary. We need to
     * stop propagation on these events otherwise
     * developers will see 2 ionFocus or 2 ionBlur
     * events at a time.
     */
    const root = getElementRoot(this.el);
    root.addEventListener('ionFocus', (ev: Event) => ev.stopPropagation());
    root.addEventListener('ionBlur', (ev: Event) => ev.stopPropagation());
  }

  /**
   * When doing subsequent presentations of an inline
   * overlay, the IO callback will fire again causing
   * the calendar to go back one month. We need to listen
   * for the presentation of the overlay so we can properly
   * cancel that IO callback.
   */
  private initializeOverlayListener = () => {
    const overlay = this.el.closest('ion-popover, ion-modal');
    if (overlay === null) { return; }

    overlay.addEventListener('willPresent', () => {
      this.overlayIsPresenting = true;
    });
  }

  private processValue = (value?: string | null) => {
    const valueToProcess = value || getToday();
    const { month, day, year, hour, minute, tzOffset } = parseDate(valueToProcess);

    this.workingParts = {
      month,
      day,
      year,
      hour,
      minute,
      tzOffset,
      ampm: hour >= 12 ? 'pm' : 'am'
    }

    this.activePartsClone = this.activeParts = {
      month,
      day,
      year,
      hour,
      minute,
      tzOffset,
      ampm: hour >= 12 ? 'pm' : 'am'
    }

  }

  componentWillLoad() {
    this.processValue(this.value);
    this.processMinParts();
    this.processMaxParts();
    this.parsedHourValues = convertToArrayOfNumbers(this.hourValues);
    this.parsedMinuteValues = convertToArrayOfNumbers(this.minuteValues);
    this.parsedMonthValues = convertToArrayOfNumbers(this.monthValues);
    this.parsedYearValues = convertToArrayOfNumbers(this.yearValues);
    this.parsedDayValues = convertToArrayOfNumbers(this.dayValues);
    this.emitStyle();
  }

  private emitStyle() {
    this.ionStyle.emit({
      'interactive': true,
      'datetime': true,
      'interactive-disabled': this.disabled,
    });
  }

  private onFocus = () => {
    this.ionFocus.emit();
  }

  private onBlur = () => {
    this.ionBlur.emit();
  }

  private hasValue = () => {
    return this.value != null && this.value !== '';
  }

  private nextMonth = () => {
    const { calendarBodyRef } = this;
    if (!calendarBodyRef) { return; }

    const nextMonth = calendarBodyRef.querySelector('.calendar-month:last-of-type');
    if (!nextMonth) { return; }

    calendarBodyRef.scrollTo({
      top: 0,
      left: (nextMonth as HTMLElement).offsetWidth * 2,
      behavior: 'smooth'
    });
  }

  private prevMonth = () => {
    const { calendarBodyRef } = this;
    if (!calendarBodyRef) { return; }

    const prevMonth = calendarBodyRef.querySelector('.calendar-month:first-of-type');
    if (!prevMonth) { return; }

    calendarBodyRef.scrollTo({
      top: 0,
      left: 0,
      behavior: 'smooth'
    });
  }

  private renderFooter() {
    const { showDefaultButtons, showClearButton } = this;
    const hasSlottedButtons = this.el.querySelector('[slot="buttons"]') !== null;
    if (!hasSlottedButtons && !showDefaultButtons && !showClearButton) { return; }

    const clearButtonClick = () => {
      this.reset();
      this.value = undefined;
    }

    /**
     * By default we render two buttons:
     * Cancel - Dismisses the datetime and
     * does not update the `value` prop.
     * OK - Dismisses the datetime and
     * updates the `value` prop.
     */
    return (
      <div class="datetime-footer">
        <div class="datetime-buttons">
          <div class={{
            ['datetime-action-buttons']: true,
            ['has-clear-button']: this.showClearButton
          }}>
            <slot name="buttons">
              <ion-buttons>
                {showDefaultButtons && <ion-button id="cancel-button" color={this.color} onClick={() => this.cancel(true)}>{this.cancelText}</ion-button>}
                <div>
                  {showClearButton && <ion-button id="clear-button" color={this.color} onClick={() => clearButtonClick()}>{this.clearText}</ion-button>}
                  {showDefaultButtons && <ion-button id="confirm-button" color={this.color} onClick={() => this.confirm(true)}>{this.doneText}</ion-button>}
                </div>
              </ion-buttons>
            </slot>
          </div>
        </div>
      </div>
    );
  }

  private toggleMonthAndYearView = () => {
    this.showMonthAndYear = !this.showMonthAndYear;
  }

  private renderYearView() {
    const { presentation, workingParts } = this;
    const calendarYears = getCalendarYears(this.todayParts, this.minParts, this.maxParts, this.parsedYearValues);
    const showMonth = presentation !== 'year';
    const showYear = presentation !== 'month';

    const months = getPickerMonths(this.locale, workingParts, this.minParts, this.maxParts, this.parsedMonthValues);
    const years = calendarYears.map(year => {
      return {
        text: `${year}`,
        value: year
      }
    })
    return (
      <div class="datetime-year">
        <div class="datetime-year-body">
          <ion-picker-internal>
            {
              showMonth &&
              <ion-picker-column-internal
                color={this.color}
                items={months}
                value={workingParts.month}
                onIonChange={(ev: CustomEvent) => {
                  this.setWorkingParts({
                    ...this.workingParts,
                    month: ev.detail.value
                  });

                  if (presentation === 'month' || presentation === 'month-year') {
                    this.setActiveParts({
                      ...this.activeParts,
                      month: ev.detail.value
                    });
                  }

                  ev.stopPropagation();
                }}
              ></ion-picker-column-internal>
            }
            {
              showYear &&
              <ion-picker-column-internal
                color={this.color}
                items={years}
                value={workingParts.year}
                onIonChange={(ev: CustomEvent) => {
                  this.setWorkingParts({
                    ...this.workingParts,
                    year: ev.detail.value
                  });

                  if (presentation === 'year' || presentation === 'month-year') {
                    this.setActiveParts({
                      ...this.activeParts,
                      year: ev.detail.value
                    });
                  }

                  ev.stopPropagation();
                }}
              ></ion-picker-column-internal>
            }
          </ion-picker-internal>
        </div>
      </div>
    );
  }

  private renderCalendarHeader(mode: Mode) {
    const expandedIcon = mode === 'ios' ? chevronDown : caretUpSharp;
    const collapsedIcon = mode === 'ios' ? chevronForward : caretDownSharp;
    return (
      <div class="calendar-header">
        <div class="calendar-action-buttons">
          <div class="calendar-month-year">
            <ion-item button detail={false} lines="none" onClick={() => this.toggleMonthAndYearView()}>
              <ion-label>
                {getMonthAndYear(this.locale, this.workingParts)} <ion-icon icon={this.showMonthAndYear ? expandedIcon : collapsedIcon} lazy={false}></ion-icon>
              </ion-label>
            </ion-item>
          </div>

          <div class="calendar-next-prev">
            <ion-buttons>
              <ion-button onClick={() => this.prevMonth()}>
                <ion-icon slot="icon-only" icon={chevronBack} lazy={false}></ion-icon>
              </ion-button>
              <ion-button onClick={() => this.nextMonth()}>
                <ion-icon slot="icon-only" icon={chevronForward} lazy={false}></ion-icon>
              </ion-button>
            </ion-buttons>
          </div>
        </div>
        <div class="calendar-days-of-week">
          {getDaysOfWeek(this.locale, mode, this.firstDayOfWeek % 7).map(d => {
            return <div class="day-of-week">{d}</div>
          })}
        </div>
      </div>
    )
  }

  private renderMonth(month: number, year: number) {
    const yearAllowed = this.parsedYearValues === undefined || this.parsedYearValues.includes(year);
    const monthAllowed = this.parsedMonthValues === undefined || this.parsedMonthValues.includes(month);
    const isMonthDisabled = !yearAllowed || !monthAllowed;
    return (
      <div class="calendar-month">
        <div class="calendar-month-grid">
          {getDaysOfMonth(month, year, this.firstDayOfWeek % 7).map((dateObject, index) => {
            const { day, dayOfWeek } = dateObject;
            const referenceParts = { month, day, year };
            const { isActive, isToday, ariaLabel, ariaSelected, disabled } = getCalendarDayState(this.locale, referenceParts, this.activePartsClone, this.todayParts, this.minParts, this.maxParts, this.parsedDayValues);

            return (
              <button
                tabindex="-1"
                data-day={day}
                data-month={month}
                data-year={year}
                data-index={index}
                data-day-of-week={dayOfWeek}
                disabled={isMonthDisabled || disabled}
                class={{
                  'calendar-day-padding': day === null,
                  'calendar-day': true,
                  'calendar-day-active': isActive,
                  'calendar-day-today': isToday
                }}
                aria-selected={ariaSelected}
                aria-label={ariaLabel}
                onClick={() => {
                  if (day === null) { return; }

                  this.setWorkingParts({
                    ...this.workingParts,
                    month,
                    day,
                    year
                  });

                  this.setActiveParts({
                    ...this.activeParts,
                    month,
                    day,
                    year
                  })
                }}
              >{day}</button>
            )
          })}
        </div>
      </div>
    )
  }

  private renderCalendarBody() {
    return (
      <div class="calendar-body ion-focusable" ref={el => this.calendarBodyRef = el} tabindex="0">
        {generateMonths(this.workingParts).map(({ month, year }) => {
          return this.renderMonth(month, year);
        })}
      </div>
    )
  }

  private renderCalendar(mode: Mode) {
    return (
      <div class="datetime-calendar">
        {this.renderCalendarHeader(mode)}
        {this.renderCalendarBody()}
      </div>
    )
  }

  private renderTimeLabel() {
    const hasSlottedTimeLabel = this.el.querySelector('[slot="time-label"]') !== null;
    if (!hasSlottedTimeLabel && !this.showDefaultTimeLabel) { return; }

    return (
      <slot name="time-label">Time</slot>
    );
  }

  private renderTimePicker(
    hoursItems: PickerColumnItem[],
    minutesItems: PickerColumnItem[],
    ampmItems: PickerColumnItem[],
    use24Hour: boolean
   ) {
    const { color, activePartsClone, workingParts } = this;

    return (
      <ion-picker-internal>
        <ion-picker-column-internal
          color={color}
          value={activePartsClone.hour}
          items={hoursItems}
          numericInput
          onIonChange={(ev: CustomEvent) => {
            this.setWorkingParts({
              ...workingParts,
              hour: ev.detail.value
            });
            this.setActiveParts({
              ...activePartsClone,
              hour: ev.detail.value
            });

            ev.stopPropagation();
          }}
        ></ion-picker-column-internal>
        <ion-picker-column-internal
          color={color}
          value={activePartsClone.minute}
          items={minutesItems}
          numericInput
          onIonChange={(ev: CustomEvent) => {
            this.setWorkingParts({
              ...workingParts,
              minute: ev.detail.value
            });
            this.setActiveParts({
              ...activePartsClone,
              minute: ev.detail.value
            });

            ev.stopPropagation();
          }}
        ></ion-picker-column-internal>
        { !use24Hour && <ion-picker-column-internal
          color={color}
          value={activePartsClone.ampm}
          items={ampmItems}
          onIonChange={(ev: CustomEvent) => {
            const hour = calculateHourFromAMPM(workingParts, ev.detail.value);

            this.setWorkingParts({
              ...workingParts,
              ampm: ev.detail.value,
              hour
            });

            this.setActiveParts({
              ...activePartsClone,
              ampm: ev.detail.value,
              hour
            });

            ev.stopPropagation();
          }}
        ></ion-picker-column-internal> }
      </ion-picker-internal>
    )
  }

  private renderTimeOverlay(
    hoursItems: PickerColumnItem[],
    minutesItems: PickerColumnItem[],
    ampmItems: PickerColumnItem[],
    use24Hour: boolean
   ) {
    return [
      <div class="time-header">
        {this.renderTimeLabel()}
      </div>,
      <button
        class={{
          'time-body': true,
          'time-body-active': this.isTimePopoverOpen
        }}
        aria-expanded="false"
        aria-haspopup="true"
        onClick={async ev => {
          const { popoverRef } = this;

          if (popoverRef) {

            this.isTimePopoverOpen = true;
            popoverRef.present(ev);

            await popoverRef.onWillDismiss();

            this.isTimePopoverOpen = false;
          }
        }}
      >
        {getFormattedTime(this.activePartsClone, use24Hour)}
      </button>,
      <ion-popover
        alignment="center"
        translucent
        overlayIndex={1}
        arrow={false}
        style={{
          '--offset-y': '-10px'
        }}
        // Allow native browser keyboard events to support up/down/home/end key
        // navigation within the time picker.
        keyboardEvents
        ref={el => this.popoverRef = el}
      >
        {this.renderTimePicker(hoursItems, minutesItems, ampmItems, use24Hour)}
      </ion-popover>
    ]
  }

  /**
   * Render time picker inside of datetime.
   * Do not pass color prop to segment on
   * iOS mode. MD segment has been customized and
   * should take on the color prop, but iOS
   * should just be the default segment.
   */
  private renderTime() {
    const { workingParts, presentation } = this;
    const timeOnlyPresentation = presentation === 'time';
    const use24Hour = is24Hour(this.locale, this.hourCycle);
    const { hours, minutes, am, pm } = generateTime(this.workingParts, use24Hour ? 'h23' : 'h12', this.minParts, this.maxParts, this.parsedHourValues, this.parsedMinuteValues);

    const hoursItems = hours.map(hour => {
      return {
        text: getFormattedHour(hour, use24Hour),
        value: getInternalHourValue(hour, use24Hour, workingParts.ampm)
      }
    });

    const minutesItems = minutes.map(minute => {
      return {
        text: addTimePadding(minute),
        value: minute
      }
    });

    const ampmItems = [];
    if (am) {
      ampmItems.push({
        text: 'AM',
        value: 'am'
      })
    }

    if (pm) {
      ampmItems.push({
        text: 'PM',
        value: 'pm'
      })
    }

    return (
      <div class="datetime-time">
          {timeOnlyPresentation ? this.renderTimePicker(hoursItems, minutesItems, ampmItems, use24Hour) : this.renderTimeOverlay(hoursItems, minutesItems, ampmItems, use24Hour)}
      </div>
    )
  }

  private renderCalendarViewHeader(mode: Mode) {
    const hasSlottedTitle = this.el.querySelector('[slot="title"]') !== null;
    if (!hasSlottedTitle && !this.showDefaultTitle) { return; }

    return (
      <div class="datetime-header">
        <div class="datetime-title">
          <slot name="title">Select Date</slot>
        </div>
        {mode === 'md' && <div class="datetime-selected-date">
          {getMonthAndDay(this.locale, this.activeParts)}
        </div>}
      </div>
    );
  }

  private renderDatetime(mode: Mode) {
    const { presentation } = this;
    switch (presentation) {
      case 'date-time':
        return [
          this.renderCalendarViewHeader(mode),
          this.renderCalendar(mode),
          this.renderYearView(),
          this.renderTime(),
          this.renderFooter()
        ]
      case 'time-date':
        return [
          this.renderCalendarViewHeader(mode),
          this.renderTime(),
          this.renderCalendar(mode),
          this.renderYearView(),
          this.renderFooter()
        ]
      case 'time':
        return [
          this.renderTime(),
          this.renderFooter()
        ]
      case 'month':
      case 'month-year':
      case 'year':
        return [
          this.renderYearView(),
          this.renderFooter()
        ]
      default:
        return [
          this.renderCalendarViewHeader(mode),
          this.renderCalendar(mode),
          this.renderYearView(),
          this.renderFooter()
        ]
    }
  }

  render() {
    const { name, value, disabled, el, color, isPresented, readonly, showMonthAndYear, presentation, size } = this;
    const mode = getIonMode(this);
    const isMonthAndYearPresentation = presentation === 'year' || presentation === 'month' || presentation === 'month-year';
    const shouldShowMonthAndYear = showMonthAndYear || isMonthAndYearPresentation;

    renderHiddenInput(true, el, name, value, disabled);

    return (
      <Host
        aria-disabled={disabled ? 'true' : null}
        onFocus={this.onFocus}
        onBlur={this.onBlur}
        class={{
          ...createColorClasses(color, {
            [mode]: true,
            ['datetime-presented']: isPresented,
            ['datetime-readonly']: readonly,
            ['datetime-disabled']: disabled,
            'show-month-and-year': shouldShowMonthAndYear,
            [`datetime-presentation-${presentation}`]: true,
            [`datetime-size-${size}`]: true
          })
        }}
      >
        {this.renderDatetime(mode)}
      </Host>
    );
  }
}

let datetimeIds = 0;
