/*
 * 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 {AnyCalendarDate, AnyDateTime, AnyTime, CycleOptions, CycleTimeOptions, DateDuration, DateField, DateFields, DateTimeDuration, Disambiguation, TimeDuration, TimeField, TimeFields} from './types';
import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate';
import {epochFromDate, fromAbsolute, toAbsolute, toCalendar, toCalendarDateTime} from './conversion';
import {GregorianCalendar} from './calendars/GregorianCalendar';
import {Mutable} from './utils';

const ONE_HOUR = 3600000;

export function add(date: CalendarDateTime, duration: DateTimeDuration): CalendarDateTime;
export function add(date: CalendarDate, duration: DateDuration): CalendarDate;
export function add(date: CalendarDate | CalendarDateTime, duration: DateTimeDuration): CalendarDate | CalendarDateTime;
export function add(date: CalendarDate | CalendarDateTime, duration: DateTimeDuration) {
  let mutableDate: Mutable<AnyCalendarDate | AnyDateTime> = date.copy();
  let days = 'hour' in mutableDate ? addTimeFields(mutableDate, duration) : 0;

  addYears(mutableDate, duration.years || 0);
  if (mutableDate.calendar.balanceYearMonth) {
    mutableDate.calendar.balanceYearMonth(mutableDate, date);
  }

  mutableDate.month += duration.months || 0;

  balanceYearMonth(mutableDate);
  constrainMonthDay(mutableDate);

  mutableDate.day += (duration.weeks || 0) * 7;
  mutableDate.day += duration.days || 0;
  mutableDate.day += days;

  balanceDay(mutableDate);

  if (mutableDate.calendar.balanceDate) {
    mutableDate.calendar.balanceDate(mutableDate);
  }

  // Constrain in case adding ended up with a date outside the valid range for the calendar system.
  // The behavior here is slightly different than when constraining in the `set` function in that
  // we adjust smaller fields to their minimum/maximum values rather than constraining each field
  // individually. This matches the general behavior of `add` vs `set` regarding how fields are balanced.
  if (mutableDate.year < 1) {
    mutableDate.year = 1;
    mutableDate.month = 1;
    mutableDate.day = 1;
  }

  let maxYear = mutableDate.calendar.getYearsInEra(mutableDate);
  if (mutableDate.year > maxYear) {
    let isInverseEra = mutableDate.calendar.isInverseEra?.(mutableDate);
    mutableDate.year = maxYear;
    mutableDate.month = isInverseEra ? 1 : mutableDate.calendar.getMonthsInYear(mutableDate);
    mutableDate.day = isInverseEra ? 1 : mutableDate.calendar.getDaysInMonth(mutableDate);
  }

  if (mutableDate.month < 1) {
    mutableDate.month = 1;
    mutableDate.day = 1;
  }

  let maxMonth = mutableDate.calendar.getMonthsInYear(mutableDate);
  if (mutableDate.month > maxMonth) {
    mutableDate.month = maxMonth;
    mutableDate.day = mutableDate.calendar.getDaysInMonth(mutableDate);
  }

  mutableDate.day = Math.max(1, Math.min(mutableDate.calendar.getDaysInMonth(mutableDate), mutableDate.day));
  return mutableDate;
}

function addYears(date: Mutable<AnyCalendarDate>, years: number) {
  if (date.calendar.isInverseEra?.(date)) {
    years = -years;
  }

  date.year += years;
}

function balanceYearMonth(date: Mutable<AnyCalendarDate>) {
  while (date.month < 1) {
    addYears(date, -1);
    date.month += date.calendar.getMonthsInYear(date);
  }

  let monthsInYear = 0;
  while (date.month > (monthsInYear = date.calendar.getMonthsInYear(date))) {
    date.month -= monthsInYear;
    addYears(date, 1);
  }
}

function balanceDay(date: Mutable<AnyCalendarDate>) {
  while (date.day < 1) {
    date.month--;
    balanceYearMonth(date);
    date.day += date.calendar.getDaysInMonth(date);
  }

  while (date.day > date.calendar.getDaysInMonth(date)) {
    date.day -= date.calendar.getDaysInMonth(date);
    date.month++;
    balanceYearMonth(date);
  }
}

function constrainMonthDay(date: Mutable<AnyCalendarDate>) {
  date.month = Math.max(1, Math.min(date.calendar.getMonthsInYear(date), date.month));
  date.day = Math.max(1, Math.min(date.calendar.getDaysInMonth(date), date.day));
}

export function constrain(date: Mutable<AnyCalendarDate>) {
  if (date.calendar.constrainDate) {
    date.calendar.constrainDate(date);
  }

  date.year = Math.max(1, Math.min(date.calendar.getYearsInEra(date), date.year));
  constrainMonthDay(date);
}

export function invertDuration(duration: DateTimeDuration): DateTimeDuration {
  let inverseDuration = {};
  for (let key in duration) {
    if (typeof duration[key] === 'number') {
      inverseDuration[key] = -duration[key];
    }
  }

  return inverseDuration;
}

export function subtract(date: CalendarDateTime, duration: DateTimeDuration): CalendarDateTime;
export function subtract(date: CalendarDate, duration: DateDuration): CalendarDate;
export function subtract(date: CalendarDate | CalendarDateTime, duration: DateTimeDuration): CalendarDate | CalendarDateTime {
  return add(date, invertDuration(duration));
}

export function set(date: CalendarDateTime, fields: DateFields): CalendarDateTime;
export function set(date: CalendarDate, fields: DateFields): CalendarDate;
export function set(date: CalendarDate | CalendarDateTime, fields: DateFields) {
  let mutableDate: Mutable<AnyCalendarDate> = date.copy();

  if (fields.era != null) {
    mutableDate.era = fields.era;
  }

  if (fields.year != null) {
    mutableDate.year = fields.year;
  }

  if (fields.month != null) {
    mutableDate.month = fields.month;
  }

  if (fields.day != null) {
    mutableDate.day = fields.day;
  }

  constrain(mutableDate);
  return mutableDate;
}

export function setTime(value: CalendarDateTime, fields: TimeFields): CalendarDateTime;
export function setTime(value: Time, fields: TimeFields): Time;
export function setTime(value: Time | CalendarDateTime, fields: TimeFields) {
  let mutableValue: Mutable<Time | CalendarDateTime> = value.copy();

  if (fields.hour != null) {
    mutableValue.hour = fields.hour;
  }

  if (fields.minute != null) {
    mutableValue.minute = fields.minute;
  }

  if (fields.second != null) {
    mutableValue.second = fields.second;
  }

  if (fields.millisecond != null) {
    mutableValue.millisecond = fields.millisecond;
  }

  constrainTime(mutableValue);
  return mutableValue;
}

function balanceTime(time: Mutable<AnyTime>): number {
  time.second += Math.floor(time.millisecond / 1000);
  time.millisecond = nonNegativeMod(time.millisecond, 1000);

  time.minute += Math.floor(time.second / 60);
  time.second = nonNegativeMod(time.second, 60);

  time.hour += Math.floor(time.minute / 60);
  time.minute = nonNegativeMod(time.minute, 60);

  let days = Math.floor(time.hour / 24);
  time.hour = nonNegativeMod(time.hour, 24);

  return days;
}

export function constrainTime(time: Mutable<AnyTime>) {
  time.millisecond = Math.max(0, Math.min(time.millisecond, 1000));
  time.second = Math.max(0, Math.min(time.second, 59));
  time.minute = Math.max(0, Math.min(time.minute, 59));
  time.hour = Math.max(0, Math.min(time.hour, 23));
}

function nonNegativeMod(a: number, b: number) {
  let result = a % b;
  if (result < 0) {
    result += b;
  }
  return result;
}

function addTimeFields(time: Mutable<AnyTime>, duration: TimeDuration): number {
  time.hour += duration.hours || 0;
  time.minute += duration.minutes || 0;
  time.second += duration.seconds || 0;
  time.millisecond += duration.milliseconds || 0;
  return balanceTime(time);
}

export function addTime(time: Time, duration: TimeDuration): Time {
  let res = time.copy();
  addTimeFields(res, duration);
  return res;
}

export function subtractTime(time: Time, duration: TimeDuration): Time {
  return addTime(time, invertDuration(duration));
}

export function cycleDate(value: CalendarDateTime, field: DateField, amount: number, options?: CycleOptions): CalendarDateTime;
export function cycleDate(value: CalendarDate, field: DateField, amount: number, options?: CycleOptions): CalendarDate;
export function cycleDate(value: CalendarDate | CalendarDateTime, field: DateField, amount: number, options?: CycleOptions) {
  let mutable: Mutable<CalendarDate | CalendarDateTime> = value.copy();

  switch (field) {
    case 'era': {
      let eras = value.calendar.getEras();
      let eraIndex = eras.indexOf(value.era);
      if (eraIndex < 0) {
        throw new Error('Invalid era: ' + value.era);
      }
      eraIndex = cycleValue(eraIndex, amount, 0, eras.length - 1, options?.round);
      mutable.era = eras[eraIndex];

      // Constrain the year and other fields within the era, so the era doesn't change when we balance below.
      constrain(mutable);
      break;
    }
    case 'year': {
      if (mutable.calendar.isInverseEra?.(mutable)) {
        amount = -amount;
      }

      // The year field should not cycle within the era as that can cause weird behavior affecting other fields.
      // We need to also allow values < 1 so that decrementing goes to the previous era. If we get -Infinity back
      // we know we wrapped around after reaching 9999 (the maximum), so set the year back to 1.
      mutable.year = cycleValue(value.year, amount, -Infinity, 9999, options?.round);
      if (mutable.year === -Infinity) {
        mutable.year = 1;
      }

      if (mutable.calendar.balanceYearMonth) {
        mutable.calendar.balanceYearMonth(mutable, value);
      }
      break;
    }
    case 'month':
      mutable.month = cycleValue(value.month, amount, 1, value.calendar.getMonthsInYear(value), options?.round);
      break;
    case 'day':
      mutable.day = cycleValue(value.day, amount, 1, value.calendar.getDaysInMonth(value), options?.round);
      break;
    default:
      throw new Error('Unsupported field ' + field);
  }

  if (value.calendar.balanceDate) {
    value.calendar.balanceDate(mutable);
  }

  constrain(mutable);
  return mutable;
}

export function cycleTime(value: CalendarDateTime, field: TimeField, amount: number, options?: CycleTimeOptions): CalendarDateTime;
export function cycleTime(value: Time, field: TimeField, amount: number, options?: CycleTimeOptions): Time;
export function cycleTime(value: Time | CalendarDateTime, field: TimeField, amount: number, options?: CycleTimeOptions) {
  let mutable: Mutable<Time | CalendarDateTime> = value.copy();

  switch (field) {
    case 'hour': {
      let hours = value.hour;
      let min = 0;
      let max = 23;
      if (options?.hourCycle === 12) {
        let isPM = hours >= 12;
        min = isPM ? 12 : 0;
        max = isPM ? 23 : 11;
      }
      mutable.hour = cycleValue(hours, amount, min, max, options?.round);
      break;
    }
    case 'minute':
      mutable.minute = cycleValue(value.minute, amount, 0, 59, options?.round);
      break;
    case 'second':
      mutable.second = cycleValue(value.second, amount, 0, 59, options?.round);
      break;
    case 'millisecond':
      mutable.millisecond = cycleValue(value.millisecond, amount, 0, 999, options?.round);
      break;
    default:
      throw new Error('Unsupported field ' + field);
  }

  return mutable;
}

function cycleValue(value: number, amount: number, min: number, max: number, round = false) {
  if (round) {
    value += Math.sign(amount);

    if (value < min) {
      value = max;
    }

    let div = Math.abs(amount);
    if (amount > 0) {
      value = Math.ceil(value / div) * div;
    } else {
      value = Math.floor(value / div) * div;
    }

    if (value > max) {
      value = min;
    }
  } else {
    value += amount;
    if (value < min) {
      value = max - (min - value - 1);
    } else if (value > max) {
      value = min + (value - max - 1);
    }
  }

  return value;
}

export function addZoned(dateTime: ZonedDateTime, duration: DateTimeDuration): ZonedDateTime {
  let ms: number;
  if ((duration.years != null && duration.years !== 0) || (duration.months != null && duration.months !== 0) || (duration.days != null && duration.days !== 0)) {
    let res = add(toCalendarDateTime(dateTime), {
      years: duration.years,
      months: duration.months,
      days: duration.days
    });

    // Changing the date may change the timezone offset, so we need to recompute
    // using the 'compatible' disambiguation.
    ms = toAbsolute(res, dateTime.timeZone);
  } else {
    // Otherwise, preserve the offset of the original date.
    ms = epochFromDate(dateTime) - dateTime.offset;
  }

  // Perform time manipulation in milliseconds rather than on the original time fields to account for DST.
  // For example, adding one hour during a DST transition may result in the hour field staying the same or
  // skipping an hour. This results in the offset field changing value instead of the specified field.
  ms += duration.milliseconds || 0;
  ms += (duration.seconds || 0) * 1000;
  ms += (duration.minutes || 0) * 60 * 1000;
  ms += (duration.hours || 0) * 60 * 60 * 1000;

  let res = fromAbsolute(ms, dateTime.timeZone);
  return toCalendar(res, dateTime.calendar);
}

export function subtractZoned(dateTime: ZonedDateTime, duration: DateTimeDuration): ZonedDateTime {
  return addZoned(dateTime, invertDuration(duration));
}

export function cycleZoned(dateTime: ZonedDateTime, field: DateField | TimeField, amount: number, options?: CycleTimeOptions): ZonedDateTime {
  // For date fields, we want the time to remain consistent and the UTC offset to potentially change to account for DST changes.
  // For time fields, we want the time to change by the amount given. This may result in the hour field staying the same, but the UTC
  // offset changing in the case of a backward DST transition, or skipping an hour in the case of a forward DST transition.
  switch (field) {
    case 'hour': {
      let min = 0;
      let max = 23;
      if (options?.hourCycle === 12) {
        let isPM = dateTime.hour >= 12;
        min = isPM ? 12 : 0;
        max = isPM ? 23 : 11;
      }

      // The minimum and maximum hour may be affected by daylight saving time.
      // For example, it might jump forward at midnight, and skip 1am.
      // Or it might end at midnight and repeat the 11pm hour. To handle this, we get
      // the possible absolute times for the min and max, and find the maximum range
      // that is within the current day.
      let plainDateTime = toCalendarDateTime(dateTime);
      let minDate = toCalendar(setTime(plainDateTime, {hour: min}), new GregorianCalendar());
      let minAbsolute = [toAbsolute(minDate, dateTime.timeZone, 'earlier'), toAbsolute(minDate, dateTime.timeZone, 'later')]
        .filter(ms => fromAbsolute(ms, dateTime.timeZone).day === minDate.day)[0];

      let maxDate = toCalendar(setTime(plainDateTime, {hour: max}), new GregorianCalendar());
      let maxAbsolute = [toAbsolute(maxDate, dateTime.timeZone, 'earlier'), toAbsolute(maxDate, dateTime.timeZone, 'later')]
        .filter(ms => fromAbsolute(ms, dateTime.timeZone).day === maxDate.day).pop();

      // Since hours may repeat, we need to operate on the absolute time in milliseconds.
      // This is done in hours from the Unix epoch so that cycleValue works correctly,
      // and then converted back to milliseconds.
      let ms = epochFromDate(dateTime) - dateTime.offset;
      let hours = Math.floor(ms / ONE_HOUR);
      let remainder = ms % ONE_HOUR;
      ms = cycleValue(
        hours,
        amount,
        Math.floor(minAbsolute / ONE_HOUR),
        Math.floor(maxAbsolute / ONE_HOUR),
        options?.round
      ) * ONE_HOUR + remainder;

      // Now compute the new timezone offset, and convert the absolute time back to local time.
      return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
    }
    case 'minute':
    case 'second':
    case 'millisecond':
      // @ts-ignore
      return cycleTime(dateTime, field, amount, options);
    case 'era':
    case 'year':
    case 'month':
    case 'day': {
      let res = cycleDate(toCalendarDateTime(dateTime), field, amount, options);
      let ms = toAbsolute(res, dateTime.timeZone);
      return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
    }
    default:
      throw new Error('Unsupported field ' + field);
  }
}

export function setZoned(dateTime: ZonedDateTime, fields: DateFields & TimeFields, disambiguation?: Disambiguation): ZonedDateTime {
  // Set the date/time fields, and recompute the UTC offset to account for DST changes.
  // We also need to validate by converting back to a local time in case hours are skipped during forward DST transitions.
  let plainDateTime = toCalendarDateTime(dateTime);
  let res = setTime(set(plainDateTime, fields), fields);

  // If the resulting plain date time values are equal, return the original time.
  // We don't want to change the offset when setting the time to the same value.
  if (res.compare(plainDateTime) === 0) {
    return dateTime;
  }

  let ms = toAbsolute(res, dateTime.timeZone, disambiguation);
  return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
}
