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

import { addDays } from "@/lib/calendar"
import {
  dueOn,
  instanceKey,
  isDueOn,
  isPastCutOff,
  toGenerate,
  toMarkMissed,
  type OpenInstance,
  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,
})

/** August 2026: the 3rd is a Monday, the 8th a Saturday, the 9th a Sunday. */
describe("daily", () => {
  test("exists on every working day of the holder's own week", () => {
    const fiveDay = template({ pattern: "mon_fri" })
    const sixDay = template({ pattern: "mon_sat" })
    expect(isDueOn(fiveDay, "2026-08-07", none)).toBe(true)
    expect(isDueOn(fiveDay, "2026-08-08", none)).toBe(false)
    expect(isDueOn(sixDay, "2026-08-08", none)).toBe(true)
    expect(isDueOn(sixDay, "2026-08-09", none)).toBe(false)
  })

  test("does not exist on a holiday", () => {
    expect(isDueOn(template(), "2026-08-05", new Set(["2026-08-05"]))).toBe(
      false,
    )
  })
})

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

  test("falls on its own day and no other", () => {
    expect(isDueOn(thursday, "2026-08-06", none)).toBe(true)
    expect(isDueOn(thursday, "2026-08-05", none)).toBe(false)
    expect(isDueOn(thursday, "2026-08-13", none)).toBe(true)
  })

  test("moves forward when its day is a holiday", () => {
    const holidays = new Set(["2026-08-06"])
    expect(isDueOn(thursday, "2026-08-06", holidays)).toBe(false)
    expect(isDueOn(thursday, "2026-08-07", holidays)).toBe(true)
  })

  /**
   * A weekly duty set to a Saturday for somebody who does not work Saturdays is
   * a mistake in the template. Moving it to Monday would hide the mistake and
   * quietly change which week the work belongs to.
   */
  test("a day the holder never works produces nothing rather than moving", () => {
    const saturday = template({
      recurrence: "weekly",
      dayOfWeek: 6,
      pattern: "mon_fri",
    })
    for (let i = 0; i < 14; i++) {
      expect(isDueOn(saturday, addDays("2026-08-03", i), none)).toBe(false)
    }
  })
})

describe("monthly", () => {
  test("falls on its fixed day", () => {
    const fifteenth = template({ recurrence: "monthly", dayOfMonth: 15 })
    expect(isDueOn(fifteenth, "2026-09-15", none)).toBe(true)
    expect(isDueOn(fifteenth, "2026-09-14", none)).toBe(false)
  })

  test("clamps to a month that is shorter than the day asked for", () => {
    const thirtyFirst = template({ recurrence: "monthly", dayOfMonth: 31 })
    // April has 30 days, and 30 April 2026 is a Thursday.
    expect(isDueOn(thirtyFirst, "2026-04-30", none)).toBe(true)
    // February 2026 ends on the 28th, a Saturday, so a five-day week moves it.
    expect(isDueOn(thirtyFirst, "2026-02-28", none)).toBe(false)
    expect(isDueOn(thirtyFirst, "2026-03-02", none)).toBe(true)
  })

  /**
   * The case the previous-period lookback exists for. 31 May 2026 clamps to the
   * 31st, a Sunday, so it moves into June. Asking only about June's own fixed
   * day would never produce it.
   */
  test("a shifted day that lands in the next month is still generated", () => {
    const thirtyFirst = template({ recurrence: "monthly", dayOfMonth: 31 })
    expect(isDueOn(thirtyFirst, "2026-06-01", none)).toBe(true)
  })

  test("a holiday pushes it further, not off the calendar", () => {
    const fifteenth = template({ recurrence: "monthly", dayOfMonth: 15 })
    const holidays = new Set(["2026-09-15", "2026-09-16"])
    expect(isDueOn(fifteenth, "2026-09-15", holidays)).toBe(false)
    expect(isDueOn(fifteenth, "2026-09-17", holidays)).toBe(true)
  })
})

describe("quarterly", () => {
  const q = template({ recurrence: "quarterly", dayOfMonth: 10 })

  test("fires in January, April, July and October, and nowhere else", () => {
    expect(isDueOn(q, "2026-04-10", none)).toBe(true)
    expect(isDueOn(q, "2026-07-10", none)).toBe(true)
    expect(isDueOn(q, "2026-05-11", none)).toBe(false)
    expect(isDueOn(q, "2026-06-10", none)).toBe(false)
  })
})

describe("active", () => {
  test("a deactivated template stops generating", () => {
    expect(isDueOn(template({ active: false }), "2026-08-03", none)).toBe(false)
  })
})

