/*
 * 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 {AnyDateTime, DateTimeDuration, Disambiguation} from './types';
import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate';
import {epochFromDate, fromAbsolute, possibleAbsolutes, toAbsolute, toCalendar, toCalendarDateTime, toTimeZone} from './conversion';
import {getLocalTimeZone} from './queries';
import {GregorianCalendar} from './calendars/GregorianCalendar';
import {Mutable} from './utils';

const TIME_RE = /^(\d{2})(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
const DATE_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})$/;
const DATE_TIME_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
const ZONED_DATE_TIME_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:([+-]\d{2})(?::?(\d{2}))?)?\[(.*?)\]$/;
const ABSOLUTE_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:(?:([+-]\d{2})(?::?(\d{2}))?)|Z)$/;
const DATE_TIME_DURATION_RE =
    /^((?<negative>-)|\+)?P((?<years>\d*)Y)?((?<months>\d*)M)?((?<weeks>\d*)W)?((?<days>\d*)D)?((?<time>T)((?<hours>\d*[.,]?\d{1,9})H)?((?<minutes>\d*[.,]?\d{1,9})M)?((?<seconds>\d*[.,]?\d{1,9})S)?)?$/;
const requiredDurationTimeGroups = ['hours', 'minutes', 'seconds'];
const requiredDurationGroups = ['years', 'months', 'weeks', 'days', ...requiredDurationTimeGroups];

/** Parses an ISO 8601 time string. */
export function parseTime(value: string): Time {
  let m = value.match(TIME_RE);
  if (!m) {
    throw new Error('Invalid ISO 8601 time string: ' + value);
  }

  return new Time(
    parseNumber(m[1], 0, 23),
    m[2] ? parseNumber(m[2], 0, 59) : 0,
    m[3] ? parseNumber(m[3], 0, 59) : 0,
    m[4] ? parseNumber(m[4], 0, Infinity) * 1000 : 0
  );
}

/** Parses an ISO 8601 date string, with no time components. */
export function parseDate(value: string): CalendarDate {
  let m = value.match(DATE_RE);
  if (!m) {
    throw new Error('Invalid ISO 8601 date string: ' + value);
  }

  let date: Mutable<CalendarDate> = new CalendarDate(
    parseNumber(m[1], 0, 9999),
    parseNumber(m[2], 1, 12),
    1
  );

  date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
  return date as CalendarDate;
}

/** Parses an ISO 8601 date and time string, with no time zone. */
export function parseDateTime(value: string): CalendarDateTime {
  let m = value.match(DATE_TIME_RE);
  if (!m) {
    throw new Error('Invalid ISO 8601 date time string: ' + value);
  }

  let year = parseNumber(m[1], -9999, 9999);
  let era = year < 1 ? 'BC' : 'AD';

  let date: Mutable<CalendarDateTime> = new CalendarDateTime(
    era,
    year < 1 ? -year + 1 : year,
    parseNumber(m[2], 1, 12),
    1,
    m[4] ? parseNumber(m[4], 0, 23) : 0,
    m[5] ? parseNumber(m[5], 0, 59) : 0,
    m[6] ? parseNumber(m[6], 0, 59) : 0,
    m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
  );

  date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
  return date as CalendarDateTime;
}

/**
 * Parses an ISO 8601 date and time string with a time zone extension and optional UTC offset
 * (e.g. "2021-11-07T00:45[America/Los_Angeles]" or "2021-11-07T00:45-07:00[America/Los_Angeles]").
 * Ambiguous times due to daylight saving time transitions are resolved according to the `disambiguation`
 * parameter.
 */
export function parseZonedDateTime(value: string, disambiguation?: Disambiguation): ZonedDateTime {
  let m = value.match(ZONED_DATE_TIME_RE);
  if (!m) {
    throw new Error('Invalid ISO 8601 date time string: ' + value);
  }

  let year = parseNumber(m[1], -9999, 9999);
  let era = year < 1 ? 'BC' : 'AD';

  let date: Mutable<ZonedDateTime> = new ZonedDateTime(
    era,
    year < 1 ? -year + 1 : year,
    parseNumber(m[2], 1, 12),
    1,
    m[10],
    0,
    m[4] ? parseNumber(m[4], 0, 23) : 0,
    m[5] ? parseNumber(m[5], 0, 59) : 0,
    m[6] ? parseNumber(m[6], 0, 59) : 0,
    m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
  );

  date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));

  let plainDateTime = toCalendarDateTime(date as ZonedDateTime);

  let ms: number;
  if (m[8]) {
    date.offset = parseNumber(m[8], -23, 23) * 60 * 60 * 1000 + parseNumber(m[9] ?? '0', 0, 59) * 60 * 1000;
    ms = epochFromDate(date as ZonedDateTime) - date.offset;

    // Validate offset against parsed date.
    let absolutes = possibleAbsolutes(plainDateTime, date.timeZone);
    if (!absolutes.includes(ms)) {
      throw new Error(`Offset ${offsetToString(date.offset)} is invalid for ${dateTimeToString(date)} in ${date.timeZone}`);
    }
  } else {
    // Convert to absolute and back to fix invalid times due to DST.
    ms = toAbsolute(toCalendarDateTime(plainDateTime), date.timeZone, disambiguation);
  }

  return fromAbsolute(ms, date.timeZone);
}

