import { describe, expect, test } from "bun:test"

import { EN, TH, type MessageKey } from "@/lib/i18n/messages"
import {
  eachDay,
  explain,
  generatedLine,
  read,
  rangeLine,
  readRange,
  skipLine,
  type Line,
} from "@/lib/recurrence-preview"
import { isDueOn, type Template } from "@/lib/recurrence"

const none = new Set<string>()

const template = (over: Partial<Template> = {}): Template => ({
  id: "t1",
  recurrence: "daily",
  active: true,
  pattern: "mon_fri",
  ...over,
})

/**
 * The reading is only worth anything if it agrees with the engine. This is the
 * property that matters: for every template, over a range that crosses a
 * weekend, a month end and a holiday, `due` is `isDueOn` and nothing else.
 */
describe("the reading never disagrees with the engine", () => {
  const holidays = new Set(["2026-09-15", "2026-09-16"])
  const templates = [
    template(),
    template({ pattern: "mon_sat" }),
    template({ recurrence: "weekly", dayOfWeek: 4 }),
    template({ recurrence: "weekly", dayOfWeek: 6 }),
    template({ recurrence: "weekly", dayOfWeek: 6, pattern: "mon_sat" }),
    template({ recurrence: "monthly", dayOfMonth: 31 }),
    template({ recurrence: "monthly", dayOfMonth: 31, pattern: "mon_sat" }),
    template({ recurrence: "monthly", dayOfMonth: 15 }),
    template({ recurrence: "quarterly", dayOfMonth: 10 }),
    template({ active: false }),
  ]

  test("over 400 days from the start of 2026", () => {
    const days = eachDay("2026-01-01", "2027-02-04", 400)
    expect(days).toHaveLength(400)
    for (const t of templates) {
      for (const r of readRange(t, days, holidays)) {
        expect(r.due).toBe(isDueOn(t, r.date, holidays))
      }
    }
  })

  test("every day carries either a shift it can explain or a silence", () => {
    const days = eachDay("2026-01-01", "2026-12-31", 400)
    for (const t of templates) {
      for (const r of readRange(t, days, holidays)) {
        expect(r.due ? r.silence === undefined : r.silence !== undefined).toBe(
          true,
        )
      }
    }
  })
})

/** 31 May 2026 clamps to a Sunday, so the instance lands in June. */
describe("the shift that carries into the next month", () => {
  const thirtyFirst = template({ recurrence: "monthly", dayOfMonth: 31 })

  test("names the day it moved from and the month that produced it", () => {
    const r = read(thirtyFirst, "2026-06-01", none)
    expect(r.due).toBe(true)
    expect(r.fixed).toBe("2026-05-31")
    expect(r.carried).toBe(true)
    expect(r.skipped).toEqual([{ date: "2026-05-31", why: "sunday" }])
  })

  test("the day it left says where it went", () => {
    const r = read(thirtyFirst, "2026-05-31", none)
    expect(r.due).toBe(false)
    expect(r.silence).toEqual({
      kind: "moved-away",
      to: "2026-06-01",
      skipped: [{ date: "2026-05-31", why: "sunday" }],
    })
  })
})

describe("a month too short for the day asked for", () => {
  const thirtyFirst = template({ recurrence: "monthly", dayOfMonth: 31 })

  test("April has thirty days, and the clamp is named", () => {
    const r = read(thirtyFirst, "2026-04-30", none)
    expect(r.due).toBe(true)
    expect(r.clampedFrom).toBe(31)
    expect(r.fixed).toBe("2026-04-30")
    expect(r.skipped).toEqual([])
  })

  test("February clamps to a Saturday, and the two patterns part ways", () => {
    const five = read(thirtyFirst, "2026-03-02", none)
    expect(five.due).toBe(true)
    expect(five.fixed).toBe("2026-02-28")
    expect(five.clampedFrom).toBe(31)
    expect(five.carried).toBe(true)
    expect(five.skipped).toEqual([
      { date: "2026-02-28", why: "saturday" },
      { date: "2026-03-01", why: "sunday" },
    ])

    const six = read({ ...thirtyFirst, pattern: "mon_sat" }, "2026-02-28", none)
    expect(six.due).toBe(true)
    expect(six.skipped).toEqual([])
    expect(six.carried).toBe(false)
  })
})

