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

import { EN } from "@/lib/i18n/messages"
import {
  OCCURRENCES,
  becomesTasks,
  describeSchedule,
  invalidFields,
  isDayOfMonth,
  isDeadline,
  isMinutes,
  isRecurring,
  isWeekday,
  minutesOf,
  missingFields,
  occurrencesFor,
  scheduleFor,
  type Draft,
  type Occurrence,
  type Refusal,
  type Schedule,
} from "@/lib/task-form"

const draft = (over: Partial<Draft> = {}): Draft => ({
  positionId: "line-leader-packing",
  description: "Record packing output",
  occurrence: "daily",
  deadline: "",
  dayOfWeek: "",
  dayOfMonth: "",
  checker: "somchai",
  verifier: "",
  report: "",
  sopUrl: "",
  exampleUrl: "",
  minutes: "",
  customer: "",
  ...over,
})

describe("scheduleFor", () => {
  const cases: [Occurrence, Schedule["kind"]][] = [
    ["once", "deadline"],
    ["daily", "none"],
    ["weekly", "day_of_week"],
    ["monthly", "day_of_month"],
    ["quarterly", "day_of_month"],
    ["general", "not_a_task"],
  ]
  for (const [occurrence, kind] of cases) {
    test(`${occurrence} asks for ${kind}`, () => {
      expect(scheduleFor(occurrence).kind).toBe(kind)
    })
  }
})

describe("becomesTasks", () => {
  test("a general duty never generates anything", () => {
    expect(becomesTasks("general")).toBe(false)
  })
  test("everything else does", () => {
    for (const o of [
      "once",
      "daily",
      "weekly",
      "monthly",
      "quarterly",
    ] as const) {
      expect(becomesTasks(o)).toBe(true)
    }
  })
  test("only a repeating duty belongs to a position for good", () => {
    expect(isRecurring("once")).toBe(false)
    expect(isRecurring("daily")).toBe(true)
    expect(isRecurring("general")).toBe(false)
  })
})

/**
 * Open point G, answered by the client on 3 August: "Recurring self to-dos are
 * saved and visible to the manager. One-time self to-dos they assign themselves
 * are not needed."
 *
 * Written out as four literal names rather than derived from `isRecurring`,
 * because a test that recomputed the list the way the code does would agree
 * with the code whatever the code said, including the day somebody widens it.
 */
describe("occurrencesFor", () => {
  test("a staff member gets the four that repeat and nothing else", () => {
    expect([...occurrencesFor("staff")]).toEqual([
      "daily",
      "weekly",
      "monthly",
      "quarterly",
    ])
  })

  test("the one-time duty the client turned down is not among them", () => {
    expect(occurrencesFor("staff")).not.toContain("once")
  })

  /** A general duty is a line in a job description, and that screen is an admin's. */
  test("nor is the one that never becomes work", () => {
    expect(occurrencesFor("staff")).not.toContain("general")
  })

  test("the two tiers that assign keep all six", () => {
    for (const tier of ["manager", "admin"] as const) {
      expect([...occurrencesFor(tier)]).toEqual([...OCCURRENCES])
    }
  })

  /** Whatever a staff member may create, the engine must generate from. */
  test("everything a staff member may create repeats", () => {
    for (const occurrence of occurrencesFor("staff")) {
      expect(isRecurring(occurrence)).toBe(true)
    }
  })
})