/**
 * Parses an ISO 8601 date and time string with a UTC offset (e.g. "2021-11-07T07:45:00Z"
 * or "2021-11-07T07:45:00-07:00"). The result is converted to the provided time zone.
 */
export function parseAbsolute(value: string, timeZone: string): ZonedDateTime {
  let m = value.match(ABSOLUTE_RE);
  if (!m) {
    throw new Error('Invalid ISO 8601 date time string: ' + value);
  }

  let year = parseNumber(m[1], -9999, 9999);
  let era = year < 1 ? 'BC' : 'AD';

  let date: Mutable<ZonedDateTime> = new ZonedDateTime(
    era,
    year < 1 ? -year + 1 : year,
    parseNumber(m[2], 1, 12),
    1,
    timeZone,
    0,
    m[4] ? parseNumber(m[4], 0, 23) : 0,
    m[5] ? parseNumber(m[5], 0, 59) : 0,
    m[6] ? parseNumber(m[6], 0, 59) : 0,
    m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
  );

  date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));

  if (m[8]) {
    date.offset = parseNumber(m[8], -23, 23) * 60 * 60 * 1000 + parseNumber(m[9] ?? '0', 0, 59) * 60 * 1000;
  }

  return toTimeZone(date as ZonedDateTime, timeZone);
}

/**
 * Parses an ISO 8601 date and time string with a UTC offset (e.g. "2021-11-07T07:45:00Z"
 * or "2021-11-07T07:45:00-07:00"). The result is converted to the user's local time zone.
 */
export function parseAbsoluteToLocal(value: string): ZonedDateTime {
  return parseAbsolute(value, getLocalTimeZone());
}

function parseNumber(value: string, min: number, max: number) {
  let val = Number(value);
  if (val < min || val > max) {
    throw new RangeError(`Value out of range: ${min} <= ${val} <= ${max}`);
  }

  return val;
}

export function timeToString(time: Time): string {
  return `${String(time.hour).padStart(2, '0')}:${String(time.minute).padStart(2, '0')}:${String(time.second).padStart(2, '0')}${time.millisecond ? String(time.millisecond / 1000).slice(1) : ''}`;
}

export function dateToString(date: CalendarDate): string {
  let gregorianDate = toCalendar(date, new GregorianCalendar());
  let year: string;
  if (gregorianDate.era === 'BC') {
    year = gregorianDate.year === 1 
      ? '0000'
      : '-' + String(Math.abs(1 - gregorianDate.year)).padStart(6, '00');
  } else {
    year = String(gregorianDate.year).padStart(4, '0');
  }
  return `${year}-${String(gregorianDate.month).padStart(2, '0')}-${String(gregorianDate.day).padStart(2, '0')}`;
}

export function dateTimeToString(date: AnyDateTime): string {
  // @ts-ignore
  return `${dateToString(date)}T${timeToString(date)}`;
}

function offsetToString(offset: number) {
  let sign = Math.sign(offset) < 0 ? '-' : '+';
  offset = Math.abs(offset);
  let offsetHours = Math.floor(offset / (60 * 60 * 1000));
  let offsetMinutes = (offset % (60 * 60 * 1000)) / (60 * 1000);
  return `${sign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`;
}

export function zonedDateTimeToString(date: ZonedDateTime): string {
  return `${dateTimeToString(date)}${offsetToString(date.offset)}[${date.timeZone}]`;
}

/**
 * Parses an ISO 8601 duration string (e.g. "P3Y6M6W4DT12H30M5S").
 * @param value An ISO 8601 duration string.
 * @returns A DateTimeDuration object.
 */
export function parseDuration(value: string): Required<DateTimeDuration> {
  const match = value.match(DATE_TIME_DURATION_RE);

  if (!match) {
    throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
  }

  const parseDurationGroup = (
    group: string | undefined,
    isNegative: boolean
  ): number => {
    if (!group) {
      return 0;
    }
    try {
      const sign = isNegative ? -1 : 1;
      return sign * Number(group.replace(',', '.'));
    } catch {
      throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
    }
  };

  const isNegative = !!match.groups?.negative;

  const hasRequiredGroups = requiredDurationGroups.some(group => match.groups?.[group]);

  if (!hasRequiredGroups) {
    throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
  }

  const durationStringIncludesTime = match.groups?.time;

  if (durationStringIncludesTime) {
    const hasRequiredDurationTimeGroups = requiredDurationTimeGroups.some(group => match.groups?.[group]);
    if (!hasRequiredDurationTimeGroups) {
      throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
    }
  }

  const duration: Mutable<DateTimeDuration> = {
    years: parseDurationGroup(match.groups?.years, isNegative),
    months: parseDurationGroup(match.groups?.months, isNegative),
    weeks: parseDurationGroup(match.groups?.weeks, isNegative),
    days: parseDurationGroup(match.groups?.days, isNegative),
    hours: parseDurationGroup(match.groups?.hours, isNegative),
    minutes: parseDurationGroup(match.groups?.minutes, isNegative),
    seconds: parseDurationGroup(match.groups?.seconds, isNegative)
  };

  if (duration.hours !== undefined && ((duration.hours % 1) !== 0) && (duration.minutes || duration.seconds)) {
    throw new Error(`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`);
  }

  if (duration.minutes !== undefined && ((duration.minutes % 1) !== 0) && duration.seconds) {
    throw new Error(`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`);
  }

  return duration as Required<DateTimeDuration>;
}