describe("a fixed day on a Saturday", () => {
  const fifteenth = template({ recurrence: "monthly", dayOfMonth: 15 })

  test("a five day week moves to Monday and says which days it passed", () => {
    const r = read(fifteenth, "2026-08-17", none)
    expect(r.due).toBe(true)
    expect(r.fixed).toBe("2026-08-15")
    expect(r.skipped).toEqual([
      { date: "2026-08-15", why: "saturday" },
      { date: "2026-08-16", why: "sunday" },
    ])
  })

  test("a six day week keeps its Saturday", () => {
    const r = read({ ...fifteenth, pattern: "mon_sat" }, "2026-08-15", none)
    expect(r.due).toBe(true)
    expect(r.skipped).toEqual([])
  })
})

describe("holidays push it further", () => {
  const fifteenth = template({ recurrence: "monthly", dayOfMonth: 15 })
  const holidays = new Set(["2026-09-15", "2026-09-16"])

  test("both holidays are named, not just the shift", () => {
    const r = read(fifteenth, "2026-09-17", holidays)
    expect(r.due).toBe(true)
    expect(r.skipped).toEqual([
      { date: "2026-09-15", why: "holiday" },
      { date: "2026-09-16", why: "holiday" },
    ])
  })
})

describe("days that produce nothing", () => {
  test("a Saturday on a five day week", () => {
    expect(read(template(), "2026-08-08", none).silence).toEqual({
      kind: "saturday",
    })
  })

  test("a Sunday", () => {
    expect(
      read(template({ pattern: "mon_sat" }), "2026-08-09", none).silence,
    ).toEqual({
      kind: "sunday",
    })
  })

  test("a holiday", () => {
    expect(
      read(template(), "2026-08-05", new Set(["2026-08-05"])).silence,
    ).toEqual({
      kind: "holiday",
    })
  })

  test("a weekly duty on a day the holder never works", () => {
    const saturday = template({ recurrence: "weekly", dayOfWeek: 6 })
    expect(read(saturday, "2026-08-08", none).silence).toEqual({
      kind: "never-worked",
    })
  })

  test("a quarterly duty outside its months", () => {
    const q = template({ recurrence: "quarterly", dayOfMonth: 10 })
    expect(read(q, "2026-06-10", none).silence).toEqual({
      kind: "not-a-quarter-month",
    })
  })

  test("a monthly duty says where in this month it does fall", () => {
    const fifteenth = template({ recurrence: "monthly", dayOfMonth: 15 })
    expect(read(fifteenth, "2026-09-14", none).silence).toEqual({
      kind: "not-its-day",
      falls: "2026-09-15",
    })
  })

  test("a weekly duty says where in this week it does fall", () => {
    const thursday = template({ recurrence: "weekly", dayOfWeek: 4 })
    expect(read(thursday, "2026-08-05", none).silence).toEqual({
      kind: "not-its-weekday",
      falls: "2026-08-06",
    })
  })

  test("a deactivated template, before any date rule is consulted", () => {
    expect(
      read(template({ active: false }), "2026-08-03", none).silence,
    ).toEqual({
      kind: "inactive",
    })
  })
})

describe("weekly shifts", () => {
  const thursday = template({ recurrence: "weekly", dayOfWeek: 4 })

  test("a holiday on its day moves it inside the week", () => {
    const holidays = new Set(["2026-08-06"])
    const r = read(thursday, "2026-08-07", holidays)
    expect(r.due).toBe(true)
    expect(r.fixed).toBe("2026-08-06")
    expect(r.carried).toBe(false)
    expect(r.skipped).toEqual([{ date: "2026-08-06", why: "holiday" }])
  })

  test("a Friday holiday carries the instance into the next week", () => {
    const friday = template({ recurrence: "weekly", dayOfWeek: 5 })
    const holidays = new Set(["2026-08-07"])
    const r = read(friday, "2026-08-10", holidays)
    expect(r.due).toBe(isDueOn(friday, "2026-08-10", holidays))
    expect(r.due).toBe(true)
    expect(r.fixed).toBe("2026-08-07")
    expect(r.carried).toBe(true)
  })
})

/**
 * These assert the key and the values, never a sentence, because a sentence is
 * what `explain` stopped returning. A test reading English back would pass just
 * as happily on the day the screen printed the key at a Thai reader.
 */
