/*
 * 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();
  }
}
