/*
 * 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 {add, addTime, addZoned, constrain, constrainTime, cycleDate, cycleTime, cycleZoned, set, setTime, setZoned, subtract, subtractTime, subtractZoned} from './manipulation';
import {AnyCalendarDate, AnyTime, Calendar, CycleOptions, CycleTimeOptions, DateDuration, DateField, DateFields, DateTimeDuration, Disambiguation, TimeDuration, TimeField, TimeFields} from './types';
import {compareDate, compareTime} from './queries';
import {dateTimeToString, dateToString, timeToString, zonedDateTimeToString} from './string';
import {GregorianCalendar} from './calendars/GregorianCalendar';
import {toCalendarDateTime, toDate, toZoned, zonedToDate} from './conversion';

export type DateValue = CalendarDate | CalendarDateTime | ZonedDateTime;

function shiftArgs(args: any[]) {
  let calendar: Calendar = typeof args[0] === 'object'
    ? args.shift()
    : new GregorianCalendar();

  let era: string;
  if (typeof args[0] === 'string') {
    era = args.shift();
  } else {
    let eras = calendar.getEras();
    era = eras[eras.length - 1];
  }

  let year = args.shift();
  let month = args.shift();
  let day = args.shift();

  return [calendar, era, year, month, day];
}

/** A CalendarDate represents a date without any time components in a specific calendar system. */
export class CalendarDate {
  // This prevents TypeScript from allowing other types with the same fields to match.
  // i.e. a ZonedDateTime should not be be passable to a parameter that expects CalendarDate.
  // If that behavior is desired, use the AnyCalendarDate interface instead.
  // @ts-ignore
  #type;
  /** The calendar system associated with this date, e.g. Gregorian. */
  public readonly calendar: Calendar;
  /** The calendar era for this date, e.g. "BC" or "AD". */
  public readonly era: string;
  /** The year of this date within the era. */
  public readonly year: number;
  /**
   * The month number within the year. Note that some calendar systems such as Hebrew
   * may have a variable number of months per year. Therefore, month numbers may not
   * always correspond to the same month names in different years.
   */
  public readonly month: number;
  /** The day number within the month. */
  public readonly day: number;

  constructor(year: number, month: number, day: number);
  constructor(era: string, year: number, month: number, day: number);
  constructor(calendar: Calendar, year: number, month: number, day: number);
  constructor(calendar: Calendar, era: string, year: number, month: number, day: number);
  constructor(...args: any[]) {
    let [calendar, era, year, month, day] = shiftArgs(args);
    this.calendar = calendar;
    this.era = era;
    this.year = year;
    this.month = month;
    this.day = day;

    constrain(this);
  }

  /** Returns a copy of this date. */
  copy(): CalendarDate {
    if (this.era) {
      return new CalendarDate(this.calendar, this.era, this.year, this.month, this.day);
    } else {
      return new CalendarDate(this.calendar, this.year, this.month, this.day);
    }
  }

  /** Returns a new `CalendarDate` with the given duration added to it. */
  add(duration: DateDuration): CalendarDate {
    return add(this, duration);
  }

  /** Returns a new `CalendarDate` with the given duration subtracted from it. */
  subtract(duration: DateDuration): CalendarDate {
    return subtract(this, duration);
  }

  /** Returns a new `CalendarDate` with the given fields set to the provided values. Other fields will be constrained accordingly. */
  set(fields: DateFields): CalendarDate {
    return set(this, fields);
  }

  /**
   * Returns a new `CalendarDate` with the given field adjusted by a specified amount.
   * When the resulting value reaches the limits of the field, it wraps around.
   */
  cycle(field: DateField, amount: number, options?: CycleOptions): CalendarDate {
    return cycleDate(this, field, amount, options);
  }

  /** Converts the date to a native JavaScript Date object, with the time set to midnight in the given time zone. */
  toDate(timeZone: string): Date {
    return toDate(this, timeZone);
  }

  /** Converts the date to an ISO 8601 formatted string. */
  toString(): string {
    return dateToString(this);
  }

  /** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
  compare(b: AnyCalendarDate): number {
    return compareDate(this, b);
  }
}

/** A Time represents a clock time without any date components. */
export class Time {
  // This prevents TypeScript from allowing other types with the same fields to match.
  // @ts-ignore
  #type;
  /** The hour, numbered from 0 to 23. */
  public readonly hour: number;
  /** The minute in the hour. */
  public readonly minute: number;
  /** The second in the minute. */
  public readonly second: number;
  /** The millisecond in the second. */
  public readonly millisecond: number;