describe("the words are keys and data", () => {
  test("a daily duty names the pattern and stops", () => {
    expect(explain(read(template(), "2026-08-05", none), template())).toEqual([
      { key: "recurrence.line.workingDay", pattern: "mon_fri" },
    ])
  })

  test("a Saturday off names the pattern that makes it one", () => {
    const t = template({ pattern: "mon_fri" })
    expect(explain(read(t, "2026-08-08", none), t)).toEqual([
      { key: "recurrence.line.saturdayOff", pattern: "mon_fri" },
    ])
  })

  test("a weekly duty names its day as a number, not a word", () => {
    const t = template({ recurrence: "weekly", dayOfWeek: 4 })
    expect(explain(read(t, "2026-08-06", none), t)).toEqual([
      { key: "recurrence.line.itsDayIs", weekday: 4 },
      { key: "recurrence.line.doesNotMove" },
    ])
  })

  test("a shift names the day it left as a date, not a formatted one", () => {
    const t = template({ recurrence: "monthly", dayOfMonth: 15 })
    expect(explain(read(t, "2026-08-17", none), t)).toEqual([
      { key: "recurrence.line.dayOfMonth", dayOfMonth: 15 },
      { key: "recurrence.line.movedHereFrom", day: "2026-08-15" },
    ])
  })

  test("a clamp names the day asked for and the month too short for it", () => {
    const t = template({ recurrence: "monthly", dayOfMonth: 31 })
    expect(explain(read(t, "2026-04-30", none), t)).toEqual([
      // No ordinal anywhere: the day it landed on is the heading above it, and
      // "the 30th" is a form Thai does not have.
      { key: "recurrence.line.clamped", dayOfMonth: 31, month: "2026-04-30" },
      { key: "recurrence.line.doesNotMove" },
    ])
  })

  test("an instance from the month before names both months", () => {
    const t = template({ recurrence: "monthly", dayOfMonth: 31 })
    expect(explain(read(t, "2026-06-01", none), t)).toEqual([
      { key: "recurrence.line.dayOfMonth", dayOfMonth: 31 },
      { key: "recurrence.line.movedHereFrom", day: "2026-05-31" },
      {
        key: "recurrence.line.fromTheMonth",
        fromMonth: "2026-05-31",
        toMonth: "2026-06-01",
      },
    ])
  })

  test("an instance from the week before names that week", () => {
    const t = template({ recurrence: "weekly", dayOfWeek: 5 })
    const holidays = new Set(["2026-08-07"])
    expect(explain(read(t, "2026-08-10", holidays), t)).toEqual([
      { key: "recurrence.line.itsDayIs", weekday: 5 },
      { key: "recurrence.line.movedHereFrom", day: "2026-08-07" },
      { key: "recurrence.line.fromTheWeekOf", day: "2026-08-07" },
    ])
  })

  test("the day it left says where it went", () => {
    const t = template({ recurrence: "monthly", dayOfMonth: 15 })
    expect(explain(read(t, "2026-08-15", none), t)).toEqual([
      { key: "recurrence.line.dayOfMonthIsToday", dayOfMonth: 15 },
      { key: "recurrence.line.movedTo", day: "2026-08-17" },
    ])
  })

  test("the clamped day it left says it was the clamp", () => {
    const t = template({ recurrence: "monthly", dayOfMonth: 31 })
    expect(explain(read(t, "2026-02-28", none), t)).toEqual([
      {
        key: "recurrence.line.clampIsToday",
        dayOfMonth: 31,
        month: "2026-02-28",
      },
      { key: "recurrence.line.movedTo", day: "2026-03-02" },
    ])
  })

  test("a duty with nowhere to go says so rather than naming a day", () => {
    const t = template({ recurrence: "monthly", dayOfMonth: 15 })
    const holidays = new Set(eachDay("2026-08-15", "2026-09-30"))
    expect(explain(read(t, "2026-08-15", holidays), t)).toEqual([
      { key: "recurrence.line.dayOfMonthIsToday", dayOfMonth: 15 },
      { key: "recurrence.line.movedNowhere" },
    ])
  })

  test("a day that is not its day names where in the month it falls", () => {
    const t = template({ recurrence: "monthly", dayOfMonth: 15 })
    expect(explain(read(t, "2026-09-14", none), t)).toEqual([
      { key: "recurrence.line.dayOfMonth", dayOfMonth: 15 },
      {
        key: "recurrence.line.fallsThisMonthOn",
        month: "2026-09-14",
        day: "2026-09-15",
      },
    ])
  })

  test("a day that is not its weekday names where in the week it falls", () => {
    const t = template({ recurrence: "weekly", dayOfWeek: 4 })
    expect(explain(read(t, "2026-08-05", none), t)).toEqual([
      { key: "recurrence.line.itsDayIs", weekday: 4 },
      { key: "recurrence.line.fallsThisWeekOn", day: "2026-08-06" },
    ])
  })

  test("a weekly duty nobody can do refuses, and says why", () => {
    const t = template({ recurrence: "weekly", dayOfWeek: 6 })
    expect(explain(read(t, "2026-08-08", none), t)).toEqual([
      { key: "recurrence.line.neverWorked", weekday: 6 },
      { key: "recurrence.line.notMoved" },
    ])
  })

  test("a quarterly duty outside its months names the month", () => {
    const t = template({ recurrence: "quarterly", dayOfMonth: 10 })
    expect(explain(read(t, "2026-06-10", none), t)).toEqual([
      { key: "recurrence.line.notAQuarterMonth", month: "2026-06-10" },
    ])
  })

  test("a deactivated duty, a holiday and a Sunday each carry nothing", () => {
    expect(
      explain(
        read(template({ active: false }), "2026-08-03", none),
        template(),
      ),
    ).toEqual([{ key: "recurrence.line.inactive" }])
    expect(
      explain(
        read(template(), "2026-08-05", new Set(["2026-08-05"])),
        template(),
      ),
    ).toEqual([{ key: "recurrence.line.holiday" }])
    expect(explain(read(template(), "2026-08-09", none), template())).toEqual([
      { key: "recurrence.line.sunday" },
    ])
  })

  test("a duty with no day at all asks for one", () => {
    const noDay = template({ recurrence: "monthly" })
    expect(explain(read(noDay, "2026-08-05", none), noDay)).toEqual([
      { key: "recurrence.line.noSchedule" },
    ])
    const noWeekday = template({ recurrence: "weekly" })
    expect(explain(read(noWeekday, "2026-08-05", none), noWeekday)).toEqual([
      { key: "recurrence.line.noSchedule" },
    ])
  })

  test("each skipped day carries its own reason, and the pattern where it matters", () => {
    expect(skipLine({ date: "2026-09-15", why: "holiday" }, "mon_fri")).toEqual(
      {
        key: "recurrence.skip.holiday",
        day: "2026-09-15",
      },
    )
    expect(skipLine({ date: "2026-08-16", why: "sunday" }, "mon_fri")).toEqual({
      key: "recurrence.skip.sunday",
      day: "2026-08-16",
    })
    expect(
      skipLine({ date: "2026-08-15", why: "saturday" }, "mon_fri"),
    ).toEqual({
      key: "recurrence.skip.saturday",
      day: "2026-08-15",
      pattern: "mon_fri",
    })
  })
})

