const DAY_IN_SECONDS = 86400;
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

export enum DayOfTheWeek {
  MONDAY = 1,
  TUESDAY = 2,
  WEDNESDAY = 3,
  THURSDAY = 4,
  FRIDAY = 5,
  SATURDAY = 6,
  SUNDAY = 7,
}

function parseIsoString(isoString: string): { year: number; month: number; day: number } {
  if (!isoString.match(new RegExp(/^\d{4}-\d{2}-\d{2}$/))) {
    throw new Error(
      `CalendarDate Validation Error: Input ${isoString.toString()} is not valid, it should follow the pattern YYYY-MM-DD.`,
    );
  }
  const split = isoString.split('-');
  return {
    year: parseInt(split[0]),
    month: parseInt(split[1]),
    day: parseInt(split[2]),
  };
}

export class CalendarDate {
  readonly year: number;

  /**
   * Month of the year starting from 1
   */
  readonly month: number;

  /**
   * Day of the month starting from 1
   */
  readonly day: number;

  /**
   * Seconds passed since the unix epoch. Always calculated with a time and timezone set to T00:00:00.00Z.
   */
  readonly unixTimestampInSeconds: number;

  /**
   * Day of the week starting from monday according to ISO 8601. Values from 1-7.
   */
  readonly weekday: number;

  /**
   * cache for Intl.DateTimeFormat instances
   * @private
   */
  private static dateTimeFormatterByTimezone = new Map<string, Intl.DateTimeFormat>();

  /**
   * Customizes the default string description for instances of `CalendarDate`.
   */
  get [Symbol.toStringTag]() {
    return 'CalendarDate';
  }

  /**
   * Throws an Error for invalid inputs.
   *
   * @param isoString Format: yyyy-MM-dd
   */
  constructor(isoString: string);

  /**
   * Throws an Error for invalid inputs.
   *
   * @param year Integer between 1-9999, other inputs may lead to unstable behaviour
   * @param month Integer between 1-12
   * @param day Integer between 1-31
   */
  constructor(year: number, month: number, day: number);

  constructor(input1: string | number, input2?: number, input3?: number) {
    if (typeof input1 === 'string') {
      const result = parseIsoString(input1);
      this.year = result.year;
      this.month = result.month;
      this.day = result.day;
    } else if (
      typeof input1 === 'number' &&
      typeof input2 === 'number' &&
      typeof input3 === 'number'
    ) {
      this.year = input1;
      this.month = input2;
      this.day = input3;
    } else {
      const inputs = [input1, input2, input3].filter((input) => input !== undefined);
      throw new Error(
        `CalendarDate Validation Error: Input [ ${inputs.join(
          ' , ',
        )} ] of type [ ${inputs.map((input) => typeof input).join(' , ')} ] is not valid.`,
      );
    }

    if (this.year < 0 || this.year > 9999) {
      throw new Error(
        `CalendarDate Validation Error: Input year ${this.year} is not valid. Year must be a number between 0 and 9999.`,
      );
    }
    if (this.month < 1 || this.month > 12) {
      throw new Error(
        `CalendarDate Validation Error: Input month ${this.month} is not valid. Month must be a number between 1 and 12.`,
      );
    }
    const maxDayOfMonth = CalendarDate.getMaxDayOfMonth(this.year, this.month);
    if (this.day < 1) {
      throw new Error(
        `CalendarDate Validation Error: Input day ${this.day} is not valid. Day must be a number greater than 0.`,
      );
    }
    if (this.day > maxDayOfMonth) {
      throw new Error(
        `CalendarDate Validation Error: Input date ${this.year}-${this.month}-${this.day} is not a valid calendar date.`,
      );
    }

    const date = new Date(`${this.toString()}T00:00:00.000Z`);
    this.unixTimestampInSeconds = date.getTime() / 1000;
    this.weekday = date.getUTCDay() === 0 ? 7 : date.getUTCDay();
    Object.freeze(this);
  }

  public static isLeapYear(year: number): boolean {
    return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
  }

  public static getMaxDayOfMonth(year: number, month: number): number {
    return month === 2 && CalendarDate.isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
  }