describe("missingFields", () => {
  test("a complete daily duty is missing nothing", () => {
    expect(missingFields(draft())).toEqual([])
  })

  test("a position and a description are always required", () => {
    expect(missingFields(draft({ positionId: "" }))).toContain("positionId")
    expect(missingFields(draft({ description: "   " }))).toContain(
      "description",
    )
  })

  test("a one-off needs its date, and only a one-off does", () => {
    expect(missingFields(draft({ occurrence: "once" }))).toContain("deadline")
    expect(missingFields(draft({ occurrence: "daily" }))).not.toContain(
      "deadline",
    )
  })

  test("a weekly duty needs its day, a monthly one needs a different field", () => {
    expect(missingFields(draft({ occurrence: "weekly" }))).toContain(
      "dayOfWeek",
    )
    expect(missingFields(draft({ occurrence: "monthly" }))).toContain(
      "dayOfMonth",
    )
    expect(missingFields(draft({ occurrence: "weekly" }))).not.toContain(
      "dayOfMonth",
    )
  })

  test("every task is checked by somebody", () => {
    expect(missingFields(draft({ checker: "" }))).toContain("checker")
  })

  /**
   * `functional-spec.md` Q1: a task is complete at `checked`, and verification
   * is an audit of a sample that may never happen. A duty naming nobody to
   * audit it is the ordinary duty, not an incomplete one.
   */
  test("nothing under the verifier or the extras is ever missing", () => {
    expect(missingFields(draft())).toEqual([])
    expect(
      missingFields(
        draft({
          verifier: "",
          report: "",
          sopUrl: "",
          exampleUrl: "",
          minutes: "",
          customer: "",
        }),
      ),
    ).toEqual([])
  })

  /**
   * A general duty is not a task, so the fields that make a task work do not
   * apply to it. Asking for a checker on something nobody ever checks would be
   * the form arguing with the model.
   */
  test("a general duty asks for nothing a task needs", () => {
    const missing = missingFields(
      draft({ occurrence: "general", checker: "", deadline: "" }),
    )
    expect(missing).toEqual([])
  })

  test("a general duty still needs a position and a description", () => {
    const missing = missingFields(
      draft({ occurrence: "general", positionId: "", description: "" }),
    )
    expect(missing).toEqual(["positionId", "description"])
  })
})

/**
 * The number 99 was accepted here until today: `missingFields` only asked
 * whether the box was empty, the summary read "Day 99 of every month", the
 * insert failed `recurring_templates_day_of_month_range`, and the screen said
 * the system had not answered. Every value below is refused or accepted before
 * the round trip, and `createDuty` asks the same two questions again.
 */
describe("invalidFields", () => {
  const monthly = (dayOfMonth: string) =>
    invalidFields(draft({ occurrence: "monthly", dayOfMonth }))

  test("a day of the month is 1 to 31", () => {
    for (const bad of ["0", "32", "99", "-1", "1.5", "abc"]) {
      expect(monthly(bad)).toEqual(["dayOfMonth"])
    }
    for (const good of ["1", "15", "28", "31"]) {
      expect(monthly(good)).toEqual([])
    }
  })

  test("an empty day of the month is missing, not wrong", () => {
    expect(monthly("")).toEqual([])
    expect(missingFields(draft({ occurrence: "monthly" }))).toContain(
      "dayOfMonth",
    )
  })

  /**
   * A quarterly duty fixes a day the same way a monthly one does, and a weekly
   * one has no such field: 99 typed into it and then switched to weekly is 99
   * nothing will store.
   */
  test("the range is asked of exactly the occurrences that have the field", () => {
    expect(
      invalidFields(draft({ occurrence: "quarterly", dayOfMonth: "99" })),
    ).toEqual(["dayOfMonth"])
    expect(
      invalidFields(draft({ occurrence: "weekly", dayOfMonth: "99" })),
    ).toEqual([])
  })

  test("time to do is whole minutes, 1 or more, or nothing at all", () => {
    for (const bad of ["0", "-5", "12.5", "abc"]) {
      expect(invalidFields(draft({ minutes: bad }))).toEqual(["minutes"])
    }
    for (const good of ["1", "15", "480"]) {
      expect(invalidFields(draft({ minutes: good }))).toEqual([])
    }
    expect(invalidFields(draft({ minutes: "" }))).toEqual([])
  })

  test("an empty box is nothing typed and not a zero", () => {
    expect(minutesOf(draft({ minutes: "" }))).toBeNull()
    expect(minutesOf(draft({ minutes: "0" }))).toBe(0)
  })

  /**
   * The same two questions the service asks of a body it did not draw. `NaN`
   * survives a Server Action as a number, so "is it a number" is not enough on
   * its own and `Number.isInteger` is what actually refuses it.
   */
  test("the guards refuse what is not a number at all", () => {
    for (const value of [NaN, Infinity, "15", null, undefined, 1.5]) {
      expect(isDayOfMonth(value)).toBe(false)
      expect(isMinutes(value)).toBe(false)
    }
    expect(isDayOfMonth(31)).toBe(true)
    expect(isDayOfMonth(32)).toBe(false)
    expect(isMinutes(1)).toBe(true)
    expect(isMinutes(0)).toBe(false)
  })
})