/**
 * The plural, which is the thing the dictionary's own comment says is never
 * needed. Thai has no plural form and reads the same either way; English does
 * not, and "1 days" is the fault that reached two other screens this week.
 */
describe("a count of one gets its own sentence", () => {
  test("one day is not one days", () => {
    expect(rangeLine(["2026-08-05"])).toEqual({
      key: "recurrence.rangeOneDay",
      date: "2026-08-05",
    })
    expect(EN["recurrence.rangeOneDay"]).not.toContain("{count}")
    expect(EN["recurrence.rangeOneDay"]).not.toContain("days")
  })

  test("more than one names both ends and the number", () => {
    expect(rangeLine(eachDay("2026-08-03", "2026-08-05"))).toEqual({
      key: "recurrence.rangeDays",
      count: 3,
      from: "2026-08-03",
      to: "2026-08-05",
    })
  })

  test("the ends come from the days, not from what was asked for", () => {
    // `eachDay` stops at its limit, and a range cut short must not name a last
    // day it does not contain.
    const cut = eachDay("2026-01-01", "2030-01-01", 10)
    expect(rangeLine(cut)).toEqual({
      key: "recurrence.rangeDays",
      count: 10,
      from: "2026-01-01",
      to: "2026-01-10",
    })
  })

  test("none, one and several are three sentences", () => {
    expect(generatedLine("mon_fri", 0)).toEqual({
      key: "recurrence.generatesNothing",
      pattern: "mon_fri",
    })
    expect(generatedLine("mon_sat", 1)).toEqual({
      key: "recurrence.generatesOne",
      pattern: "mon_sat",
    })
    expect(generatedLine("mon_sat", 7)).toEqual({
      key: "recurrence.generates",
      pattern: "mon_sat",
      count: 7,
    })
    expect(EN["recurrence.generatesOne"]).not.toContain("instances")
    expect(EN["recurrence.generatesNothing"]).not.toContain("{count}")
  })
})

