import {
  addDays,
  dateOf,
  isoDayOfWeek,
  isWorkingDay,
  lastDayOfMonth,
  monthOf,
  nextWorkingDay,
  previousMonth,
  type WorkPattern,
} from "@/lib/calendar"

/**
 * Which duties are due on a given day, and which have run out of time.
 *
 * Pure. No database, no schema. The rules are subtle and the calendar is full
 * of edge cases; none of them are easier to see through an ORM.
 */

export type Recurrence = "daily" | "weekly" | "monthly" | "quarterly"

export type Template = {
  id: string
  recurrence: Recurrence
  /** 1 Monday to 7 Sunday. Weekly only. */
  dayOfWeek?: number
  /** 1 to 31, clamped to the month. Monthly and quarterly only. */
  dayOfMonth?: number
  /** Deactivating stops generation and leaves produced instances alone. */
  active: boolean
  /** The holder's week, which decides whether Saturdays exist for them. */
  pattern: WorkPattern
}

/**
 * Quarterly fires in January, April, July and October. The specification says
 * "quarterly" and a day of the month, and never which months, so this is an
 * assumption and it is written down rather than buried.
 */
export function isQuarterMonth(month: number): boolean {
  return (month - 1) % 3 === 0
}

/**
 * The day the schedule points at in one month before anything moves it: the
 * day asked for, or the last day of a month too short to hold it.
 *
 * Split out so that a screen can say what the day was before the shift without
 * writing the clamp a second time.
 */
export function clampedDayInMonth(
  template: Template,
  year: number,
  month: number,
): string | null {
  const wanted = template.dayOfMonth
  if (!wanted) return null
  return dateOf(year, month, Math.min(wanted, lastDayOfMonth(year, month)))
}

/**
 * The day a monthly or quarterly duty falls on in one given month: the fixed
 * day, clamped to a month that may be shorter, then moved to the next working
 * day if that lands on a weekend or a holiday.
 *
 * Returns null when the month is not one this duty fires in.
 */
export function dueDateInMonth(
  template: Template,
  year: number,
  month: number,
  holidays: ReadonlySet<string>,
): string | null {
  if (template.recurrence === "quarterly" && !isQuarterMonth(month)) return null
  const clamped = clampedDayInMonth(template, year, month)
  if (!clamped) return null
  return nextWorkingDay(clamped, template.pattern, holidays)
}

/**
 * The day a weekly duty points at in the week containing `anchor`, before any
 * shift. Same reason as the clamp above: the screen names the day it moved
 * from, and there is one place that decides which day that is.
 */
export function fixedDayInWeek(
  template: Template,
  anchor: string,
): string | null {
  const wanted = template.dayOfWeek
  if (!wanted) return null
  const monday = addDays(anchor, 1 - isoDayOfWeek(anchor))
  return addDays(monday, wanted - 1)
}

/**
 * The day a weekly duty falls on in the week containing `anchor`: its own day,
 * moved forward if that day is a holiday.
 */
export function dueDateInWeek(
  template: Template,
  anchor: string,
  holidays: ReadonlySet<string>,
): string | null {
  const target = fixedDayInWeek(template, anchor)
  if (!target) return null
  if (
    !isWorkingDay(target, template.pattern, holidays) &&
    isoDayOfWeek(target) >= 6
  ) {
    // The duty is set to a day this person does not work at all. That is a
    // mistake in the template, not something to silently move.
    return null
  }
  return nextWorkingDay(target, template.pattern, holidays)
}

/**
 * Is this template due on this date?
 *
 * A shifted due date can land outside the period that produced it: the 31st of
 * a month whose 30th is a Sunday moves into the next month. So the previous
 * period is asked as well as the current one, otherwise that instance is never
 * generated at all.
 */
export function isDueOn(
  template: Template,
  date: string,
  holidays: ReadonlySet<string> = new Set(),
): boolean {
  if (!template.active) return false

  switch (template.recurrence) {
    case "daily":
      return isWorkingDay(date, template.pattern, holidays)

    case "weekly": {
      const thisWeek = dueDateInWeek(template, date, holidays)
      const lastWeek = dueDateInWeek(template, addDays(date, -7), holidays)
      return thisWeek === date || lastWeek === date
    }

    case "monthly":
    case "quarterly": {
      const here = monthOf(date)
      const before = previousMonth(here)
      return (
        dueDateInMonth(template, here.year, here.month, holidays) === date ||
        dueDateInMonth(template, before.year, before.month, holidays) === date
      )
    }
  }
}

/** Everything that should exist on one day. */
export function dueOn(
  templates: readonly Template[],
  date: string,
  holidays: ReadonlySet<string> = new Set(),
): Template[] {
  return templates.filter((t) => isDueOn(t, date, holidays))
}

/**
 * Generation is idempotent, and this is what buys it: one instance per template
 * per due date, so a second run collides with the first instead of adding to
 * it. The same key exists as a unique index in the database.
 *
 * The consequence is worth stating: a wrong date is permanent. The second run
 * collides with the mistake rather than correcting it.
 */
export function instanceKey(templateId: string, dueDate: string): string {
  return `${templateId}#${dueDate}`
}

export function toGenerate(
  templates: readonly Template[],
  date: string,
  existing: ReadonlySet<string>,
  holidays: ReadonlySet<string> = new Set(),
): Template[] {
  return dueOn(templates, date, holidays).filter(
    (t) => !existing.has(instanceKey(t.id, date)),
  )
}

/**
 * The cut-off follows the task's own cycle, three cases rather than one clock:
 * a daily task at the end of its day, a weekly one at the end of its week, a
 * monthly one at the end of its month.
 *
 * `on` is the day the evaluation runs. A task is out of time when its period
 * has closed, never on the day it is still in.
 */
export function isPastCutOff(
  recurrence: Recurrence,
  dueDate: string,
  on: string,
): boolean {
  switch (recurrence) {
    case "daily":
      return on > dueDate
    case "weekly": {
      const endOfWeek = addDays(dueDate, 7 - isoDayOfWeek(dueDate))
      return on > endOfWeek
    }
    case "monthly":
    case "quarterly": {
      const { year, month } = monthOf(dueDate)
      return on > dateOf(year, month, lastDayOfMonth(year, month))
    }
  }
}

export type OpenInstance = {
  key: string
  recurrence: Recurrence
  dueDate: string
  status: "todo" | "started"
}

/**
 * What to mark missed. It marks and never removes: an undone task stays open
 * and can still be done, so this writes a status and takes nothing away.
 */
export function toMarkMissed(
  open: readonly OpenInstance[],
  on: string,
): OpenInstance[] {
  return open.filter((i) => isPastCutOff(i.recurrence, i.dueDate, on))
}
