import {
  addDays,
  isoDayOfWeek,
  monthOf,
  previousMonth,
  type WorkPattern,
} from "@/lib/calendar"
import type { MessageKey } from "@/lib/i18n/messages"
import {
  clampedDayInMonth,
  dueDateInMonth,
  dueDateInWeek,
  fixedDayInWeek,
  isDueOn,
  isQuarterMonth,
  type Template,
} from "@/lib/recurrence"

/**
 * Reading the recurrence engine out loud.
 *
 * Nothing here decides a date. `isDueOn` decides, and every sentence this
 * module supports is assembled from what the engine already returned: the day
 * it pointed at, the day it landed on, and the days in between it refused.
 *
 * The point is the shift. A mark on the 17th tells nobody that the duty was
 * fixed to Saturday the 15th, and a shift nobody sees is a shift nobody
 * checks.
 */

/** Why the engine would not stop on a day. Mirrors `isWorkingDay`. */
export type SkipReason = "sunday" | "saturday" | "holiday"

export type Skip = { date: string; why: SkipReason }

/** Why a day produces nothing. Never left blank on screen. */
export type Silence =
  | { kind: "inactive" }
  | { kind: "holiday" }
  | { kind: "sunday" }
  | { kind: "saturday" }
  /** Its own day, but the duty landed elsewhere. */
  | { kind: "moved-away"; to: string | null; skipped: Skip[] }
  /**
   * `falls` is never null here, unlike the monthly case below. A week whose day
   * produces nothing at all is `never-worked`, which `readWeekly` returns first,
   * so the sentence for "nothing falls in this week" was one no reader could
   * reach and no test could pin.
   */
  | { kind: "not-its-weekday"; falls: string }
  | { kind: "not-its-day"; falls: string | null }
  /** Fixed to a day this holder never works, which the engine refuses to move. */
  | { kind: "never-worked" }
  | { kind: "not-a-quarter-month" }
  | { kind: "no-schedule" }

export type Reading = {
  date: string
  /** Straight from `isDueOn`. The screen never decides this. */
  due: boolean
  /** The day the schedule pointed at before the shift. Absent for daily. */
  fixed?: string
  /** Each day the engine passed over on its way to `date`, and why. */
  skipped?: Skip[]
  /** The shift carried it out of the period that produced it. */
  carried?: boolean
  /** The day of the month asked for, when the month was too short to hold it. */
  clampedFrom?: number
  /** The day itself is a holiday, which stays worth saying even when it is not why. */
  holiday: boolean
  silence?: Silence
}

function whySkipped(date: string, holidays: ReadonlySet<string>): SkipReason {
  if (holidays.has(date)) return "holiday"
  return isoDayOfWeek(date) === 7 ? "sunday" : "saturday"
}

/**
 * The days between the day the schedule pointed at and the day it landed on.
 * Every one of them was refused by `isWorkingDay`, so each carries a reason.
 */
function skippedBetween(
  from: string,
  to: string | null,
  holidays: ReadonlySet<string>,
): Skip[] {
  const out: Skip[] = []
  if (!to || to <= from) return out
  for (let day = from; day < to && out.length <= 31; day = addDays(day, 1)) {
    out.push({ date: day, why: whySkipped(day, holidays) })
  }
  return out
}

/** A reading before the holiday flag is stamped on it. */
type Unflagged = Omit<Reading, "holiday">

function readDaily(
  date: string,
  due: boolean,
  holidays: ReadonlySet<string>,
): Unflagged {
  if (due) return { date, due }
  if (holidays.has(date)) return { date, due, silence: { kind: "holiday" } }
  return {
    date,
    due,
    silence:
      isoDayOfWeek(date) === 7 ? { kind: "sunday" } : { kind: "saturday" },
  }
}

