import {
  DateMarker, addMs,
  diffHours, diffMinutes, diffSeconds, diffWholeWeeks, diffWholeDays,
  startOfDay, startOfHour, startOfMinute, startOfSecond,
  weekOfYear, arrayToUtcDate, dateToUtcArray, dateToLocalArray, arrayToLocalDate, timeAsMs, isValidDate,
} from './marker.js'
import { CalendarSystem, createCalendarSystem } from './calendar-system.js'
import { Locale } from './locale.js'
import { NamedTimeZoneImpl, NamedTimeZoneImplClass } from './timezone.js'
import { Duration, asRoughYears, asRoughMonths, asRoughDays, asRoughMs } from './duration.js'
import { DateFormatter, CmdFormatterFunc } from './DateFormatter.js'
import { buildIsoString } from './formatting-utils.js'
import { parse } from './parsing.js'
import { isInt } from '../util/misc.js'

export type WeekNumberCalculation = 'local' | 'ISO' | ((m: Date) => number)

export interface DateEnvSettings {
  timeZone: string
  namedTimeZoneImpl?: NamedTimeZoneImplClass
  calendarSystem: string
  locale: Locale
  weekNumberCalculation?: WeekNumberCalculation
  firstDay?: number, // will override what the locale wants
  weekText?: string,
  weekTextLong?: string
  cmdFormatter?: CmdFormatterFunc
  defaultSeparator?: string
}

export type DateInput = Date | string | number | number[]

export interface DateMarkerMeta {
  marker: DateMarker
  isTimeUnspecified: boolean
  forcedTzo: number | null
}

export class DateEnv {
  timeZone: string
  namedTimeZoneImpl: NamedTimeZoneImpl
  canComputeOffset: boolean

  calendarSystem: CalendarSystem
  locale: Locale
  weekDow: number // which day begins the week
  weekDoy: number // which day must be within the year, for computing the first week number
  weekNumberFunc: any
  weekText: string // DON'T LIKE how options are confused with local
  weekTextLong: string
  cmdFormatter?: CmdFormatterFunc
  defaultSeparator: string

  constructor(settings: DateEnvSettings) {
    let timeZone = this.timeZone = settings.timeZone
    let isNamedTimeZone = timeZone !== 'local' && timeZone !== 'UTC'

    if (settings.namedTimeZoneImpl && isNamedTimeZone) {
      this.namedTimeZoneImpl = new settings.namedTimeZoneImpl(timeZone)
    }

    this.canComputeOffset = Boolean(!isNamedTimeZone || this.namedTimeZoneImpl)

    this.calendarSystem = createCalendarSystem(settings.calendarSystem)
    this.locale = settings.locale
    this.weekDow = settings.locale.week.dow
    this.weekDoy = settings.locale.week.doy

    if (settings.weekNumberCalculation === 'ISO') {
      this.weekDow = 1
      this.weekDoy = 4
    }

    if (typeof settings.firstDay === 'number') {
      this.weekDow = settings.firstDay
    }

    if (typeof settings.weekNumberCalculation === 'function') {
      this.weekNumberFunc = settings.weekNumberCalculation
    }

    this.weekText = settings.weekText != null ? settings.weekText : settings.locale.options.weekText
    this.weekTextLong = (settings.weekTextLong != null ? settings.weekTextLong : settings.locale.options.weekTextLong) || this.weekText

    this.cmdFormatter = settings.cmdFormatter
    this.defaultSeparator = settings.defaultSeparator
  }

  // Creating / Parsing

  createMarker(input: DateInput): DateMarker {
    let meta = this.createMarkerMeta(input)
    if (meta === null) {
      return null
    }
    return meta.marker
  }

  createNowMarker(): DateMarker {
    if (this.canComputeOffset) {
      return this.timestampToMarker(new Date().valueOf())
    }
    // if we can't compute the current date val for a timezone,
    // better to give the current local date vals than UTC
    return arrayToUtcDate(dateToLocalArray(new Date()))
  }

  createMarkerMeta(input: DateInput): DateMarkerMeta {
    if (typeof input === 'string') {
      return this.parse(input)
    }

    let marker = null

    if (typeof input === 'number') {
      marker = this.timestampToMarker(input)
    } else if (input instanceof Date) {
      input = input.valueOf()

      if (!isNaN(input)) {
        marker = this.timestampToMarker(input)
      }
    } else if (Array.isArray(input)) {
      marker = arrayToUtcDate(input)
    }

    if (marker === null || !isValidDate(marker)) {
      return null
    }

    return { marker, isTimeUnspecified: false, forcedTzo: null }
  }