  constructor(
    hour: number = 0,
    minute: number = 0,
    second: number = 0,
    millisecond: number = 0
  ) {
    this.hour = hour;
    this.minute = minute;
    this.second = second;
    this.millisecond = millisecond;
    constrainTime(this);
  }

  /** Returns a copy of this time. */
  copy(): Time {
    return new Time(this.hour, this.minute, this.second, this.millisecond);
  }

  /** Returns a new `Time` with the given duration added to it. */
  add(duration: TimeDuration): Time {
    return addTime(this, duration);
  }

  /** Returns a new `Time` with the given duration subtracted from it. */
  subtract(duration: TimeDuration): Time {
    return subtractTime(this, duration);
  }

  /** Returns a new `Time` with the given fields set to the provided values. Other fields will be constrained accordingly. */
  set(fields: TimeFields): Time {
    return setTime(this, fields);
  }

  /**
   * Returns a new `Time` with the given field adjusted by a specified amount.
   * When the resulting value reaches the limits of the field, it wraps around.
   */
  cycle(field: TimeField, amount: number, options?: CycleTimeOptions): Time {
    return cycleTime(this, field, amount, options);
  }

  /** Converts the time to an ISO 8601 formatted string. */
  toString(): string {
    return timeToString(this);
  }

  /** Compares this time with another. A negative result indicates that this time is before the given one, and a positive time indicates that it is after. */
  compare(b: AnyTime): number {
    return compareTime(this, b);
  }
}

/** A CalendarDateTime represents a date and time without a time zone, in a specific calendar system. */
export class CalendarDateTime {
  // This prevents TypeScript from allowing other types with the same fields to match.
  // @ts-ignore
  #type;
  /** The calendar system associated with this date, e.g. Gregorian. */
  public readonly calendar: Calendar;
  /** The calendar era for this date, e.g. "BC" or "AD". */
  public readonly era: string;
  /** The year of this date within the era. */
  public readonly year: number;
  /**
   * The month number within the year. Note that some calendar systems such as Hebrew
   * may have a variable number of months per year. Therefore, month numbers may not
   * always correspond to the same month names in different years.
   */
  public readonly month: number;
  /** The day number within the month. */
  public readonly day: number;
  /** The hour in the day, numbered from 0 to 23. */
  public readonly hour: number;
  /** The minute in the hour. */
  public readonly minute: number;
  /** The second in the minute. */
  public readonly second: number;
  /** The millisecond in the second. */
  public readonly millisecond: number;

