/*
 * Copyright 2020 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {alignCenter, constrainValue, isInvalid, previousAvailableDate} from './utils';
import {Calendar, CalendarDate, CalendarIdentifier, DateDuration, GregorianCalendar, isEqualDay, maxDate, minDate, toCalendar, toCalendarDate} from '@internationalized/date';
import {CalendarState, RangeCalendarState} from './types';
import {DateValue, MappedDateValue, RangeCalendarProps} from '@react-types/calendar';
import {RangeValue, ValidationState} from '@react-types/shared';
import {useCalendarState} from './useCalendarState';
import {useControlledState} from '@react-stately/utils';
import {useMemo, useRef, useState} from 'react';

export interface RangeCalendarStateOptions<T extends DateValue = DateValue> extends RangeCalendarProps<T> {
  /** The locale to display and edit the value according to. */
  locale: string,
  /**
   * A function that creates a [Calendar](../internationalized/date/Calendar.html)
   * object for a given calendar identifier. Such a function may be imported from the
   * `@internationalized/date` package, or manually implemented to include support for
   * only certain calendars.
   */
  createCalendar: (name: CalendarIdentifier) => Calendar,
  /**
   * The amount of days that will be displayed at once. This affects how pagination works.
   * @default {months: 1}
   */
  visibleDuration?: DateDuration,
  /** 
   * Determines the alignment of the visible months on initial render based on the current selection or current date if there is no selection. 
   * @default 'center'
   */
  selectionAlignment?: 'start' | 'center' | 'end'
}

/**
 * Provides state management for a range calendar component.
 * A range calendar displays one or more date grids and allows users to select a contiguous range of dates.
 */
