import { assert, describe, expect, it } from "@effect/vitest"
import { Array, Deferred, Duration, Effect, Fiber, Pull, Random, Result, Schedule } from "effect"
import { constant, constUndefined } from "effect/Function"
import { TestClock } from "effect/testing"

describe("Schedule", () => {
  describe("combining", () => {
    it.effect("max - outputs the slowest schedule duration", () =>
      Effect.gen(function*() {
        const schedule = Schedule.max([
          Schedule.fixed("5 seconds"),
          Schedule.exponential("5 seconds"),
          Schedule.spaced("10 seconds")
        ])
        const inputs = Array.makeBy(3, constUndefined)
        const outputs = yield* runDelays(schedule, inputs)
        assert.deepStrictEqual(outputs, [
          Duration.seconds(10),
          Duration.seconds(10),
          Duration.seconds(20)
        ])
      }))

    it.effect("max - stops when any schedule completes", () =>
      Effect.gen(function*() {
        const schedule = Schedule.max([
          Schedule.duration("1 second"),
          Schedule.spaced("5 seconds")
        ])
        const inputs = Array.makeBy(3, constUndefined)
        const outputs = yield* runDelays(schedule, inputs)
        assert.deepStrictEqual(outputs, [
          Duration.seconds(5),
          Duration.zero
        ])
      }))

    it.effect("min - outputs the fastest schedule duration", () =>
      Effect.gen(function*() {
        const schedule = Schedule.min([
          Schedule.fixed("5 seconds"),
          Schedule.exponential("5 seconds"),
          Schedule.spaced("10 seconds")
        ])
        const inputs = Array.makeBy(3, constUndefined)
        const outputs = yield* runDelays(schedule, inputs)
        assert.deepStrictEqual(outputs, [
          Duration.seconds(5),
          Duration.seconds(5),
          Duration.seconds(5)
        ])
      }))

    it.effect("min - continues after a schedule completes", () =>
      Effect.gen(function*() {
        const schedule = Schedule.min([
          Schedule.duration("1 second"),
          Schedule.spaced("5 seconds")
        ])
        const inputs = Array.makeBy(3, constUndefined)
        const outputs = yield* runDelays(schedule, inputs)
        assert.deepStrictEqual(outputs, [
          Duration.seconds(1),
          Duration.seconds(5),
          Duration.seconds(5)
        ])
      }))
  })

  describe("sequencing", () => {
    it.effect("tap - provides full metadata", () =>
      Effect.gen(function*() {
        const observed: Array<Schedule.Metadata<number, string>> = []
        const schedule = Schedule.spaced(Duration.millis(250)).pipe(
          Schedule.tap((metadata: Schedule.Metadata<number, string>) =>
            Effect.sync(() => {
              observed.push(metadata)
            })
          )
        )
        const step = yield* Schedule.toStep(schedule)
        const first = yield* step(1_000, "a")
        const second = yield* step(1_250, "b")

        expect(first).toEqual([0, Duration.millis(250)])
        expect(second).toEqual([1, Duration.millis(250)])
        expect(observed).toEqual([
          {
            input: "a",
            output: 0,
            duration: Duration.millis(250),
            attempt: 1,
            start: 1_000,
            now: 1_000,
            elapsed: 0,
            elapsedSincePrevious: 0
          },
          {
            input: "b",
            output: 1,
            duration: Duration.millis(250),
            attempt: 2,
            start: 1_000,
            now: 1_250,
            elapsed: 250,
            elapsedSincePrevious: 250
          }
        ])
      }))

    it.effect("modifyDelay - provides full metadata", () =>
      Effect.gen(function*() {
        const observed: Array<Schedule.Metadata<number, string>> = []
        const schedule = Schedule.spaced(Duration.millis(250)).pipe(
          Schedule.modifyDelay((metadata: Schedule.Metadata<number, string>) =>
            Effect.sync(() => {
              observed.push(metadata)
              return Duration.sum(metadata.duration, Duration.millis(metadata.elapsedSincePrevious))
            })
          )
        )
        const step = yield* Schedule.toStep(schedule)
        const first = yield* step(1_000, "a")
        const second = yield* step(1_250, "b")

        assert.deepStrictEqual(first, [0, Duration.millis(250)])
        assert.deepStrictEqual(second, [1, Duration.millis(500)])
        assert.deepStrictEqual(observed, [
          {
            input: "a",
            output: 0,
            duration: Duration.millis(250),
            attempt: 1,
            start: 1_000,
            now: 1_000,
            elapsed: 0,
            elapsedSincePrevious: 0
          },
          {
            input: "b",
            output: 1,
            duration: Duration.millis(250),
            attempt: 2,
            start: 1_000,
            now: 1_250,
            elapsed: 250,
            elapsedSincePrevious: 250
          }
        ])
      }))

    it.effect("addDelay - provides full metadata", () =>
      Effect.gen(function*() {
        const observed: Array<Schedule.Metadata<number, string>> = []
        const schedule = Schedule.spaced(Duration.millis(250)).pipe(
          Schedule.addDelay((metadata: Schedule.Metadata<number, string>) =>
            Effect.sync(() => {
              observed.push(metadata)
              return Duration.millis(metadata.elapsedSincePrevious)
            })
          )
        )
        const step = yield* Schedule.toStep(schedule)
        const first = yield* step(1_000, "a")
        const second = yield* step(1_250, "b")

        assert.deepStrictEqual(first, [0, Duration.millis(250)])
        assert.deepStrictEqual(second, [1, Duration.millis(500)])
        assert.deepStrictEqual(observed, [
          {
            input: "a",
            output: 0,
            duration: Duration.millis(250),
            attempt: 1,
            start: 1_000,
            now: 1_000,
            elapsed: 0,
            elapsedSincePrevious: 0
          },
          {
            input: "b",
            output: 1,
            duration: Duration.millis(250),
            attempt: 2,
            start: 1_000,
            now: 1_250,
            elapsed: 250,
            elapsedSincePrevious: 250
          }
        ])
      }))

    it.effect("andThenResult - sequences self then other when collecting delays", () =>
      Effect.gen(function*() {
        const left = Schedule.fixed("500 millis").pipe(
          Schedule.while(({ attempt }) => Effect.succeed(attempt <= 3))
        )
        const right = Schedule.fixed("1 second")
        const schedule = Schedule.andThenResult(left, right)
        const inputs = Array.makeBy(6, constUndefined)
        const outputs = yield* runDelays(schedule, inputs)
        expect(outputs).toEqual([
          Duration.millis(500),
          Duration.millis(500),
          Duration.millis(500),
          Duration.seconds(1),
          Duration.seconds(1),
          Duration.seconds(1)
        ])
      }))

    it.effect("andThenResult - includes finite other completion when collecting delays", () =>
      Effect.gen(function*() {
        const left = Schedule.fixed("500 millis").pipe(
          Schedule.while(({ attempt }) => Effect.succeed(attempt <= 2))
        )
        const right = Schedule.duration("1 second")
        const schedule = Schedule.andThenResult(left, right)
        const inputs = Array.makeBy(5, constUndefined)
        const outputs = yield* runDelays(schedule, inputs)
        expect(outputs).toEqual([
          Duration.millis(500),
          Duration.millis(500),
          Duration.seconds(1),
          Duration.zero
        ])
      }))

    it.effect("andThenResult - wraps self outputs as Failure and other outputs as Success", () =>
      Effect.gen(function*() {
        const left = Schedule.identity<string>().pipe(Schedule.upTo({ times: 2 }))
        const right = Schedule.identity<string>()
        const step = yield* Schedule.toStep(Schedule.andThenResult(left, right))

        const first = yield* step(0, "left-1")
        const second = yield* step(0, "left-2")
        const third = yield* step(0, "right-1")

        assert.deepStrictEqual([first, second, third], [
          [Result.fail("left-1"), Duration.zero],
          [Result.fail("left-2"), Duration.zero],
          [Result.succeed("right-1"), Duration.zero]
        ])
      }))
  })

  describe("cron", () => {
    it.effect("should recur on interval matching cron expression", () =>
      Effect.gen(function*() {
        const now = new Date(2024, 0, 1, 0, 0, 35).getTime()
        // At every second minute
        const schedule = Schedule.cron("*/2 * * * *")
        const inputs = Array.makeBy(4, constUndefined)
        yield* TestClock.setTime(now)
        const [, outputs] = yield* runDelays(schedule, inputs).pipe(
          Effect.map(Array.mapAccum(now, (next, delay) => {
            const timestamp = next + Duration.toMillis(delay)
            return [timestamp, format(timestamp)]
          }))
        )
        expect(outputs).toEqual([
          "Mon Jan 01 2024 00:02:00",
          "Mon Jan 01 2024 00:04:00",
          "Mon Jan 01 2024 00:06:00",
          "Mon Jan 01 2024 00:08:00"
        ])
      }))

    it.effect("should recur on interval matching cron expression (second granularity)", () =>
      Effect.gen(function*() {
        const now = new Date(2024, 0, 1, 0, 0, 0).getTime()
        // At every third minute
        const schedule = Schedule.cron("*/3 * * * * *")
        const inputs = Array.makeBy(10, constUndefined)
        yield* TestClock.setTime(now)
        const [, outputs] = yield* runDelays(schedule, inputs).pipe(
          Effect.map(Array.mapAccum(now, (next, delay) => {
            const timestamp = next + Duration.toMillis(delay)
            return [timestamp, format(timestamp)]
          }))
        )
        expect(outputs).toEqual([
          "Mon Jan 01 2024 00:00:03",
          "Mon Jan 01 2024 00:00:06",
          "Mon Jan 01 2024 00:00:09",
          "Mon Jan 01 2024 00:00:12",
          "Mon Jan 01 2024 00:00:15",
          "Mon Jan 01 2024 00:00:18",
          "Mon Jan 01 2024 00:00:21",
          "Mon Jan 01 2024 00:00:24",
          "Mon Jan 01 2024 00:00:27",
          "Mon Jan 01 2024 00:00:30"
        ])
      }))

    it.effect("should recur at time matching cron expression", () =>
      Effect.gen(function*() {
        const now = new Date(2024, 0, 1, 0, 0, 0).getTime()
        // At 04:30 on day-of-month 5 and 15 and on Wednesday.
        const schedule = Schedule.cron("30 4 5,15 * WED")
        const inputs = Array.makeBy(6, constUndefined)
        yield* TestClock.setTime(now)
        const [, outputs] = yield* runDelays(schedule, inputs).pipe(
          Effect.map(Array.mapAccum(now, (next, delay) => {
            const timestamp = next + Duration.toMillis(delay)
            return [timestamp, format(timestamp)]
          }))
        )
        expect(outputs).toEqual([
          "Wed Jan 03 2024 04:30:00",
          "Fri Jan 05 2024 04:30:00",
          "Wed Jan 10 2024 04:30:00",
          "Mon Jan 15 2024 04:30:00",
          "Wed Jan 17 2024 04:30:00",
          "Wed Jan 24 2024 04:30:00"
        ])
      }))

    it.effect("does not fail when the test clock is adjusted to infinity", () =>
      Effect.gen(function*() {
        const latch = yield* Deferred.make<void>()
        const fiber = yield* Deferred.await(latch).pipe(
          Effect.repeat(Schedule.cron("0 0 4 8-14 * *", "UTC")),
          Effect.forkChild
        )

        yield* TestClock.adjust(Infinity)
        yield* Deferred.succeed(latch, void 0)
        yield* Fiber.join(fiber)
      }))
  })

  describe("duration", () => {
    it.effect("recurs once after the provided duration", () =>
      Effect.gen(function*() {
        const schedule = Schedule.duration(Duration.seconds(1))
        const inputs = Array.makeBy(5, constUndefined)
        const output = yield* runDelays(schedule, inputs)
        expect(output).toEqual([Duration.seconds(1), Duration.zero])
      }))
  })

  describe("upTo", () => {
    it.effect("limits by times", () =>
      Effect.gen(function*() {
        const schedule = Schedule.identity<string>().pipe(Schedule.upTo({ times: 2 }))
        const step = yield* Schedule.toStep(schedule)

        const first = yield* step(0, "a")
        const second = yield* step(0, "b")
        const third = yield* Pull.matchEffect(step(0, "c"), {
          onSuccess: () => Effect.succeed("unexpected success"),
          onFailure: () => Effect.succeed("unexpected failure"),
          onDone: (value) => Effect.succeed(value)
        })

        assert.deepStrictEqual([first, second, third], [
          ["a", Duration.zero],
          ["b", Duration.zero],
          "c"
        ])
      }))

    it.effect("limits by duration", () =>
      Effect.gen(function*() {
        const schedule = Schedule.identity<string>().pipe(Schedule.upTo({ duration: "1 second" }))
        const step = yield* Schedule.toStep(schedule)

        const first = yield* step(0, "a")
        const second = yield* step(1_000, "b")
        const third = yield* Pull.matchEffect(step(1_001, "c"), {
          onSuccess: () => Effect.succeed("unexpected success"),
          onFailure: () => Effect.succeed("unexpected failure"),
          onDone: (value) => Effect.succeed(value)
        })

        assert.deepStrictEqual([first, second, third], [
          ["a", Duration.zero],
          ["b", Duration.zero],
          "c"
        ])
      }))

    it.effect("limits by the first exhausted option", () =>
      Effect.gen(function*() {
        const schedule = Schedule.identity<string>().pipe(Schedule.upTo({ duration: "1 hour", times: 1 }))
        const step = yield* Schedule.toStep(schedule)

        const first = yield* step(0, "a")
        const second = yield* Pull.matchEffect(step(0, "b"), {
          onSuccess: () => Effect.succeed("unexpected success"),
          onFailure: () => Effect.succeed("unexpected failure"),
          onDone: (value) => Effect.succeed(value)
        })

        assert.deepStrictEqual([first, second], [["a", Duration.zero], "b"])
      }))

    it.effect("leaves the schedule unchanged when no options are specified", () =>
      Effect.gen(function*() {
        const schedule = Schedule.identity<string>().pipe(Schedule.upTo({}))
        const step = yield* Schedule.toStep(schedule)

        const first = yield* step(0, "a")
        const second = yield* step(0, "b")

        assert.deepStrictEqual([first, second], [
          ["a", Duration.zero],
          ["b", Duration.zero]
        ])
      }))
  })

  describe("jittered", () => {
    it.effect("keeps delays within 80%-120% of the original", () =>
      Effect.gen(function*() {
        const schedule = Schedule.jittered(Schedule.spaced(Duration.seconds(1)))
        const inputs = Array.makeBy(20, constUndefined)
        const output = yield* runDelays(schedule, inputs).pipe(Random.withSeed("jittered-bounds"))
        expect(output.every((delay) => {
          const millis = Duration.toMillis(delay)
          return millis >= 800 && millis <= 1200
        })).toBe(true)
      }))

    it.effect("does not change completion output", () =>
      Effect.gen(function*() {
        const schedule = Schedule.jittered(Schedule.duration(Duration.seconds(1)))
        const inputs = Array.makeBy(5, constUndefined)
        const output = yield* runDelays(schedule, inputs).pipe(Random.withSeed("jittered-completion"))
        expect(output.length).toEqual(2)
        expect(Duration.toMillis(output[0])).toBeGreaterThanOrEqual(800)
        expect(Duration.toMillis(output[0])).toBeLessThanOrEqual(1200)
        expect(output[1]).toEqual(Duration.zero)
      }))
  })

  describe("spaced", () => {
    it.effect("constant delays", () =>
      Effect.gen(function*() {
        const schedule = Schedule.spaced(Duration.seconds(1))
        const inputs = Array.makeBy(5, constUndefined)
        const output = yield* runDelays(schedule, inputs)
        expect(output).toEqual(Array.makeBy(5, constant(Duration.seconds(1))))
      }))
  })

  describe("fixed", () => {
    it.effect("constant delays", () =>
      Effect.gen(function*() {
        const schedule = Schedule.fixed(Duration.seconds(1))
        const inputs = Array.makeBy(5, constUndefined)
        const output = yield* runDelays(schedule, inputs)
        expect(output).toEqual(Array.makeBy(5, constant(Duration.seconds(1))))
      }))

    it.effect("delays until the nearest window boundary when action is slow", () =>
      Effect.gen(function*() {
        const delays: Array<Duration.Duration> = []
        const schedule = Schedule.fixed("1 seconds").pipe(
          Schedule.while(({ attempt }) => Effect.succeed(attempt <= 5)),
          Schedule.tap((metadata) =>
            Effect.sync(() => {
              delays.push(metadata.duration)
            })
          )
        )
        yield* Effect.sleep("500 millis").pipe(
          Effect.schedule(schedule),
          Effect.andThen(Effect.sync(() => delays.push(Duration.zero))),
          Effect.forkChild
        )
        yield* TestClock.setTime(Number.POSITIVE_INFINITY)
        expect(delays).toEqual([
          Duration.millis(1000),
          Duration.millis(500),
          Duration.millis(500),
          Duration.millis(500),
          Duration.millis(500),
          Duration.zero
        ])
      }))

    it.effect("matches effect v3 when action duration exceeds the interval", () =>
      Effect.gen(function*() {
        const delays: Array<Duration.Duration> = []
        const schedule = Schedule.fixed("1 seconds").pipe(
          Schedule.while(({ attempt }) => Effect.succeed(attempt <= 5)),
          Schedule.tap((metadata) =>
            Effect.sync(() => {
              delays.push(metadata.duration)
            })
          )
        )
        yield* Effect.sleep("1.5 seconds").pipe(
          Effect.schedule(schedule),
          Effect.andThen(Effect.sync(() => delays.push(Duration.zero))),
          Effect.forkChild
        )
        yield* TestClock.setTime(Number.POSITIVE_INFINITY)
        expect(delays).toEqual([
          Duration.millis(1000),
          Duration.zero,
          Duration.zero,
          Duration.zero,
          Duration.zero,
          Duration.zero
        ])
      }))
  })

  describe("windowed", () => {
    it.effect("constant delays", () =>
      Effect.gen(function*() {
        const schedule = Schedule.windowed(Duration.seconds(1))
        const inputs = Array.makeBy(5, constUndefined)
        const output = yield* runDelays(schedule, inputs)
        expect(output).toEqual(Array.makeBy(5, constant(Duration.seconds(1))))
      }))

    it.effect("delays until the nearest window boundary", () =>
      Effect.gen(function*() {
        const delays: Array<Duration.Duration> = []
        const schedule = Schedule.windowed("1 seconds").pipe(
          Schedule.while(({ attempt }) => Effect.succeed(attempt <= 5)),
          Schedule.tap((metadata) =>
            Effect.sync(() => {
              delays.push(metadata.duration)
            })
          )
        )
        yield* Effect.sleep("1.5 seconds").pipe(
          Effect.schedule(schedule),
          Effect.andThen(Effect.sync(() => delays.push(Duration.zero))),
          Effect.forkChild
        )
        yield* TestClock.setTime(Number.POSITIVE_INFINITY)
        expect(delays).toEqual([
          Duration.millis(1000),
          Duration.millis(500),
          Duration.millis(500),
          Duration.millis(500),
          Duration.millis(500),
          Duration.zero
        ])
      }))
  })
})

const run = Effect.fnUntraced(function*<A, E, R>(effect: Effect.Effect<A, E, R>) {
  const fiber = yield* Effect.forkChild(effect)
  yield* TestClock.setTime(Number.POSITIVE_INFINITY)
  return yield* Fiber.join(fiber)
})

const runDelays = <Output, Input, Error, Env>(
  schedule: Schedule.Schedule<Output, Input, Error, Env>,
  input: Iterable<Input>
) =>
  Effect.gen(function*() {
    const step = yield* Schedule.toStepWithMetadata(schedule)
    const out: Array<Duration.Duration> = []
    yield* Effect.gen(function*() {
      for (const value of input) {
        out.push((yield* step(value)).duration)
      }
    }).pipe(Pull.catchDone(() => {
      out.push(Duration.zero)
      return Effect.void
    }))
    return out
  }).pipe(run)

const format = (timestamp: number | string | Date): string => {
  const date = new Date(timestamp)
  const hours = `0${date.getHours()}`.slice(-2)
  const minutes = `0${date.getMinutes()}`.slice(-2)
  const seconds = `0${date.getSeconds()}`.slice(-2)
  return `${date.toDateString()} ${hours}:${minutes}:${seconds}`
}