function readWeekly(
  template: Template,
  date: string,
  due: boolean,
  holidays: ReadonlySet<string>,
): Unflagged {
  const thisWeek = dueDateInWeek(template, date, holidays)

  if (due) {
    // `isDueOn` asks this week and the one before, so one of the two produced
    // this day. Whichever it was decides the day it moved from.
    const anchor = thisWeek === date ? date : addDays(date, -7)
    const fixed = fixedDayInWeek(template, anchor)
    return {
      date,
      due,
      ...(fixed
        ? { fixed, skipped: skippedBetween(fixed, date, holidays) }
        : {}),
      carried: thisWeek !== date,
    }
  }

  const fixed = fixedDayInWeek(template, date)
  if (!fixed) return { date, due, silence: { kind: "no-schedule" } }
  if (thisWeek === null) return { date, due, silence: { kind: "never-worked" } }
  if (fixed === date) {
    return {
      date,
      due,
      fixed,
      silence: {
        kind: "moved-away",
        to: thisWeek,
        skipped: skippedBetween(fixed, thisWeek, holidays),
      },
    }
  }
  return { date, due, silence: { kind: "not-its-weekday", falls: thisWeek } }
}

function readMonthly(
  template: Template,
  date: string,
  due: boolean,
  holidays: ReadonlySet<string>,
): Unflagged {
  const here = monthOf(date)
  const before = previousMonth(here)

  if (due) {
    // Same two questions `isDueOn` asks, in the same order.
    const fromHere =
      dueDateInMonth(template, here.year, here.month, holidays) === date
    const period = fromHere ? here : before
    const fixed = clampedDayInMonth(template, period.year, period.month)
    const wanted = template.dayOfMonth
    return {
      date,
      due,
      ...(fixed
        ? { fixed, skipped: skippedBetween(fixed, date, holidays) }
        : {}),
      carried: !fromHere,
      ...(fixed && wanted && Number(fixed.slice(8)) !== wanted
        ? { clampedFrom: wanted }
        : {}),
    }
  }

  if (!template.dayOfMonth)
    return { date, due, silence: { kind: "no-schedule" } }
  if (template.recurrence === "quarterly" && !isQuarterMonth(here.month)) {
    return { date, due, silence: { kind: "not-a-quarter-month" } }
  }

  const fixed = clampedDayInMonth(template, here.year, here.month)
  const falls = dueDateInMonth(template, here.year, here.month, holidays)
  if (fixed === date) {
    return {
      date,
      due,
      fixed,
      ...(Number(fixed.slice(8)) !== template.dayOfMonth
        ? { clampedFrom: template.dayOfMonth }
        : {}),
      silence: {
        kind: "moved-away",
        to: falls,
        skipped: skippedBetween(date, falls, holidays),
      },
    }
  }
  return { date, due, silence: { kind: "not-its-day", falls } }
}

/** What the engine says about one template on one day, and why it says it. */
export function read(
  template: Template,
  date: string,
  holidays: ReadonlySet<string> = new Set(),
): Reading {
  const due = isDueOn(template, date, holidays)
  const holiday = holidays.has(date)
  if (!template.active) {
    return { date, due, holiday, silence: { kind: "inactive" } }
  }

  switch (template.recurrence) {
    case "daily":
      return { ...readDaily(date, due, holidays), holiday }
    case "weekly":
      return { ...readWeekly(template, date, due, holidays), holiday }
    case "monthly":
    case "quarterly":
      return { ...readMonthly(template, date, due, holidays), holiday }
  }
}

/** Every day from `from` to `to`, inclusive. Bounded, because a range is free to be silly. */
export function eachDay(from: string, to: string, limit = 92): string[] {
  const out: string[] = []
  for (let day = from; day <= to && out.length < limit; day = addDays(day, 1)) {
    out.push(day)
  }
  return out
}

export function readRange(
  template: Template,
  days: readonly string[],
  holidays: ReadonlySet<string> = new Set(),
): Reading[] {
  return days.map((day) => read(template, day, holidays))
}

/**
 * A key the dictionary carries. Misspell one and this stops compiling here,
 * rather than printing itself onto the screen.
 */
type Key<K extends MessageKey> = K

/**
 * 1 Monday to 7 Sunday, as the database spells it.
 *
 * A template's `dayOfWeek` is any number at all, and only these seven name a
 * day. The old formatter took the number straight and fed `0` to a calendar,
 * which named Sunday for a duty that had no day set at all.
 */
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7

function asWeekday(value: number | undefined): Weekday | null {
  return value !== undefined &&
    Number.isInteger(value) &&
    value >= 1 &&
    value <= 7
    ? (value as Weekday)
    : null
}