/**
 * The two the form cannot get wrong, which is not the same as the two the
 * server may assume are right. A weekday arrives from a radio group of seven
 * and a deadline from a date box, and both also arrive in whatever a request
 * body says they are. `createDuty` asks these of every body it is handed.
 */
describe("the guards on what only a posted body can send", () => {
  test("a day of the week is 1 to 7 and nothing else", () => {
    for (const good of [1, 2, 3, 4, 5, 6, 7]) {
      expect(isWeekday(good)).toBe(true)
    }
    for (const bad of [
      0,
      8,
      99,
      -1,
      1.5,
      NaN,
      Infinity,
      "3",
      null,
      undefined,
    ]) {
      expect(isWeekday(bad)).toBe(false)
    }
  })

  test("a deadline is a day that exists, spelled YYYY-MM-DD", () => {
    for (const good of ["2026-08-05", "2026-02-28", "2024-02-29"]) {
      expect(isDeadline(good)).toBe(true)
    }
    // 2026-13-45 has no such month and no such day, 2026-02-30 no such day, and
    // an Invalid Date is what each of them used to become on the way to the
    // insert.
    for (const bad of [
      "",
      "not-a-date",
      "2026-13-45",
      "2026-02-30",
      "2026-2-5",
      "05/08/2026",
      "2026-08-05T00:00:00Z",
      20260805,
      null,
      undefined,
    ]) {
      expect(isDeadline(bad)).toBe(false)
    }
  })

  /** Every refusal the service can answer with is a sentence somebody reads. */
  test("each refusal names a key the dictionary carries", () => {
    const reasons: Refusal[] = [
      "position_out_of_scope",
      "checker_out_of_scope",
      "verifier_out_of_scope",
      "day_of_month_out_of_range",
      "day_of_week_out_of_range",
      "deadline_not_a_date",
      "time_to_do_not_a_number",
      "occurrence_not_allowed",
    ]
    const words: Record<Refusal, keyof typeof EN> = {
      position_out_of_scope: "taskForm.positionNotScoped",
      checker_out_of_scope: "taskForm.checkerNotScoped",
      verifier_out_of_scope: "taskForm.verifierNotScoped",
      day_of_month_out_of_range: "taskForm.invalid.dayOfMonth",
      day_of_week_out_of_range: "taskForm.invalid.dayOfWeek",
      deadline_not_a_date: "taskForm.invalid.deadline",
      time_to_do_not_a_number: "taskForm.invalid.minutes",
      occurrence_not_allowed: "taskForm.occurrenceNotAllowed",
    }
    for (const reason of reasons) {
      expect(EN[words[reason]]).toBeTruthy()
    }
  })
})

/**
 * These assert the key and the values, not a sentence, because a sentence is
 * what this function stopped returning. A test that read the English back would
 * pass just as happily on the day the screen printed the key at somebody.
 *
 * `EN` is read here, once, to prove every key names a real entry. `TH` is typed
 * against `EN`, so a key that exists in the dictionary exists in both languages.
 */