/**
 * Every line the screen can reach, gathered once, so the two things that would
 * put a broken sentence in front of somebody are checked against all of them
 * rather than against the handful a test happened to name.
 */
const everyLine = (): Line[] => {
  const templates = [
    template(),
    template({ pattern: "mon_sat" }),
    template({ active: false }),
    template({ recurrence: "weekly" }),
    template({ recurrence: "weekly", dayOfWeek: 4 }),
    // A Friday duty and a Friday holiday, which is the only way an instance
    // belongs to the week before the one it lands in.
    template({ recurrence: "weekly", dayOfWeek: 5 }),
    template({ recurrence: "weekly", dayOfWeek: 6 }),
    template({ recurrence: "weekly", dayOfWeek: 6, pattern: "mon_sat" }),
    template({ recurrence: "monthly" }),
    template({ recurrence: "monthly", dayOfMonth: 15 }),
    template({ recurrence: "monthly", dayOfMonth: 31 }),
    template({ recurrence: "monthly", dayOfMonth: 31, pattern: "mon_sat" }),
    template({ recurrence: "quarterly", dayOfMonth: 10 }),
  ]
  const days = eachDay("2026-01-01", "2026-12-31", 400)
  // A calendar that swallows a month, which is the only way to reach the two
  // lines that say nothing falls anywhere.
  const holidays = new Set([
    "2026-08-07",
    "2026-09-15",
    "2026-09-16",
    ...eachDay("2026-11-01", "2026-12-31"),
  ])
  const lines: Line[] = [
    rangeLine(["2026-08-05"]),
    rangeLine(days),
    generatedLine("mon_fri", 0),
    generatedLine("mon_fri", 1),
    generatedLine("mon_sat", 7),
  ]
  for (const t of templates) {
    for (const reading of readRange(t, days, holidays)) {
      lines.push(...explain(reading, t))
      for (const skip of reading.skipped ?? [])
        lines.push(skipLine(skip, t.pattern))
      if (reading.silence?.kind === "moved-away") {
        for (const skip of reading.silence.skipped) {
          lines.push(skipLine(skip, t.pattern))
        }
      }
    }
  }
  return lines
}