  constructor(year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
  constructor(era: string, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
  constructor(calendar: Calendar, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
  constructor(calendar: Calendar, era: string, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
  constructor(...args: any[]) {
    let [calendar, era, year, month, day] = shiftArgs(args);
    this.calendar = calendar;
    this.era = era;
    this.year = year;
    this.month = month;
    this.day = day;
    this.hour = args.shift() || 0;
    this.minute = args.shift() || 0;
    this.second = args.shift() || 0;
    this.millisecond = args.shift() || 0;

    constrain(this);
  }

  /** Returns a copy of this date. */
  copy(): CalendarDateTime {
    if (this.era) {
      return new CalendarDateTime(this.calendar, this.era, this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond);
    } else {
      return new CalendarDateTime(this.calendar, this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond);
    }
  }

  /** Returns a new `CalendarDateTime` with the given duration added to it. */
  add(duration: DateTimeDuration): CalendarDateTime {
    return add(this, duration);
  }

  /** Returns a new `CalendarDateTime` with the given duration subtracted from it. */
  subtract(duration: DateTimeDuration): CalendarDateTime {
    return subtract(this, duration);
  }

  /** Returns a new `CalendarDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */
  set(fields: DateFields & TimeFields): CalendarDateTime {
    return set(setTime(this, fields), fields);
  }

  /**
   * Returns a new `CalendarDateTime` with the given field adjusted by a specified amount.
   * When the resulting value reaches the limits of the field, it wraps around.
   */
  cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): CalendarDateTime {
    switch (field) {
      case 'era':
      case 'year':
      case 'month':
      case 'day':
        return cycleDate(this, field, amount, options);
      default:
        return cycleTime(this, field, amount, options);
    }
  }

  /** Converts the date to a native JavaScript Date object in the given time zone. */
  toDate(timeZone: string, disambiguation?: Disambiguation): Date {
    return toDate(this, timeZone, disambiguation);
  }

  /** Converts the date to an ISO 8601 formatted string. */
  toString(): string {
    return dateTimeToString(this);
  }

  /** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
  compare(b: CalendarDate | CalendarDateTime | ZonedDateTime): number {
    let res = compareDate(this, b);
    if (res === 0) {
      return compareTime(this, toCalendarDateTime(b));
    }

    return res;
  }
}

/** A ZonedDateTime represents a date and time in a specific time zone and calendar system. */
export class ZonedDateTime {
  // This prevents TypeScript from allowing other types with the same fields to match.
  // @ts-ignore
  #type;
  /** The calendar system associated with this date, e.g. Gregorian. */
  public readonly calendar: Calendar;
  /** The calendar era for this date, e.g. "BC" or "AD". */
  public readonly era: string;
  /** The year of this date within the era. */
  public readonly year: number;
  /**
   * The month number within the year. Note that some calendar systems such as Hebrew
   * may have a variable number of months per year. Therefore, month numbers may not
   * always correspond to the same month names in different years.
   */
  public readonly month: number;
  /** The day number within the month. */
  public readonly day: number;
  /** The hour in the day, numbered from 0 to 23. */
  public readonly hour: number;
  /** The minute in the hour. */
  public readonly minute: number;
  /** The second in the minute. */
  public readonly second: number;
  /** The millisecond in the second. */
  public readonly millisecond: number;
  /** The IANA time zone identifier that this date and time is represented in. */
  public readonly timeZone: string;
  /** The UTC offset for this time, in milliseconds. */
  public readonly offset: number;

  constructor(year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number);
  constructor(era: string, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number);
  constructor(calendar: Calendar, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number);
  constructor(calendar: Calendar, era: string, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number);
  constructor(...args: any[]) {
    let [calendar, era, year, month, day] = shiftArgs(args);
    let timeZone = args.shift();
    let offset = args.shift();
    this.calendar = calendar;
    this.era = era;
    this.year = year;
    this.month = month;
    this.day = day;
    this.timeZone = timeZone;
    this.offset = offset;
    this.hour = args.shift() || 0;
    this.minute = args.shift() || 0;
    this.second = args.shift() || 0;
    this.millisecond = args.shift() || 0;

    constrain(this);
  }

  /** Returns a copy of this date. */
  copy(): ZonedDateTime {
    if (this.era) {
      return new ZonedDateTime(this.calendar, this.era, this.year, this.month, this.day, this.timeZone, this.offset, this.hour, this.minute, this.second, this.millisecond);
    } else {
      return new ZonedDateTime(this.calendar, this.year, this.month, this.day, this.timeZone, this.offset, this.hour, this.minute, this.second, this.millisecond);
    }
  }

  /** Returns a new `ZonedDateTime` with the given duration added to it. */
  add(duration: DateTimeDuration): ZonedDateTime {
    return addZoned(this, duration);
  }

  /** Returns a new `ZonedDateTime` with the given duration subtracted from it. */
  subtract(duration: DateTimeDuration): ZonedDateTime {
    return subtractZoned(this, duration);
  }

  /** Returns a new `ZonedDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */
  set(fields: DateFields & TimeFields, disambiguation?: Disambiguation): ZonedDateTime {
    return setZoned(this, fields, disambiguation);
  }

  /**
   * Returns a new `ZonedDateTime` with the given field adjusted by a specified amount.
   * When the resulting value reaches the limits of the field, it wraps around.
   */
  cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): ZonedDateTime {
    return cycleZoned(this, field, amount, options);
  }

  /** Converts the date to a native JavaScript Date object. */
  toDate(): Date {
    return zonedToDate(this);
  }

   /** Converts the date to an ISO 8601 formatted string, including the UTC offset and time zone identifier. */
  toString(): string {
    return zonedDateTimeToString(this);
  }

   /** Converts the date to an ISO 8601 formatted string in UTC. */
  toAbsoluteString(): string {
    return this.toDate().toISOString();
  }

  /** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
  compare(b: CalendarDate | CalendarDateTime | ZonedDateTime): number {
    // TODO: Is this a bad idea??
    return this.toDate().getTime() - toZoned(b, this.timeZone).toDate().getTime();
  }
}