describe("describeSchedule", () => {
  test("names the case, and never a sentence", () => {
    expect(describeSchedule(draft({ occurrence: "daily" }))).toEqual({
      key: "occurrence.dailyWorking",
    })
    expect(
      describeSchedule(draft({ occurrence: "weekly", dayOfWeek: "4" })),
    ).toEqual({ key: "taskForm.schedule.everyWeekday", weekday: 4 })
  })

  test("every key it can name is in the dictionary", () => {
    const drafts: Draft[] = [
      draft({ occurrence: "general" }),
      draft({ occurrence: "once" }),
      draft({ occurrence: "once", deadline: "2026-08-31" }),
      draft({ occurrence: "daily" }),
      draft({ occurrence: "weekly" }),
      draft({ occurrence: "weekly", dayOfWeek: "4" }),
      draft({ occurrence: "monthly" }),
      draft({ occurrence: "monthly", dayOfMonth: "15" }),
      draft({ occurrence: "monthly", dayOfMonth: "31" }),
      draft({ occurrence: "quarterly" }),
      draft({ occurrence: "quarterly", dayOfMonth: "15" }),
      draft({ occurrence: "quarterly", dayOfMonth: "31" }),
    ]
    const keys = [...new Set(drafts.map((d) => describeSchedule(d).key))].sort()
    expect(keys).toEqual([
      "occurrence.dailyWorking",
      "taskForm.schedule.everyWeekday",
      "taskForm.schedule.monthlyOn",
      "taskForm.schedule.monthlyOnLate",
      "taskForm.schedule.monthlyUndecided",
      "taskForm.schedule.notATask",
      "taskForm.schedule.onceOn",
      "taskForm.schedule.onceUndecided",
      "taskForm.schedule.quarterlyOn",
      "taskForm.schedule.quarterlyOnLate",
      "taskForm.schedule.quarterlyUndecided",
      "taskForm.schedule.weeklyUndecided",
    ])
    for (const key of keys) expect(EN[key]).toBeString()
  })

  test("hands back the day as a day, for the reader's calendar to format", () => {
    expect(
      describeSchedule(draft({ occurrence: "once", deadline: "2026-08-31" })),
    ).toEqual({ key: "taskForm.schedule.onceOn", day: "2026-08-31" })
  })

  test("a month and a quarter are different sentences, not a substituted word", () => {
    expect(
      describeSchedule(draft({ occurrence: "monthly", dayOfMonth: "15" })),
    ).toEqual({ key: "taskForm.schedule.monthlyOn", dayOfMonth: 15 })
    expect(
      describeSchedule(draft({ occurrence: "quarterly", dayOfMonth: "15" })),
    ).toEqual({ key: "taskForm.schedule.quarterlyOn", dayOfMonth: 15 })
  })

  test("names the shift, because a fixed day does not always exist", () => {
    expect(
      describeSchedule(draft({ occurrence: "monthly", dayOfMonth: "31" })),
    ).toEqual({ key: "taskForm.schedule.monthlyOnLate", dayOfMonth: 31 })
    expect(
      describeSchedule(draft({ occurrence: "quarterly", dayOfMonth: "31" })),
    ).toEqual({ key: "taskForm.schedule.quarterlyOnLate", dayOfMonth: 31 })
    // 28 is the last day February has, so it is the boundary itself: 28 exists
    // in every month and 29 does not, which is where the clause starts.
    expect(
      describeSchedule(draft({ occurrence: "monthly", dayOfMonth: "28" })),
    ).toEqual({ key: "taskForm.schedule.monthlyOn", dayOfMonth: 28 })
    expect(
      describeSchedule(draft({ occurrence: "monthly", dayOfMonth: "29" })),
    ).toEqual({ key: "taskForm.schedule.monthlyOnLate", dayOfMonth: 29 })
    // April, June, September and November stop at 30, so 30 is short too.
    expect(
      describeSchedule(draft({ occurrence: "monthly", dayOfMonth: "30" })),
    ).toEqual({ key: "taskForm.schedule.monthlyOnLate", dayOfMonth: 30 })
  })

  test("admits what has not been decided yet", () => {
    expect(describeSchedule(draft({ occurrence: "once" }))).toEqual({
      key: "taskForm.schedule.onceUndecided",
    })
    expect(describeSchedule(draft({ occurrence: "weekly" }))).toEqual({
      key: "taskForm.schedule.weeklyUndecided",
    })
    expect(describeSchedule(draft({ occurrence: "monthly" }))).toEqual({
      key: "taskForm.schedule.monthlyUndecided",
    })
    expect(describeSchedule(draft({ occurrence: "quarterly" }))).toEqual({
      key: "taskForm.schedule.quarterlyUndecided",
    })
  })

  /**
   * The old code indexed a names array with `Number(dayOfWeek) - 1` and would
   * have said "Every undefined" for anything outside 1 to 7. A day that is not
   * a day is a day nobody picked.
   */
  test("a day of the week that is not one counts as unpicked", () => {
    for (const dayOfWeek of ["0", "8", "abc"]) {
      expect(
        describeSchedule(draft({ occurrence: "weekly", dayOfWeek })),
      ).toEqual({ key: "taskForm.schedule.weeklyUndecided" })
    }
  })

  test("a general duty says it generates nothing", () => {
    expect(describeSchedule(draft({ occurrence: "general" }))).toEqual({
      key: "taskForm.schedule.notATask",
    })
  })
})