  parse(s: string) {
    let parts = parse(s)
    if (parts === null) {
      return null
    }

    let { marker } = parts
    let forcedTzo = null

    if (parts.timeZoneOffset !== null) {
      if (this.canComputeOffset) {
        marker = this.timestampToMarker(marker.valueOf() - parts.timeZoneOffset * 60 * 1000)
      } else {
        forcedTzo = parts.timeZoneOffset
      }
    }

    return { marker, isTimeUnspecified: parts.isTimeUnspecified, forcedTzo }
  }

  // Accessors

  getYear(marker: DateMarker): number {
    return this.calendarSystem.getMarkerYear(marker)
  }

  getMonth(marker: DateMarker): number {
    return this.calendarSystem.getMarkerMonth(marker)
  }

  getDay(marker: DateMarker): number {
    return this.calendarSystem.getMarkerDay(marker)
  }

  // Adding / Subtracting

  add(marker: DateMarker, dur: Duration): DateMarker {
    let a = this.calendarSystem.markerToArray(marker)
    a[0] += dur.years
    a[1] += dur.months
    a[2] += dur.days
    a[6] += dur.milliseconds
    return this.calendarSystem.arrayToMarker(a)
  }

  subtract(marker: DateMarker, dur: Duration): DateMarker {
    let a = this.calendarSystem.markerToArray(marker)
    a[0] -= dur.years
    a[1] -= dur.months
    a[2] -= dur.days
    a[6] -= dur.milliseconds
    return this.calendarSystem.arrayToMarker(a)
  }

  addYears(marker: DateMarker, n: number) {
    let a = this.calendarSystem.markerToArray(marker)
    a[0] += n
    return this.calendarSystem.arrayToMarker(a)
  }

  addMonths(marker: DateMarker, n: number) {
    let a = this.calendarSystem.markerToArray(marker)
    a[1] += n
    return this.calendarSystem.arrayToMarker(a)
  }

  // Diffing Whole Units

  diffWholeYears(m0: DateMarker, m1: DateMarker): number {
    let { calendarSystem } = this

    if (
      timeAsMs(m0) === timeAsMs(m1) &&
      calendarSystem.getMarkerDay(m0) === calendarSystem.getMarkerDay(m1) &&
      calendarSystem.getMarkerMonth(m0) === calendarSystem.getMarkerMonth(m1)
    ) {
      return calendarSystem.getMarkerYear(m1) - calendarSystem.getMarkerYear(m0)
    }
    return null
  }

  diffWholeMonths(m0: DateMarker, m1: DateMarker): number {
    let { calendarSystem } = this

    if (
      timeAsMs(m0) === timeAsMs(m1) &&
      calendarSystem.getMarkerDay(m0) === calendarSystem.getMarkerDay(m1)
    ) {
      return (calendarSystem.getMarkerMonth(m1) - calendarSystem.getMarkerMonth(m0)) +
          (calendarSystem.getMarkerYear(m1) - calendarSystem.getMarkerYear(m0)) * 12
    }
    return null
  }

  // Range / Duration

  greatestWholeUnit(m0: DateMarker, m1: DateMarker) {
    let n = this.diffWholeYears(m0, m1)

    if (n !== null) {
      return { unit: 'year', value: n }
    }

    n = this.diffWholeMonths(m0, m1)

    if (n !== null) {
      return { unit: 'month', value: n }
    }

    n = diffWholeWeeks(m0, m1)

    if (n !== null) {
      return { unit: 'week', value: n }
    }

    n = diffWholeDays(m0, m1)

    if (n !== null) {
      return { unit: 'day', value: n }
    }

    n = diffHours(m0, m1)

    if (isInt(n)) {
      return { unit: 'hour', value: n }
    }

    n = diffMinutes(m0, m1)

    if (isInt(n)) {
      return { unit: 'minute', value: n }
    }

    n = diffSeconds(m0, m1)

    if (isInt(n)) {
      return { unit: 'second', value: n }
    }

    return { unit: 'millisecond', value: m1.valueOf() - m0.valueOf() }
  }

  countDurationsBetween(m0: DateMarker, m1: DateMarker, d: Duration) {
    // TODO: can use greatestWholeUnit
    let diff

    if (d.years) {
      diff = this.diffWholeYears(m0, m1)
      if (diff !== null) {
        return diff / asRoughYears(d)
      }
    }

    if (d.months) {
      diff = this.diffWholeMonths(m0, m1)
      if (diff !== null) {
        return diff / asRoughMonths(d)
      }
    }

    if (d.days) {
      diff = diffWholeDays(m0, m1)
      if (diff !== null) {
        return diff / asRoughDays(d)
      }
    }

    return (m1.valueOf() - m0.valueOf()) / asRoughMs(d)
  }

  // Start-Of
  // these DON'T return zoned-dates. only UTC start-of dates