/** The lines that carry nothing to fill in. */
export type PlainLine = {
  key: Key<
    | "recurrence.line.inactive"
    | "recurrence.line.holiday"
    | "recurrence.line.sunday"
    | "recurrence.line.doesNotMove"
    | "recurrence.line.movedNowhere"
    | "recurrence.line.notMoved"
    | "recurrence.line.noSchedule"
  >
}

/**
 * One sentence of the reading, as a key and the values it needs.
 *
 * **Not a sentence.** This module decides which case a day is in; it has no
 * idea who is reading, and 183 of the 193 people here read Thai. `lib/notice.ts`
 * and `describeSchedule` in `lib/task-form.ts` reached the same shape from the
 * same direction: whoever renders has the reader, and the function does not.
 *
 * **The data stays data.** `day` and `month` are `YYYY-MM-DD` and never a
 * formatted date, so they reach the reader through `lib/i18n/dates.ts` and a
 * Thai reader gets the Buddhist year. `weekday` is a number the dictionary
 * names, and `dayOfMonth` is a number the sentence puts where its own language
 * puts it. Nothing here produces an English ordinal, because Thai has none: the
 * sentence that used to say "clamps to the 28th" now says it clamps to the last
 * day of the month, and the line under it names that day in full.
 *
 * **A union and not a bag of optional fields**, so a renderer that leaves a
 * shape unhandled fails to compile rather than printing `{day}` at somebody.
 */
export type Line =
  | PlainLine
  | {
      key: Key<
        | "recurrence.line.workingDay"
        | "recurrence.line.saturdayOff"
        | "recurrence.generatesNothing"
        | "recurrence.generatesOne"
      >
      pattern: WorkPattern
    }
  | { key: Key<"recurrence.generates">; pattern: WorkPattern; count: number }
  | { key: Key<"recurrence.rangeOneDay">; date: string }
  | {
      key: Key<"recurrence.rangeDays">
      count: number
      from: string
      to: string
    }
  | { key: Key<"recurrence.skip.saturday">; day: string; pattern: WorkPattern }
  | {
      key: Key<
        | "recurrence.skip.sunday"
        | "recurrence.skip.holiday"
        | "recurrence.line.movedHereFrom"
        | "recurrence.line.movedTo"
        | "recurrence.line.fromTheWeekOf"
        | "recurrence.line.fallsThisWeekOn"
      >
      day: string
    }
  | {
      key: Key<
        | "recurrence.line.itsDayIs"
        | "recurrence.line.itsDayIsToday"
        | "recurrence.line.neverWorked"
      >
      weekday: Weekday
    }
  | {
      key: Key<
        "recurrence.line.dayOfMonth" | "recurrence.line.dayOfMonthIsToday"
      >
      dayOfMonth: number
    }
  | {
      key: Key<"recurrence.line.clamped" | "recurrence.line.clampIsToday">
      dayOfMonth: number
      month: string
    }
  | {
      key: Key<"recurrence.line.fromTheMonth">
      fromMonth: string
      toMonth: string
    }
  | { key: Key<"recurrence.line.fallsThisMonthOn">; month: string; day: string }
  | {
      key: Key<
        "recurrence.line.notAQuarterMonth" | "recurrence.line.nothingThisMonth"
      >
      month: string
    }

/**
 * How long the range is, and which days it runs between.
 *
 * A range of one day is a range somebody picked, so it gets its own sentence
 * rather than a number in front of a plural. English needs it and Thai does
 * not, which is precisely how "1 people" and "1 duties" got onto two screens
 * this week: the plural was hand-rolled once, in English, beside a dictionary
 * whose comment says none is ever needed.
 *
 * The ends come from the days themselves and not from what was asked for,
 * because `eachDay` stops at its limit and a range that was cut short would
 * otherwise name a last day it does not contain.
 */
export function rangeLine(days: readonly string[]): Line {
  const first = days[0]!
  const last = days[days.length - 1]!
  return days.length === 1
    ? { key: "recurrence.rangeOneDay", date: first }
    : {
        key: "recurrence.rangeDays",
        count: days.length,
        from: first,
        to: last,
      }
}

/** What one work pattern produces over that range. Zero and one are ordinary. */
export function generatedLine(pattern: WorkPattern, count: number): Line {
  if (count === 0) return { key: "recurrence.generatesNothing", pattern }
  if (count === 1) return { key: "recurrence.generatesOne", pattern }
  return { key: "recurrence.generates", pattern, count }
}