describe("nothing reaches a reader half-written", () => {
  /**
   * Written out rather than counted, because the two checks below are only
   * worth their run time if the corpus reaches every branch. A key that stops
   * appearing here is either a branch nobody can reach any more or a sweep that
   * quietly stopped covering it, and both are worth being told about.
   */
  test("the sweep reaches every line the screen can show", () => {
    expect([...new Set(everyLine().map((l) => l.key))].sort()).toEqual([
      "recurrence.generates",
      "recurrence.generatesNothing",
      "recurrence.generatesOne",
      "recurrence.line.clampIsToday",
      "recurrence.line.clamped",
      "recurrence.line.dayOfMonth",
      "recurrence.line.dayOfMonthIsToday",
      "recurrence.line.doesNotMove",
      "recurrence.line.fallsThisMonthOn",
      "recurrence.line.fallsThisWeekOn",
      "recurrence.line.fromTheMonth",
      "recurrence.line.fromTheWeekOf",
      "recurrence.line.holiday",
      "recurrence.line.inactive",
      "recurrence.line.itsDayIs",
      "recurrence.line.itsDayIsToday",
      "recurrence.line.movedHereFrom",
      "recurrence.line.movedNowhere",
      "recurrence.line.movedTo",
      "recurrence.line.neverWorked",
      "recurrence.line.noSchedule",
      "recurrence.line.notAQuarterMonth",
      "recurrence.line.notMoved",
      "recurrence.line.nothingThisMonth",
      "recurrence.line.saturdayOff",
      "recurrence.line.sunday",
      "recurrence.line.workingDay",
      "recurrence.rangeDays",
      "recurrence.rangeOneDay",
      "recurrence.skip.holiday",
      "recurrence.skip.saturday",
      "recurrence.skip.sunday",
    ])
  })

  test("every key a line can name is in the dictionary, in both languages", () => {
    for (const line of everyLine()) {
      expect(EN[line.key]).toBeString()
      expect(TH[line.key]).toBeString()
    }
  })

  /**
   * The contract between a `Line` and its sentence, written out.
   *
   * A key whose sentence asks for `{month}` on a line that only carries a day
   * is a `{month}` printed at somebody, and the type cannot catch it because
   * both are strings. This can: it is the renderer's mapping stated once, and
   * checked against both dictionaries at once.
   */
  const FILLS: Record<string, string[]> = {
    // A day inside a sentence, short, and a day standing on its own, in full.
    day: ["day"],
    date: ["day"],
    month: ["month"],
    // The weekday is a number the dictionary names, and the name lands in {day}.
    weekday: ["day"],
    dayOfMonth: ["dayOfMonth"],
    pattern: ["pattern"],
    count: ["count"],
    // The ends of a range are days; the ends of a carry are months.
    from: ["from"],
    to: ["to"],
    fromMonth: ["from"],
    toMonth: ["to"],
  }

  test("every placeholder in both languages is one its line can fill", () => {
    const seen = new Set<MessageKey>()
    for (const line of everyLine()) {
      if (seen.has(line.key)) continue
      seen.add(line.key)
      const can = new Set(
        Object.keys(line)
          .filter((f) => f !== "key")
          .flatMap((f) => FILLS[f] ?? []),
      )
      for (const message of [EN[line.key], TH[line.key]]) {
        for (const [, name] of message.matchAll(/\{(\w+)\}/g)) {
          expect(`${line.key} fills {${name}}`).toBe(
            can.has(name!)
              ? `${line.key} fills {${name}}`
              : `${line.key} cannot`,
          )
        }
      }
    }
  })

  test("and every value a line carries is used by both sentences", () => {
    const seen = new Set<MessageKey>()
    for (const line of everyLine()) {
      if (seen.has(line.key)) continue
      seen.add(line.key)
      for (const field of Object.keys(line)) {
        if (field === "key") continue
        for (const message of [EN[line.key], TH[line.key]]) {
          const used = (FILLS[field] ?? []).some((name) =>
            message.includes(`{${name}}`),
          )
          expect(`${line.key} uses ${field}`).toBe(
            used ? `${line.key} uses ${field}` : `${line.key} drops ${field}`,
          )
        }
      }
    }
  })
})

describe("eachDay", () => {
  test("is inclusive at both ends", () => {
    expect(eachDay("2026-08-03", "2026-08-05")).toEqual([
      "2026-08-03",
      "2026-08-04",
      "2026-08-05",
    ])
  })

  test("stops at the limit rather than running away", () => {
    expect(eachDay("2026-01-01", "2030-01-01", 10)).toHaveLength(10)
  })
})

describe("the clamped day says so when the duty leaves it", () => {
  test("28 February is the day 31 clamps to, and it names the clamp", () => {
    const r = read(
      template({ recurrence: "monthly", dayOfMonth: 31 }),
      "2026-02-28",
      none,
    )
    expect(r.due).toBe(false)
    expect(r.clampedFrom).toBe(31)
    expect(r.silence?.kind).toBe("moved-away")
  })

  test("a day that is not clamped does not claim to be", () => {
    const r = read(
      template({ recurrence: "monthly", dayOfMonth: 15 }),
      "2026-08-15",
      none,
    )
    expect(r.clampedFrom).toBeUndefined()
  })

  test("the holiday flag is stamped whatever the reason for the silence", () => {
    const holidays = new Set(["2026-09-16"])
    const r = read(
      template({ recurrence: "monthly", dayOfMonth: 15 }),
      "2026-09-16",
      holidays,
    )
    expect(r.holiday).toBe(true)
    expect(r.silence?.kind).toBe("not-its-day")
  })
})