  startOf(m: DateMarker, unit: string) {
    if (unit === 'year') {
      return this.startOfYear(m)
    }
    if (unit === 'month') {
      return this.startOfMonth(m)
    }
    if (unit === 'week') {
      return this.startOfWeek(m)
    }
    if (unit === 'day') {
      return startOfDay(m)
    }
    if (unit === 'hour') {
      return startOfHour(m)
    }
    if (unit === 'minute') {
      return startOfMinute(m)
    }
    if (unit === 'second') {
      return startOfSecond(m)
    }
    return null
  }

  startOfYear(m: DateMarker): DateMarker {
    return this.calendarSystem.arrayToMarker([
      this.calendarSystem.getMarkerYear(m),
    ])
  }

  startOfMonth(m: DateMarker): DateMarker {
    return this.calendarSystem.arrayToMarker([
      this.calendarSystem.getMarkerYear(m),
      this.calendarSystem.getMarkerMonth(m),
    ])
  }

  startOfWeek(m: DateMarker): DateMarker {
    return this.calendarSystem.arrayToMarker([
      this.calendarSystem.getMarkerYear(m),
      this.calendarSystem.getMarkerMonth(m),
      m.getUTCDate() - ((m.getUTCDay() - this.weekDow + 7) % 7),
    ])
  }

  // Week Number

  computeWeekNumber(marker: DateMarker): number {
    if (this.weekNumberFunc) {
      return this.weekNumberFunc(this.toDate(marker))
    }
    return weekOfYear(marker, this.weekDow, this.weekDoy)
  }

  // TODO: choke on timeZoneName: long
  format(marker: DateMarker, formatter: DateFormatter, dateOptions: { forcedTzo?: number } = {}) {
    return formatter.format(
      {
        marker,
        timeZoneOffset: dateOptions.forcedTzo != null ?
          dateOptions.forcedTzo :
          this.offsetForMarker(marker),
      },
      this,
    )
  }

  formatRange(
    start: DateMarker,
    end: DateMarker,
    formatter: DateFormatter,
    dateOptions: { forcedStartTzo?: number, forcedEndTzo?: number, isEndExclusive?: boolean, defaultSeparator?: string } = {},
  ) {
    if (dateOptions.isEndExclusive) {
      end = addMs(end, -1)
    }

    return formatter.formatRange(
      {
        marker: start,
        timeZoneOffset: dateOptions.forcedStartTzo != null ?
          dateOptions.forcedStartTzo :
          this.offsetForMarker(start),
      },
      {
        marker: end,
        timeZoneOffset: dateOptions.forcedEndTzo != null ?
          dateOptions.forcedEndTzo :
          this.offsetForMarker(end),
      },
      this,
      dateOptions.defaultSeparator,
    )
  }

  /*
  DUMB: the omitTime arg is dumb. if we omit the time, we want to omit the timezone offset. and if we do that,
  might as well use buildIsoString or some other util directly
  */
  formatIso(marker: DateMarker, extraOptions: any = {}) {
    let timeZoneOffset = null

    if (!extraOptions.omitTimeZoneOffset) {
      if (extraOptions.forcedTzo != null) {
        timeZoneOffset = extraOptions.forcedTzo
      } else {
        timeZoneOffset = this.offsetForMarker(marker)
      }
    }

    return buildIsoString(marker, timeZoneOffset, extraOptions.omitTime)
  }

  // TimeZone

  timestampToMarker(ms: number) {
    if (this.timeZone === 'local') {
      return arrayToUtcDate(dateToLocalArray(new Date(ms)))
    } if (this.timeZone === 'UTC' || !this.namedTimeZoneImpl) {
      return new Date(ms)
    }
    return arrayToUtcDate(this.namedTimeZoneImpl.timestampToArray(ms))
  }

  offsetForMarker(m: DateMarker) {
    if (this.timeZone === 'local') {
      return -arrayToLocalDate(dateToUtcArray(m)).getTimezoneOffset() // convert "inverse" offset to "normal" offset
    } if (this.timeZone === 'UTC') {
      return 0
    } if (this.namedTimeZoneImpl) {
      return this.namedTimeZoneImpl.offsetForArray(dateToUtcArray(m))
    }
    return null
  }

  // Conversion

  toDate(m: DateMarker, forcedTzo?: number): Date {
    if (this.timeZone === 'local') {
      return arrayToLocalDate(dateToUtcArray(m))
    }

    if (this.timeZone === 'UTC') {
      return new Date(m.valueOf()) // make sure it's a copy
    }

    if (!this.namedTimeZoneImpl) {
      return new Date(m.valueOf() - (forcedTzo || 0))
    }

    return new Date(
      m.valueOf() -
        this.namedTimeZoneImpl.offsetForArray(dateToUtcArray(m)) * 1000 * 60, // convert minutes -> ms
    )
  }
}