/** Why the engine passed over one day on its way to another. */
export function skipLine(skip: Skip, pattern: WorkPattern): Line {
  if (skip.why === "holiday") {
    return { key: "recurrence.skip.holiday", day: skip.date }
  }
  if (skip.why === "sunday") {
    return { key: "recurrence.skip.sunday", day: skip.date }
  }
  return { key: "recurrence.skip.saturday", day: skip.date, pattern }
}

/**
 * The lines for one reading. "Moved here from Saturday 15 August" is worth more
 * than a mark on the 17th, so the day it left is named before anything else.
 */
export function explain(reading: Reading, template: Template): Line[] {
  const { recurrence, dayOfMonth, pattern } = template
  const weekday = asWeekday(template.dayOfWeek)
  const lines: Line[] = []

  if (reading.due) {
    if (recurrence === "daily") {
      return [{ key: "recurrence.line.workingDay", pattern }]
    }

    if (recurrence === "weekly" && weekday) {
      lines.push({ key: "recurrence.line.itsDayIs", weekday })
    } else if (dayOfMonth) {
      lines.push(
        reading.clampedFrom && reading.fixed
          ? {
              key: "recurrence.line.clamped",
              dayOfMonth: reading.clampedFrom,
              month: reading.fixed,
            }
          : { key: "recurrence.line.dayOfMonth", dayOfMonth },
      )
    }

    if (reading.skipped?.length && reading.fixed) {
      lines.push({ key: "recurrence.line.movedHereFrom", day: reading.fixed })
    } else {
      lines.push({ key: "recurrence.line.doesNotMove" })
    }

    if (reading.carried && reading.fixed) {
      lines.push(
        recurrence === "weekly"
          ? { key: "recurrence.line.fromTheWeekOf", day: reading.fixed }
          : {
              key: "recurrence.line.fromTheMonth",
              fromMonth: reading.fixed,
              toMonth: reading.date,
            },
      )
    }
    return lines
  }

  const silence = reading.silence
  if (!silence) return lines

  switch (silence.kind) {
    case "inactive":
      lines.push({ key: "recurrence.line.inactive" })
      break
    case "holiday":
      lines.push({ key: "recurrence.line.holiday" })
      break
    case "sunday":
      lines.push({ key: "recurrence.line.sunday" })
      break
    case "saturday":
      lines.push({ key: "recurrence.line.saturdayOff", pattern })
      break
    case "moved-away":
      lines.push(
        recurrence === "weekly" && weekday
          ? { key: "recurrence.line.itsDayIsToday", weekday }
          : reading.clampedFrom
            ? {
                key: "recurrence.line.clampIsToday",
                dayOfMonth: reading.clampedFrom,
                month: reading.date,
              }
            : dayOfMonth
              ? { key: "recurrence.line.dayOfMonthIsToday", dayOfMonth }
              : { key: "recurrence.line.noSchedule" },
      )
      lines.push(
        silence.to
          ? { key: "recurrence.line.movedTo", day: silence.to }
          : { key: "recurrence.line.movedNowhere" },
      )
      break
    case "not-its-weekday":
      // Guarded rather than defaulted. `readWeekly` reaches this only with a day
      // set, and the old `?? 0` would have named Sunday for a duty with none.
      if (weekday) lines.push({ key: "recurrence.line.itsDayIs", weekday })
      lines.push({
        key: "recurrence.line.fallsThisWeekOn",
        day: silence.falls,
      })
      break
    case "not-its-day":
      if (dayOfMonth)
        lines.push({ key: "recurrence.line.dayOfMonth", dayOfMonth })
      lines.push(
        silence.falls
          ? {
              key: "recurrence.line.fallsThisMonthOn",
              month: reading.date,
              day: silence.falls,
            }
          : { key: "recurrence.line.nothingThisMonth", month: reading.date },
      )
      break
    case "never-worked":
      if (weekday) lines.push({ key: "recurrence.line.neverWorked", weekday })
      lines.push({ key: "recurrence.line.notMoved" })
      break
    case "not-a-quarter-month":
      lines.push({
        key: "recurrence.line.notAQuarterMonth",
        month: reading.date,
      })
      break
    case "no-schedule":
      lines.push({ key: "recurrence.line.noSchedule" })
      break
  }
  return lines
}