describe("toGenerate", () => {
  const templates = [
    template({ id: "daily" }),
    template({ id: "thursday", recurrence: "weekly", dayOfWeek: 4 }),
  ]

  test("returns what is due and not already there", () => {
    expect(dueOn(templates, "2026-08-06", none).map((t) => t.id)).toEqual([
      "daily",
      "thursday",
    ])
  })

  /**
   * Running twice changes nothing. This is the whole reason the key exists, and
   * it is the property that breaks silently: a generator that duplicates looks
   * fine until somebody counts.
   */
  test("a second run on the same day produces nothing", () => {
    const first = toGenerate(templates, "2026-08-06", none)
    expect(first).toHaveLength(2)

    const written = new Set(first.map((t) => instanceKey(t.id, "2026-08-06")))
    expect(toGenerate(templates, "2026-08-06", written)).toEqual([])
  })

  test("a day already half generated only fills the gap", () => {
    const written = new Set([instanceKey("daily", "2026-08-06")])
    expect(
      toGenerate(templates, "2026-08-06", written).map((t) => t.id),
    ).toEqual(["thursday"])
  })

  test("catching up several days generates each of them once", () => {
    const written = new Set<string>()
    const days = ["2026-08-03", "2026-08-04", "2026-08-05"]
    for (const day of days) {
      for (const t of toGenerate(templates, day, written)) {
        written.add(instanceKey(t.id, day))
      }
    }
    expect(written.size).toBe(3)
    for (const day of days) {
      expect(toGenerate(templates, day, written)).toEqual([])
    }
  })
})

describe("isPastCutOff", () => {
  test("a daily task runs out at the end of its own day", () => {
    expect(isPastCutOff("daily", "2026-08-05", "2026-08-05")).toBe(false)
    expect(isPastCutOff("daily", "2026-08-05", "2026-08-06")).toBe(true)
  })

  test("a weekly task has until the end of its week", () => {
    // Due Thursday 6 August; the week ends on Sunday the 9th.
    expect(isPastCutOff("weekly", "2026-08-06", "2026-08-07")).toBe(false)
    expect(isPastCutOff("weekly", "2026-08-06", "2026-08-09")).toBe(false)
    expect(isPastCutOff("weekly", "2026-08-06", "2026-08-10")).toBe(true)
  })

  test("a monthly task has until the end of its month", () => {
    expect(isPastCutOff("monthly", "2026-08-15", "2026-08-31")).toBe(false)
    expect(isPastCutOff("monthly", "2026-08-15", "2026-09-01")).toBe(true)
  })

  test("the cycle is what differs, not the clock", () => {
    const day = "2026-08-06"
    const on = "2026-08-07"
    expect(isPastCutOff("daily", day, on)).toBe(true)
    expect(isPastCutOff("weekly", day, on)).toBe(false)
    expect(isPastCutOff("monthly", day, on)).toBe(false)
  })
})

describe("toMarkMissed", () => {
  const open: OpenInstance[] = [
    { key: "a", recurrence: "daily", dueDate: "2026-08-05", status: "todo" },
    { key: "b", recurrence: "daily", dueDate: "2026-08-06", status: "started" },
    { key: "c", recurrence: "weekly", dueDate: "2026-08-06", status: "todo" },
    { key: "d", recurrence: "monthly", dueDate: "2026-08-15", status: "todo" },
  ]

  test("marks only what has run out of time", () => {
    expect(toMarkMissed(open, "2026-08-06").map((i) => i.key)).toEqual(["a"])
  })

  test("started work is marked too, because started is not finished", () => {
    expect(toMarkMissed(open, "2026-08-07").map((i) => i.key)).toEqual([
      "a",
      "b",
    ])
  })

  test("the weekly one waits for its week and the monthly one for its month", () => {
    expect(toMarkMissed(open, "2026-08-10").map((i) => i.key)).toEqual([
      "a",
      "b",
      "c",
    ])
    expect(toMarkMissed(open, "2026-09-01").map((i) => i.key)).toEqual([
      "a",
      "b",
      "c",
      "d",
    ])
  })

  /**
   * The answer to point C: it marks and never removes. Everything it names is
   * still open work somebody can still do, and running it again names the same
   * things rather than doing something new to them.
   */
  test("running it twice names the same instances and changes nothing else", () => {
    const first = toMarkMissed(open, "2026-08-07")
    const second = toMarkMissed(open, "2026-08-07")
    expect(second).toEqual(first)
    expect(open).toHaveLength(4)
  })
})
