export type ScheduleDay =
  | 'monday'
  | 'tuesday'
  | 'wednesday'
  | 'thursday'
  | 'friday'
  | 'saturday'
  | 'sunday'

export type ScheduleFrequency =
  | 'daily'
  | 'weekly'
  | 'biweekly'
  | 'monthlyByDate'
  | 'monthlyByWeekday'

export type WeekOfMonth = 'first' | 'second' | 'third' | 'fourth' | 'fifth' | 'last'

export interface ScheduleEntry {
  frequency: ScheduleFrequency
  day?: ScheduleDay
  weekOfMonth?: WeekOfMonth | null
  biweeklyAnchor?: string | Date | null
  dayOfMonth?: number | null
}

// JS getDay(): 0 = Sunday ... 6 = Saturday
const DAY_INDEX: Record<ScheduleDay, number> = {
  sunday: 0,
  monday: 1,
  tuesday: 2,
  wednesday: 3,
  thursday: 4,
  friday: 5,
  saturday: 6,
}

const WEEK_OF_MONTH_N: Record<WeekOfMonth, number> = {
  first: 1,
  second: 2,
  third: 3,
  fourth: 4,
  fifth: 5,
  last: -1,
}

const MS_PER_DAY = 24 * 60 * 60 * 1000

const startOfDay = (d: Date): Date => {
  const r = new Date(d)
  r.setHours(0, 0, 0, 0)
  return r
}

const addDays = (d: Date, n: number): Date => {
  const r = new Date(d)
  r.setDate(r.getDate() + n)
  return r
}

const firstOccurrenceOnOrAfter = (from: Date, targetDay: number): Date => {
  const start = startOfDay(from)
  const diff = (targetDay - start.getDay() + 7) % 7
  return addDays(start, diff)
}

/**
 * For a given (year, month), return the date of the Nth occurrence of
 * `targetDay`. `n` is 1..5 for first..fifth, or -1 for "last".
 * Returns null if the month has no such occurrence (e.g. asking for the
 * 5th Monday in a month that only has 4).
 */
const nthWeekdayOfMonth = (
  year: number,
  month: number,
  targetDay: number,
  n: number,
): Date | null => {
  if (n > 0) {
    const first = new Date(year, month, 1)
    const diff = (targetDay - first.getDay() + 7) % 7
    const day = 1 + diff + (n - 1) * 7
    const daysInMonth = new Date(year, month + 1, 0).getDate()
    if (day > daysInMonth) return null
    return new Date(year, month, day)
  }
  // last occurrence of targetDay in month
  const lastDay = new Date(year, month + 1, 0)
  const diff = (lastDay.getDay() - targetDay + 7) % 7
  return new Date(year, month, lastDay.getDate() - diff)
}

/**
 * Return every calendar date (time-of-day at local midnight) on which
 * this schedule entry should fire, within [after, before].
 *
 * Dates only — the caller combines each date with the schedule's
 * time-of-day via {@link combineDateAndTime}. Splitting date from time
 * makes DST transitions a non-issue: we always build the final Date
 * with a local-time constructor.
 */
export const generateOccurrenceDates = (
  entry: ScheduleEntry,
  after: Date,
  before: Date,
): Date[] => {
  if (before.getTime() < after.getTime()) return []

  const dates: Date[] = []
  const afterStart = startOfDay(after)
  const beforeEnd = startOfDay(before)

  // Frequencies that ride a specific weekday need a valid entry.day.
  const needsWeekday =
    entry.frequency === 'weekly' ||
    entry.frequency === 'biweekly' ||
    entry.frequency === 'monthlyByWeekday'
  const targetDay = entry.day !== undefined ? DAY_INDEX[entry.day] : undefined
  if (needsWeekday && targetDay === undefined) return []

  switch (entry.frequency) {
    case 'daily': {
      let cursor = afterStart
      while (cursor.getTime() <= beforeEnd.getTime()) {
        dates.push(cursor)
        cursor = addDays(cursor, 1)
      }
      return dates
    }

    case 'weekly': {
      let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay!)
      while (cursor.getTime() <= beforeEnd.getTime()) {
        dates.push(cursor)
        cursor = addDays(cursor, 7)
      }
      return dates
    }

    case 'biweekly': {
      if (!entry.biweeklyAnchor) return []
      const anchor = startOfDay(new Date(entry.biweeklyAnchor))
      if (Number.isNaN(anchor.getTime())) return []

      let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay!)
      // Align cursor with anchor's 2-week parity. Compute the whole-day
      // delta using midday to avoid DST rounding pushing it off by one.
      const daysFromAnchor = Math.round(
        (cursor.getTime() + MS_PER_DAY / 2 - (anchor.getTime() + MS_PER_DAY / 2)) /
          MS_PER_DAY,
      )
      const mod = ((daysFromAnchor % 14) + 14) % 14
      if (mod !== 0) cursor = addDays(cursor, 14 - mod)

      while (cursor.getTime() <= beforeEnd.getTime()) {
        if (cursor.getTime() >= afterStart.getTime()) dates.push(cursor)
        cursor = addDays(cursor, 14)
      }
      return dates
    }

    case 'monthlyByDate': {
      if (typeof entry.dayOfMonth !== 'number') return []
      const dom = entry.dayOfMonth
      if (dom < 1 || dom > 31) return []

      let year = afterStart.getFullYear()
      let month = afterStart.getMonth()
      const endYear = beforeEnd.getFullYear()
      const endMonth = beforeEnd.getMonth()

      // Iterate month-by-month. Months without the requested day (e.g. the
      // 31st in April) are skipped — no clamp-down to the last day.
      while (year < endYear || (year === endYear && month <= endMonth)) {
        const daysInMonth = new Date(year, month + 1, 0).getDate()
        if (dom <= daysInMonth) {
          const occ = new Date(year, month, dom)
          if (
            occ.getTime() >= afterStart.getTime() &&
            occ.getTime() <= beforeEnd.getTime()
          ) {
            dates.push(occ)
          }
        }
        month += 1
        if (month > 11) {
          month = 0
          year += 1
        }
      }
      return dates
    }

    case 'monthlyByWeekday': {
      if (!entry.weekOfMonth) return []
      const n = WEEK_OF_MONTH_N[entry.weekOfMonth]

      let year = afterStart.getFullYear()
      let month = afterStart.getMonth()
      const endYear = beforeEnd.getFullYear()
      const endMonth = beforeEnd.getMonth()

      while (year < endYear || (year === endYear && month <= endMonth)) {
        const occ = nthWeekdayOfMonth(year, month, targetDay!, n)
        if (
          occ &&
          occ.getTime() >= afterStart.getTime() &&
          occ.getTime() <= beforeEnd.getTime()
        ) {
          dates.push(occ)
        }
        month += 1
        if (month > 11) {
          month = 0
          year += 1
        }
      }
      return dates
    }

    default:
      return []
  }
}

/**
 * Combine a calendar date (year/month/day from `date`) with a wall-clock
 * time taken from `timeSource` (a Date whose local hours/minutes we read).
 * Always produced in the server's local timezone, which is what Payload's
 * date fields display.
 */
export const combineDateAndTime = (date: Date, timeSource: Date): Date => {
  const time = new Date(timeSource)
  return new Date(
    date.getFullYear(),
    date.getMonth(),
    date.getDate(),
    time.getHours(),
    time.getMinutes(),
    0,
    0,
  )
}