  private static getIntlDateTimeFormatter(timeZone: string): Intl.DateTimeFormat {
    let formatter = CalendarDate.dateTimeFormatterByTimezone.get(timeZone);
    if (!formatter) {
      formatter = new Intl.DateTimeFormat('en-CA', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        timeZone,
      });
      CalendarDate.dateTimeFormatterByTimezone.set(timeZone, formatter);
    }
    return formatter;
  }

  /**
   * returns a CalendarDate instance for the supplied Date, using UTC values
   */
  static fromDateUTC(date: Date): CalendarDate {
    return new CalendarDate(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
  }

  /**
   * returns a CalendarDate instance for the supplied Date, using local time zone values
   */
  static fromDateLocal(date: Date): CalendarDate {
    return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
  }

  /**
   * returns a CalendarDate instance for the supplied Date, using the supplied time zone string
   */
  static fromDateWithTimeZone(date: Date, timeZone: string): CalendarDate {
    return new CalendarDate(
      CalendarDate.getIntlDateTimeFormatter(timeZone).format(date).padStart(10, '0'),
    );
  }

  /**
   * returns a CalendarDate instance for the current UTC Date
   */
  static nowUTC(): CalendarDate {
    return CalendarDate.fromDateUTC(new Date());
  }

  /**
   * returns a CalendarDate instance for the current Date using the local time zone of your environment
   */
  static nowLocal(): CalendarDate {
    return CalendarDate.fromDateLocal(new Date());
  }

  /**
   * returns a CalendarDate instance for the current Date using the supplied time zone string
   */
  static nowTimeZone(timeZone: string): CalendarDate {
    return CalendarDate.fromDateWithTimeZone(new Date(), timeZone);
  }

  /**
   *
   * @param isoString pattern YYYY-MM-DD
   */
  static parse(isoString: string): CalendarDate {
    const { year, month, day } = parseIsoString(isoString);
    return new CalendarDate(year, month, day);
  }

  static max(...values: CalendarDate[]): CalendarDate {
    if (!values.length) {
      throw new Error(
        'CalendarDate.max Validation Error: Function max requires at least one input argument.',
      );
    }
    return values.reduce(
      (maxValue, currentValue) => (currentValue > maxValue ? currentValue : maxValue),
      values[0],
    );
  }

  static min(...values: CalendarDate[]): CalendarDate {
    if (!values.length) {
      throw new Error(
        'CalendarDate.min Validation Error: Function min requires at least one input argument.',
      );
    }
    return values.reduce(
      (minValue, currentValue) => (currentValue < minValue ? currentValue : minValue),
      values[0],
    );
  }

  /**
   * Returns a copy of the supplied array of CalendarDates sorted ascending
   */
  static sortAscending(calendarDates: CalendarDate[]): CalendarDate[] {
    return [...calendarDates].sort((a, b) => a.valueOf() - b.valueOf());
  }

  /**
   * Returns a copy of the supplied array of CalendarDates sorted descending
   */
  static sortDescending(calendarDates: CalendarDate[]): CalendarDate[] {
    return [...calendarDates].sort((a, b) => b.valueOf() - a.valueOf());
  }

  /**
   * Returns the ISO string representation yyyy-MM-dd
   */
  toString(): string {
    return `${this.year.toString().padStart(4, '0')}-${this.month
      .toString()
      .padStart(2, '0')}-${this.day.toString().padStart(2, '0')}`;
  }

  /**
   * Returns a string representation formatted according to the specified format string.
   * Supports the following Tokens:
   *
   * - **yyyy**: four digit year
   * - **yy**: two digit year
   * - **y**: year without padding
   * - **MM**: month padded to 2 digits
   * - **M**: month without padding
   * - **dd**: day padded to 2 digits
   * - **d**: day without padding
   *
   */
  toFormat(pattern: string): string;

  /**
   * Returns a string representation formatted with the Intl.DateTimeFormat API.
   */
  toFormat(
    locales: string,
    options: Pick<Intl.DateTimeFormatOptions, 'year' | 'month' | 'day' | 'weekday'>,
  ): string;

  toFormat(
    input: string,
    options?: Pick<Intl.DateTimeFormatOptions, 'year' | 'month' | 'day' | 'weekday'>,
  ): string {
    if (options) {
      const formatter = new Intl.DateTimeFormat(input, options);
      return formatter.format(new Date(this.year, this.month - 1, this.day));
    } else {
      return input
        .replace(/yyyy/g, this.year.toString().padStart(4, '0'))
        .replace(/yy/g, this.year.toString().slice(-2).padStart(2, '0'))
        .replace(/y/g, this.year.toString())
        .replace(/MM/g, this.month.toString().padStart(2, '0'))
        .replace(/M/g, this.month.toString())
        .replace(/dd/g, this.day.toString().padStart(2, '0'))
        .replace(/d/g, this.day.toString());
    }
  }

  /**
   * Used by JSON stringify method.
   */
  toJSON(): string {
    return this.toString();
  }

  toDateUTC(): Date {
    return new Date(this.unixTimestampInSeconds * 1000);
  }

  /**
   * @deprecated use toDateUTC instead. This method will be removed in the next major version.
   */
  toDate(): Date {
    return this.toDateUTC();
  }

  toDateLocal(): Date {
    return new Date(this.year, this.month - 1, this.day);
  }

  /**
   * Returns the unix timestamp in seconds.
   */
  valueOf(): number {
    return this.unixTimestampInSeconds;
  }

  equals(calendarDate: CalendarDate): boolean {
    return this.valueOf() === calendarDate.valueOf();
  }

  /**
   * Checks if the month and year of this CalendarDate are the same as the month and year of the supplied CalendarDate.
   */
  isSameMonth(calendarDate: CalendarDate): boolean {
    return this.year === calendarDate.year && this.month === calendarDate.month;
  }

  /**
   * Checks if the year of this CalendarDate is the same as the year of the supplied CalendarDate.
   */
  isSameYear(calendarDate: CalendarDate): boolean {
    return this.year === calendarDate.year;
  }

  isBefore(calendarDate: CalendarDate): boolean {
    return this.valueOf() < calendarDate.valueOf();
  }

  isAfter(calendarDate: CalendarDate): boolean {
    return this.valueOf() > calendarDate.valueOf();
  }

  isBeforeOrEqual(calendarDate: CalendarDate): boolean {
    return this.valueOf() <= calendarDate.valueOf();
  }

  isAfterOrEqual(calendarDate: CalendarDate): boolean {
    return this.valueOf() >= calendarDate.valueOf();
  }

  /**
   * Returns a new CalendarDate with the specified amount of months added.
   *
   * @param amount
   * @param enforceEndOfMonth If set to true the addition will never cause an overflow to the next month.
   */
  addMonths(amount: number, enforceEndOfMonth = false): CalendarDate {
    const totalMonths = this.month + amount - 1;
    let year = this.year + Math.floor(totalMonths / 12);
    let month = totalMonths % 12;
    let day = this.day;
    if (month < 0) {
      month = (month + 12) % 12;
    }
    const maxDayOfMonth = CalendarDate.getMaxDayOfMonth(year, month + 1);
    if (enforceEndOfMonth) {
      day = Math.min(maxDayOfMonth, day);
    } else if (day > maxDayOfMonth) {
      day = day - maxDayOfMonth;
      year = year + Math.floor((month + 1) / 12);
      month = (month + 1) % 12;
    }
    return new CalendarDate(year, month + 1, day);
  }

  /**
   * Returns a new CalendarDate with the specified amount of days added.
   * Allows overflow.
   */
  addDays(amount: number): CalendarDate {
    return CalendarDate.parse(
      new Date((this.valueOf() + DAY_IN_SECONDS * amount) * 1000).toISOString().slice(0, 10),
    );
  }

  getLastDayOfMonth(): CalendarDate {
    return new CalendarDate(
      this.year,
      this.month,
      CalendarDate.getMaxDayOfMonth(this.year, this.month),
    );
  }

  getFirstDayOfMonth(): CalendarDate {
    return new CalendarDate(this.year, this.month, 1);
  }

  isFirstDayOfMonth(): boolean {
    return this.day === 1;
  }

  isLastDayOfMonth(): boolean {
    return this.day === CalendarDate.getMaxDayOfMonth(this.year, this.month);
  }

  /**
   * returns the last day (sunday) of the week of this calendar date as a new calendar date object.
   */
  getLastDayOfWeek(): CalendarDate {
    return this.addDays(7 - this.weekday);
  }

  /**
   * returns the first day (monday) of the week of this calendar date as a new calendar date object.
   */
  getFirstDayOfWeek(): CalendarDate {
    return this.addDays(-(this.weekday - 1));
  }

  /**
   * returns true if the weekday is monday(1)
   */
  isFirstDayOfWeek(): boolean {
    return this.weekday === 1;
  }

  /**
   * returns true if the weekday is sunday(7)
   */
  isLastDayOfWeek(): boolean {
    return this.weekday === 7;
  }

  /**
   * subtracts the input CalendarDate from this CalendarDate and returns the difference in days
   */
  getDifferenceInDays(calendarDate: CalendarDate, absolute?: boolean): number {
    const days = (this.valueOf() - calendarDate.valueOf()) / DAY_IN_SECONDS;
    return absolute ? Math.abs(days) : days;
  }

  /**
   * The number of the week of the year that the day is in. By definition (ISO 8601), the first week of a year contains January 4 of that year.
   * (The ISO-8601 week starts on Monday.) In other words, the first Thursday of a year is in week 1 of that year. (for timestamp values only).
   * [source: https://www.postgresql.org/docs/8.1/functions-datetime.html]
   */
  public get week(): number {
    return CalendarDate.getWeekNumber(this.year, this.month, this.day);
  }

  /**
   * Source: https://weeknumber.com/how-to/javascript
   */
  private static getWeekNumber(year: number, month: number, day: number): number {
    const date = new Date(year, month - 1, day);
    date.setHours(0, 0, 0, 0);
    // Thursday in current week decides the year.
    date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7));
    // January 4 is always in week 1.
    const week1 = new Date(date.getFullYear(), 0, 4);
    // Adjust to Thursday in week 1 and count number of weeks from date to week1.
    return (
      1 +
      Math.round(
        ((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7,
      )
    );
  }

  /**
   * The quarter of the year (1-4) based on the month of the date.
   *
   * Quarter breakdown:
   * - 1st Quarter: January to March (1-3)
   * - 2nd Quarter: April to June (4-6)
   * - 3rd Quarter: July to September (7-9)
   * - 4th Quarter: October to December (10-12)
   */
  public get quarter(): number {
    return Math.floor((this.month - 1) / 3) + 1;
  }
}
