import { describe, it } from "@effect/vitest"
import { assertFalse, assertTrue, deepStrictEqual, throws } from "@effect/vitest/utils"
import { Cron, DateTime, Equal, Option, Result } from "effect"

const match = (input: Cron.Cron | string, date: DateTime.DateTime.Input) =>
  Cron.match(Cron.isCron(input) ? input : Cron.parseUnsafe(input), date)

const next = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) =>
  Cron.next(Cron.isCron(input) ? input : Cron.parseUnsafe(input), after)

const prev = (input: Cron.Cron | string, after?: DateTime.DateTime.Input) =>
  Cron.prev(Cron.isCron(input) ? input : Cron.parseUnsafe(input), after)

const allDays = Array.from({ length: 31 }, (_, i) => i + 1)
const allWeekdays = Array.from({ length: 7 }, (_, i) => i)

describe("Cron", () => {
  it("isCronParseError", () => {
    const result = Cron.parse("")
    assertTrue(Result.isFailure(result))
    assertTrue(Cron.isCronParseError(result.failure))
    assertFalse(Cron.isCronParseError(new Error("regular error")))
    assertFalse(Cron.isCronParseError("not an error"))
  })

  it("CronParseError constructor", () => {
    const error = new Cron.CronParseError({ message: "boom", input: "0 0 * * *" })
    assertTrue(error instanceof Error)
    assertTrue(Cron.isCronParseError(error))
    deepStrictEqual(error._tag, "CronParseError")
    deepStrictEqual(error.message, "boom")
    deepStrictEqual(error.input, "0 0 * * *")
  })

  it("parse", () => {
    // Explicit full weekday ranges are preserved for day-of-month / weekday OR semantics.
    deepStrictEqual(
      Cron.parse("0 4 8-14 * 0-6"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [4],
        days: [8, 9, 10, 11, 12, 13, 14],
        months: [],
        weekdays: allWeekdays
      }))
    )
    // At 00:00 on day-of-month 1 and 15 and on Wednesday.
    deepStrictEqual(
      Cron.parse("0 0 1,15 * 3"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [0],
        days: [1, 15],
        months: [],
        weekdays: [3]
      }))
    )
    // At 00:00 on day-of-month 1 and 15 and on Wednesday.
    deepStrictEqual(
      Cron.parse("23 0-20/2 * * *"),
      Result.succeed(Cron.make({
        minutes: [23],
        hours: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20],
        days: [],
        months: [],
        weekdays: []
      }))
    )

    // Starting at minute 5 and then every 15 minutes.
    deepStrictEqual(
      Cron.parse("5/15 * * * *"),
      Result.succeed(Cron.make({
        minutes: [5, 20, 35, 50],
        hours: [],
        days: [],
        months: [],
        weekdays: []
      }))
    )

    // At 00:00 on every day-of-month from 1 through 31 and on Monday.
    deepStrictEqual(
      Cron.parse("0 0 1/1 * 1"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [0],
        days: allDays,
        months: [],
        weekdays: [1]
      }))
    )

    deepStrictEqual(
      Cron.parse("0 0 */1 * 1"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [0],
        days: [],
        months: [],
        weekdays: [1]
      }))
    )

    deepStrictEqual(
      Cron.parse("0 0 1-31 * MON"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [0],
        days: allDays,
        months: [],
        weekdays: [1]
      }))
    )

    deepStrictEqual(
      Cron.parse("0 0 1-31/1 * MON"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [0],
        days: allDays,
        months: [],
        weekdays: [1]
      }))
    )

    deepStrictEqual(
      Cron.parse("0 0 1 * 0-6"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [0],
        days: [1],
        months: [],
        weekdays: allWeekdays
      }))
    )

    deepStrictEqual(
      Cron.parse("0 0 1 * SUN-SAT"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [0],
        days: [1],
        months: [],
        weekdays: allWeekdays
      }))
    )

    deepStrictEqual(
      Cron.parse("0\t0\t*\t*\t7"),
      Result.succeed(Cron.make({
        minutes: [0],
        hours: [0],
        days: [],
        months: [],
        weekdays: [0]
      }))
    )

    const invalidMinuteTail = Cron.parse("0/1,60 * * * *")
    assertTrue(Result.isFailure(invalidMinuteTail))
    deepStrictEqual(invalidMinuteTail.failure.message, "Expected a value between 0 and 59")

    const invalidDayTail = Cron.parse("0 0 1/1,32 * *")
    assertTrue(Result.isFailure(invalidDayTail))
    deepStrictEqual(invalidDayTail.failure.message, "Expected a value between 1 and 31")

    for (
      const invalid of [
        "0,,5 * * * *",
        ",5 * * * *",
        "-5 * * * *",
        "0- * * * *",
        "1e1 * * * *",
        "0x10 * * * *",
        "+5 * * * *"
      ]
    ) {
      assertTrue(Result.isFailure(Cron.parse(invalid)))
    }

    const withNamedTimeZone = Cron.parseUnsafe("23 0-20/2 * * *", "Europe/Berlin")
    assertTrue(Option.isSome(withNamedTimeZone.tz))
    deepStrictEqual(DateTime.zoneToString(withNamedTimeZone.tz.value), "Europe/Berlin")

    const withOffsetTimeZone = Cron.parseUnsafe("23 0-20/2 * * *", "+02:00")
    assertTrue(Option.isSome(withOffsetTimeZone.tz))
    deepStrictEqual(DateTime.zoneToString(withOffsetTimeZone.tz.value), "+02:00")

    const withoutTimeZone = Cron.parseUnsafe("23 0-20/2 * * *")
    assertTrue(Option.isNone(withoutTimeZone.tz))
  })

  it("parseUnsafe", () => {
    throws(
      () => Cron.parseUnsafe(""),
      (error) => {
        if (!Cron.isCronParseError(error)) {
          throw new Error("Expected CronParseError")
        }
        deepStrictEqual(error.message, "Invalid number of segments in cron expression")
        deepStrictEqual(error.input, "")
      }
    )
    throws(
      () => Cron.parseUnsafe("0 0 4 8-14 * *", ""),
      (error) => {
        if (!Cron.isCronParseError(error)) {
          throw new Error("Expected CronParseError")
        }
        deepStrictEqual(error.message, "Invalid time zone in cron expression")
        deepStrictEqual(error.input, "")
      }
    )
  })

  it("make supports requiring both days and weekdays", () => {
    const utc = DateTime.zoneMakeNamedUnsafe("UTC")
    const values = {
      minutes: [0],
      hours: [0],
      days: [1],
      months: [],
      weekdays: [1],
      tz: utc
    }
    const orCron = Cron.make(values)
    const andCron = Cron.make({ ...values, and: true })

    assertTrue(Cron.match(orCron, new Date("2024-01-08T00:00:00.000Z")))
    assertFalse(Cron.match(andCron, new Date("2024-01-08T00:00:00.000Z")))
    assertTrue(Cron.match(andCron, new Date("2024-01-01T00:00:00.000Z")))
    assertFalse(Equal.equals(orCron, andCron))
    deepStrictEqual(Cron.next(orCron, new Date("2024-01-02T00:00:00.000Z")), new Date("2024-01-08T00:00:00.000Z"))
    deepStrictEqual(Cron.next(andCron, new Date("2024-01-02T00:00:00.000Z")), new Date("2024-04-01T00:00:00.000Z"))
  })

  it("match", () => {
    assertTrue(match("5 0 * 8 *", new Date("2024-08-01 00:05:00")))
    assertFalse(match("5 0 * 8 *", new Date("2024-09-01 00:05:00")))
    assertFalse(match("5 0 * 8 *", new Date("2024-08-01 01:05:00")))

    assertTrue(match("15 14 1 * *", new Date("2024-02-01 14:15:00")))
    assertFalse(match("15 14 1 * *", new Date("2024-02-01 15:15:00")))
    assertFalse(match("15 14 1 * *", new Date("2024-02-02 14:15:00")))

    assertTrue(match("23 0-20/2 * * 0", new Date("2024-01-07 00:23:00")))
    assertFalse(match("23 0-20/2 * * 0", new Date("2024-01-07 03:23:00")))
    assertFalse(match("23 0-20/2 * * 0", new Date("2024-01-08 00:23:00")))

    assertTrue(match("5 4 * * SUN", new Date("2024-01-07 04:05:00")))
    assertFalse(match("5 4 * * SUN", new Date("2024-01-08 04:05:00")))
    assertFalse(match("5 4 * * SUN", new Date("2025-01-07 04:05:00")))

    assertTrue(match("5 4 * DEC SUN", new Date("2024-12-01 04:05:00")))
    assertFalse(match("5 4 * DEC SUN", new Date("2024-12-01 04:06:00")))
    assertFalse(match("5 4 * DEC SUN", new Date("2024-12-02 04:05:00")))

    assertTrue(match("5 4 * * SUN", new Date("2024-01-07 04:05:00")))
    assertFalse(match("5 4 * * SUN", new Date("2024-01-08 04:05:00")))
    assertFalse(match("5 4 * * SUN", new Date("2025-01-07 04:05:00")))

    assertTrue(match("0 0 1/1 * 1", new Date("2024-01-02 00:00:00")))
    assertTrue(match("0 0 1-31 * 1", new Date("2024-01-02 00:00:00")))
    assertTrue(match("0 0 1 * SUN-SAT", new Date("2024-01-02 00:00:00")))
    assertTrue(match("0 0 * * 7", new Date("2024-01-07 00:00:00")))
    assertFalse(match("0 0 */2 * MON", new Date("2024-01-03 00:00:00")))
    assertTrue(match("0 0 */2 * MON", new Date("2024-01-15 00:00:00")))
    assertTrue(match("0 0 1-31/2 * MON", new Date("2024-01-03 00:00:00")))
    assertFalse(match("0 0 *,5 * MON", new Date("2024-01-03 00:00:00")))
    assertTrue(match("0 0 5,* * MON", new Date("2024-01-03 00:00:00")))

    assertTrue(match("42 5 0 * 8 *", new Date("2024-08-01 00:05:42")))
    assertFalse(match("42 5 0 * 8 *", new Date("2024-09-01 00:05:42")))
    assertFalse(match("42 5 0 * 8 *", new Date("2024-08-01 01:05:42")))

    const london = DateTime.zoneMakeNamedUnsafe("Europe/London")
    const londonTime = DateTime.makeZonedUnsafe("2024-06-01 14:15:00Z", {
      timeZone: london,
      adjustForTimeZone: true
    })

    const amsterdam = DateTime.zoneMakeNamedUnsafe("Europe/Amsterdam")
    const amsterdamTime = DateTime.makeZonedUnsafe("2024-06-01 15:15:00Z", {
      timeZone: amsterdam,
      adjustForTimeZone: true
    })

    assertTrue(match(Cron.parseUnsafe("15 14 1 * *", london), londonTime))
    assertTrue(match(Cron.parseUnsafe("15 14 1 * *", london), amsterdamTime))
  })

  it("next", () => {
    const after = new Date("2024-01-04 16:21:00")
    deepStrictEqual(next("5 0 8 2 *", after), new Date("2024-02-08 00:05:00"))
    deepStrictEqual(next("15 14 1 * *", after), new Date("2024-02-01 14:15:00"))
    deepStrictEqual(next("23 0-20/2 * * 0", after), new Date("2024-01-07 00:23:00"))
    deepStrictEqual(next("5 4 * * SUN", after), new Date("2024-01-07 04:05:00"))
    deepStrictEqual(next("5 4 * DEC SUN", after), new Date("2024-12-01 04:05:00"))
    deepStrictEqual(next("30 5 0 8 2 *", after), new Date("2024-02-08 00:05:30"))

    const london = DateTime.zoneMakeNamedUnsafe("Europe/London")
    const londonTime = DateTime.makeZonedUnsafe("2024-02-08 00:05:00Z", {
      timeZone: london,
      adjustForTimeZone: true
    })

    const amsterdam = DateTime.zoneMakeNamedUnsafe("Europe/Amsterdam")
    const amsterdamTime = DateTime.makeZonedUnsafe("2024-02-08 01:05:00Z", {
      timeZone: amsterdam,
      adjustForTimeZone: true
    })

    deepStrictEqual(next(Cron.parseUnsafe("5 0 8 2 *", london), after), DateTime.toDateUtc(londonTime))
    deepStrictEqual(next(Cron.parseUnsafe("5 0 8 2 *", london), after), DateTime.toDateUtc(amsterdamTime))
  })

  it("next adheres to day-of-month and weekday semantics", () => {
    const utc = DateTime.zoneMakeNamedUnsafe("UTC")
    const after = new Date("2024-01-02T12:00:00.000Z")

    deepStrictEqual(next(Cron.parseUnsafe("0 0 */2 * MON", utc), after), new Date("2024-01-15T00:00:00.000Z"))
    deepStrictEqual(next(Cron.parseUnsafe("0 0 1-31/2 * MON", utc), after), new Date("2024-01-03T00:00:00.000Z"))
    deepStrictEqual(next(Cron.parseUnsafe("0 0 *,5 * MON", utc), after), new Date("2024-01-08T00:00:00.000Z"))
    deepStrictEqual(next(Cron.parseUnsafe("0 0 5,* * MON", utc), after), new Date("2024-01-03T00:00:00.000Z"))
  })

  it("next does not skip earlier days when the upcoming day is missing from the month", () => {
    const tz = DateTime.zoneMakeNamedUnsafe("UTC")
    const cron = Cron.parseUnsafe("0 0 1,16,31 * *", tz)
    // From Feb 18 the next matching day in February would be the 31st, which does
    // not exist. Rolling onto it must not overshoot past March 1 (also a match).
    deepStrictEqual(next(cron, new Date("2020-02-18T00:00:00.000Z")), new Date("2020-03-01T00:00:00.000Z"))
    // `*/15` expands to days [1, 16, 31] and exhibits the same wrap.
    deepStrictEqual(
      next(Cron.parseUnsafe("0 0 */15 * *", tz), new Date("2020-02-18T00:00:00.000Z")),
      new Date("2020-03-01T00:00:00.000Z")
    )
    // 30-day month: from the 20th the next day is the 31st (missing) -> July 1.
    deepStrictEqual(next(cron, new Date("2024-06-20T00:00:00.000Z")), new Date("2024-07-01T00:00:00.000Z"))
  })

  it("next respects day-of-month or weekday constraints when entering a restricted month", () => {
    const tz = DateTime.zoneMakeNamedUnsafe("UTC")
    const after = new Date("2024-01-01T00:00:00.000Z")

    deepStrictEqual(next(Cron.parseUnsafe("0 0 29 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
    deepStrictEqual(next(Cron.parseUnsafe("0 0 30 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
    deepStrictEqual(next(Cron.parseUnsafe("0 0 31 2 MON", tz), after), new Date("2024-02-05T00:00:00.000Z"))
  })

  it("prev", () => {
    const utc = DateTime.zoneMakeNamedUnsafe("UTC")
    const before = new Date("2024-01-04T16:21:00Z")
    deepStrictEqual(prev(Cron.parseUnsafe("5 0 8 2 *", utc), before), new Date("2023-02-08T00:05:00.000Z"))
    deepStrictEqual(prev(Cron.parseUnsafe("15 14 1 * *", utc), before), new Date("2024-01-01T14:15:00.000Z"))
    deepStrictEqual(prev(Cron.parseUnsafe("23 0-20/2 * * * 0", utc), before), new Date("2023-12-31T23:20:23.000Z"))
    deepStrictEqual(prev(Cron.parseUnsafe("5 4 * * SUN", utc), before), new Date("2023-12-31T04:05:00.000Z"))
    deepStrictEqual(prev(Cron.parseUnsafe("5 4 * DEC SUN", utc), before), new Date("2023-12-31T04:05:00.000Z"))
    deepStrictEqual(prev(Cron.parseUnsafe("30 5 0 8 2 *", utc), before), new Date("2023-02-08T00:05:30.000Z"))

    const wednesday = new Date("2025-10-22T01:00:00.000Z")
    deepStrictEqual(prev(Cron.parseUnsafe("0 1 * * MON", utc), wednesday), new Date("2025-10-20T01:00:00.000Z"))
    deepStrictEqual(next(Cron.parseUnsafe("0 1 * * MON", utc), wednesday), new Date("2025-10-27T01:00:00.000Z"))
    deepStrictEqual(prev(Cron.parseUnsafe("0 1 * * TUE", utc), wednesday), new Date("2025-10-21T01:00:00.000Z"))
    deepStrictEqual(next(Cron.parseUnsafe("0 1 * * TUE", utc), wednesday), new Date("2025-10-28T01:00:00.000Z"))
  })

  it("returns the latest second when rolling back a minute", () => {
    const utc = DateTime.zoneMakeNamedUnsafe("UTC")
    const expr = Cron.parseUnsafe("10,30 * * * * *", utc)
    const before = new Date("2024-01-01T00:00:05.000Z")
    deepStrictEqual(prev(expr, before), new Date("2023-12-31T23:59:30.000Z"))
  })

  it("forward and reverse sequences stay aligned", () => {
    const cases = [
      ["5 2 * * 1", "2020-01-01T00:00:01Z", "2021-01-01T00:00:01Z"],
      ["0 12 1 * *", "2020-01-01T00:00:01Z", "2021-01-01T00:00:01Z"],
      ["10,30 * * * * *", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"]
    ] as const

    const gatherForward = (cron: Cron.Cron, lower: Date, upper: Date) => {
      const dates: Array<Date> = []
      let current = lower
      while (true) {
        current = Cron.next(cron, current)
        if (current >= upper) {
          return dates
        }
        dates.push(current)
      }
    }

    const gatherReverse = (cron: Cron.Cron, lower: Date, upper: Date) => {
      const dates: Array<Date> = []
      let current = upper
      while (true) {
        current = Cron.prev(cron, current)
        if (current <= lower) {
          return dates.reverse()
        }
        dates.push(current)
      }
    }

    for (const [expr, lowerStr, upperStr] of cases) {
      const lower = new Date(lowerStr)
      const upper = new Date(upperStr)
      const cron = Cron.parseUnsafe(expr, DateTime.zoneMakeNamedUnsafe("UTC"))

      deepStrictEqual(gatherForward(cron, lower, upper), gatherReverse(cron, lower, upper))
    }
  })

  it("prev prefers the latest matching day within the previous month", () => {
    const cron = Cron.parseUnsafe("0 0 8 5,20 * *", DateTime.zoneMakeNamedUnsafe("UTC"))
    const before = new Date("2024-06-03T00:00:00.000Z")
    deepStrictEqual(prev(cron, before), new Date("2024-05-20T08:00:00.000Z"))
  })

  it("prev wraps weekday using the last allowed value", () => {
    const cron = Cron.parseUnsafe("0 1 * * MON,FRI", DateTime.zoneMakeNamedUnsafe("UTC"))
    const sunday = new Date("2025-10-19T12:00:00.000Z")
    deepStrictEqual(prev(cron, sunday), new Date("2025-10-17T01:00:00.000Z"))
  })

  it("prev chooses the preferred occurrence in DST fall-back", () => {
    const make = (s: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(s))
    const cron = Cron.parseUnsafe("0 30 2 * * *", "Europe/Berlin")
    const before = make("2024-10-27T03:30:00.000+01:00[Europe/Berlin]")
    const result = DateTime.makeZonedUnsafe(prev(cron, before), { timeZone: "Europe/Berlin" })
    deepStrictEqual(result.pipe(DateTime.formatIsoZoned), "2024-10-27T02:30:00.000+02:00[Europe/Berlin]")
  })

  it("prev respects combined day-of-month and weekday constraints", () => {
    const tz = DateTime.zoneMakeNamedUnsafe("UTC")
    const cron = Cron.parseUnsafe("0 0 9 1,15 * MON", tz)
    const before = new Date("2024-04-02T12:00:00.000Z")
    deepStrictEqual(prev(cron, before), new Date("2024-04-01T09:00:00.000Z"))
  })

  it("prev handles step expressions across day boundary", () => {
    const tz = DateTime.zoneMakeNamedUnsafe("UTC")
    const cron = Cron.parseUnsafe("0 */7 8-10 * * *", tz)
    const before = new Date("2024-01-01T08:01:00.000Z")
    deepStrictEqual(prev(cron, before), new Date("2024-01-01T08:00:00.000Z"))
  })

  it("prev works with fixed offset time zones", () => {
    const offset = DateTime.zoneMakeOffset(2 * 60 * 60 * 1000)
    const cron = Cron.parseUnsafe("0 0 10 * * *", offset)
    const before = new Date("2024-05-01T07:00:00.000Z")
    deepStrictEqual(prev(cron, before), new Date("2024-04-30T08:00:00.000Z"))
  })

  it("prev wraps across year boundary", () => {
    const tz = DateTime.zoneMakeNamedUnsafe("UTC")
    const cron = Cron.parseUnsafe("0 0 1 1 *", tz)
    const from = new Date("2024-01-01T00:00:00.000Z")
    deepStrictEqual(prev(cron, from), new Date("2023-01-01T00:00:00.000Z"))
  })

  it("prev handles day 31 skipping months without it", () => {
    const tz = DateTime.zoneMakeNamedUnsafe("UTC")
    const cron = Cron.parseUnsafe("0 0 31 * *", tz)
    const from = new Date("2024-03-01T00:00:00.000Z")
    deepStrictEqual(prev(cron, from), new Date("2024-01-31T00:00:00.000Z"))
  })

  it("prev clamps to the last valid day when rolling back a month with only month constraints", () => {
    const tz = DateTime.zoneMakeNamedUnsafe("UTC")
    const cron = Cron.parseUnsafe("0 0 0 * FEB *", tz)
    const from = new Date("2024-03-31T12:00:00.000Z")
    deepStrictEqual(prev(cron, from), new Date("2024-02-29T00:00:00.000Z"))
  })

  it("prev with multiple months specified", () => {
    const tz = DateTime.zoneMakeNamedUnsafe("UTC")
    const cron = Cron.parseUnsafe("0 0 15 1,4,7,10 *", tz)
    const from = new Date("2024-05-01T00:00:00.000Z")
    deepStrictEqual(prev(cron, from), new Date("2024-04-15T00:00:00.000Z"))
  })

  it("sequence", () => {
    const start = new Date("2024-01-01 00:00:00")
    const generator = Cron.sequence(Cron.parseUnsafe("23 0-20/2 * * 0"), start)
    deepStrictEqual(generator.next().value, new Date("2024-01-07 00:23:00"))
    deepStrictEqual(generator.next().value, new Date("2024-01-07 02:23:00"))
    deepStrictEqual(generator.next().value, new Date("2024-01-07 04:23:00"))
    deepStrictEqual(generator.next().value, new Date("2024-01-07 06:23:00"))
    deepStrictEqual(generator.next().value, new Date("2024-01-07 08:23:00"))
  })

  it("equal", () => {
    const cron = Cron.parseUnsafe("23 0-20/2 * * 0")
    assertTrue(Equal.equals(cron, cron))
    assertTrue(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 0")))
    assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 1")))
    assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 * * 0-6")))
    assertFalse(Equal.equals(cron, Cron.parseUnsafe("23 0-20/2 1 * 0")))
  })

  it("handles leap years", () => {
    assertTrue(match("0 0 29 2 *", new Date("2024-02-29 00:00:00")))
    assertFalse(match("0 0 29 2 *", new Date("2025-02-29 00:00:00")))
    assertFalse(match("0 0 29 2 *", new Date("2026-02-29 00:00:00")))
    assertFalse(match("0 0 29 2 *", new Date("2027-02-29 00:00:00")))
    assertTrue(match("0 0 29 2 *", new Date("2028-02-29 00:00:00")))

    deepStrictEqual(next("0 0 29 2 *", new Date("2024-01-01 00:00:00")), new Date("2024-02-29 00:00:00"))
    deepStrictEqual(next("0 0 29 2 *", new Date("2025-01-01 00:00:00")), new Date("2028-02-29 00:00:00"))
  })

  it("handles transition into daylight savings time", () => {
    const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
    const sequence = Cron.sequence(
      Cron.parseUnsafe("30 * * * *", "Europe/Berlin"),
      make("2024-03-31T00:00:00.000+01:00[Europe/Berlin]")
    )
    const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: "Europe/Berlin" })

    const a = make("2024-03-31T00:30:00.000+01:00[Europe/Berlin]")
    const b = make("2024-03-31T01:30:00.000+01:00[Europe/Berlin]")
    const c = make("2024-03-31T03:30:00.000+02:00[Europe/Berlin]")
    const d = make("2024-03-31T04:30:00.000+02:00[Europe/Berlin]")

    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
  })

  it("handles transition out of daylight savings time", () => {
    // DST fall-back transition in Europe/Berlin on 2024-10-27:
    // At 3:00 AM +02:00, clocks "fall back" to 2:00 AM +01:00
    // This means times from 2:00-2:59 AM occur twice (ambiguous period)
    //
    // Correct "once" mode behavior for cron:
    // - Include all normal times (00:30, 01:30)
    // - Return first occurrence only of ambiguous times (02:30 +02:00)
    // - Skip second occurrence of ambiguous times (02:30 +01:00)
    // - Continue normally after transition (03:30 +01:00)

    const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
    const sequence = Cron.sequence(
      Cron.parseUnsafe("30 * * * *", "Europe/Berlin"),
      make("2024-10-27T00:00:00.000+02:00[Europe/Berlin]")
    )
    const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: "Europe/Berlin" })

    const a = make("2024-10-27T00:30:00.000+02:00[Europe/Berlin]") // Normal time
    const b = make("2024-10-27T01:30:00.000+02:00[Europe/Berlin]") // Normal time (not ambiguous)
    const c = make("2024-10-27T02:30:00.000+02:00[Europe/Berlin]") // First occurrence during DST
    const d = make("2024-10-27T03:30:00.000+01:00[Europe/Berlin]") // Standard time (skips 2nd 02:30)
    const e = make("2024-10-27T04:30:00.000+01:00[Europe/Berlin]") // Standard time

    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), e.pipe(DateTime.formatIsoZoned))
  })

  it("handles utc timezone", () => {
    const utc = DateTime.zoneMakeNamedUnsafe("UTC")
    const make = (date: string): DateTime.Zoned => Option.getOrThrow(DateTime.makeZonedFromString(date))
    const sequence = Cron.sequence(Cron.parseUnsafe("30 * * * *", utc), make("2024-10-27T00:00:00.000+00:00[UTC]"))
    const next = (): DateTime.Zoned => DateTime.makeZonedUnsafe(sequence.next().value, { timeZone: utc })

    const a = make("2024-10-27T00:30:00.000+00:00[UTC]")
    const b = make("2024-10-27T01:30:00.000+00:00[UTC]")
    const c = make("2024-10-27T02:30:00.000+00:00[UTC]")
    const d = make("2024-10-27T03:30:00.000+00:00[UTC]")

    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), a.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), b.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), c.pipe(DateTime.formatIsoZoned))
    deepStrictEqual(next().pipe(DateTime.formatIsoZoned), d.pipe(DateTime.formatIsoZoned))
  })
})