export function useRangeCalendarState<T extends DateValue = DateValue>(props: RangeCalendarStateOptions<T>): RangeCalendarState<T> {
  let {
    value: valueProp,
    defaultValue,
    onChange,
    createCalendar,
    locale,
    visibleDuration = {months: 1},
    minValue,
    maxValue,
    ...calendarProps} = props;
  let [value, setValue] = useControlledState<RangeValue<T> | null, RangeValue<MappedDateValue<T>>>(
    valueProp!,
    defaultValue || null!,
    onChange
  );

  let [anchorDate, setAnchorDateState] = useState<CalendarDate | null>(null);
  let alignment: 'center' | 'start' = 'center';
  if (value && value.start && value.end) {
    let start = alignCenter(toCalendarDate(value.start), visibleDuration, locale, minValue, maxValue);
    let end = start.add(visibleDuration).subtract({days: 1});

    if (value.end.compare(end) > 0) {
      alignment = 'start';
    }
  }

  // Available range must be stored in a ref so we have access to the updated version immediately in `isInvalid`.
  let availableRangeRef = useRef<Partial<RangeValue<DateValue>> | null>(null);
  let [availableRange, setAvailableRange] = useState<Partial<RangeValue<DateValue>>|null>(null);
  let min = useMemo(() => maxDate(minValue, availableRange?.start), [minValue, availableRange]);
  let max = useMemo(() => minDate(maxValue, availableRange?.end), [maxValue, availableRange]);

  let calendar = useCalendarState({
    ...calendarProps,
    value: value && value.start,
    createCalendar,
    locale,
    visibleDuration,
    minValue: min,
    maxValue: max,
    selectionAlignment: props.selectionAlignment || alignment
  });

  let updateAvailableRange = (date) => {
    if (date && props.isDateUnavailable && !props.allowsNonContiguousRanges) {
      const nextAvailableStartDate = nextUnavailableDate(date, calendar, -1);
      const nextAvailableEndDate = nextUnavailableDate(date, calendar, 1);
      availableRangeRef.current = {
        start: nextAvailableStartDate,
        end: nextAvailableEndDate
      };
      setAvailableRange(availableRangeRef.current);
    } else {
      availableRangeRef.current = null;
      setAvailableRange(null);
    }
  };

  // If the visible range changes, we need to update the available range.
  let [lastVisibleRange, setLastVisibleRange] = useState(calendar.visibleRange);
  if (!isEqualDay(calendar.visibleRange.start, lastVisibleRange.start) || !isEqualDay(calendar.visibleRange.end, lastVisibleRange.end)) {
    updateAvailableRange(anchorDate);
    setLastVisibleRange(calendar.visibleRange);
  }

  let setAnchorDate = (date: CalendarDate | null) => {
    if (date) {
      setAnchorDateState(date);
      updateAvailableRange(date);
    } else {
      setAnchorDateState(null);
      updateAvailableRange(null);
    }
  };

  let highlightedRange = anchorDate ? makeRange(anchorDate, calendar.focusedDate) : value && makeRange(value.start, value.end);
  let selectDate = (date: CalendarDate) => {
    if (props.isReadOnly) {
      return;
    }

    const constrainedDate = constrainValue(date, min, max);
    const previousAvailableConstrainedDate = previousAvailableDate(constrainedDate, calendar.visibleRange.start, props.isDateUnavailable);
    if (!previousAvailableConstrainedDate) {
      return;
    }

    if (!anchorDate) {
      setAnchorDate(previousAvailableConstrainedDate);
    } else {
      let range = makeRange(anchorDate, previousAvailableConstrainedDate);
      if (range) {
        setValue({
          start: convertValue(range.start, value?.start) as T,
          end: convertValue(range.end, value?.end) as T
        });
      }
      setAnchorDate(null);
    }
  };

  let [isDragging, setDragging] = useState(false);

  let {isDateUnavailable} = props;
  let isInvalidSelection = useMemo(() => {
    if (!value || anchorDate) {
      return false;
    }

    if (isDateUnavailable && (isDateUnavailable(value.start) || isDateUnavailable(value.end))) {
      return true;
    }

    return isInvalid(value.start, minValue, maxValue) || isInvalid(value.end, minValue, maxValue);
  }, [isDateUnavailable, value, anchorDate, minValue, maxValue]);

  let isValueInvalid = props.isInvalid || props.validationState === 'invalid' || isInvalidSelection;
  let validationState: ValidationState | null = isValueInvalid ? 'invalid' : null;

  return {
    ...calendar,
    value,
    setValue,
    anchorDate,
    setAnchorDate,
    highlightedRange,
    validationState,
    isValueInvalid,
    selectFocusedDate() {
      selectDate(calendar.focusedDate);
    },
    selectDate,
    highlightDate(date) {
      if (anchorDate) {
        calendar.setFocusedDate(date);
      }
    },
    isSelected(date) {
      return Boolean(highlightedRange && date.compare(highlightedRange.start) >= 0 && date.compare(highlightedRange.end) <= 0 && !calendar.isCellDisabled(date) && !calendar.isCellUnavailable(date));
    },
    isInvalid(date) {
      return calendar.isInvalid(date) || isInvalid(date, availableRangeRef.current?.start, availableRangeRef.current?.end);
    },
    isDragging,
    setDragging
  };
}

function makeRange(start: DateValue, end: DateValue): RangeValue<CalendarDate> | null {
  if (!start || !end) {
    return null;
  }

  if (end.compare(start) < 0) {
    [start, end] = [end, start];
  }

  return {start: toCalendarDate(start), end: toCalendarDate(end)};
}

function convertValue(newValue: CalendarDate, oldValue?: DateValue): DateValue {
  // The display calendar should not have any effect on the emitted value.
  // Emit dates in the same calendar as the original value, if any, otherwise gregorian.
  newValue = toCalendar(newValue, oldValue?.calendar || new GregorianCalendar());

  // Preserve time if the input value had one.
  if (oldValue && 'hour' in oldValue) {
    return oldValue.set(newValue);
  }

  return newValue;
}

function nextUnavailableDate(anchorDate: CalendarDate, state: CalendarState, dir: number): CalendarDate | undefined {
  let nextDate = anchorDate.add({days: dir});
  while (
    (dir < 0 ? nextDate.compare(state.visibleRange.start) >= 0 : nextDate.compare(state.visibleRange.end) <= 0) &&
    !state.isCellUnavailable(nextDate)
  ) {
    nextDate = nextDate.add({days: dir});
  }

  if (state.isCellUnavailable(nextDate)) {
    return nextDate.add({days: -dir});
  }

}
