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

import {
  availableActions,
  DONE,
  isLate,
  isLateOnDay,
  isOwed,
  OPEN,
  OWED,
  type Relation,
  type TaskStatus,
} from "@/lib/task-actions"

const ALL: TaskStatus[] = ["todo", "started", "finished", "checked", "missed"]

const ids = (...args: Parameters<typeof availableActions>) =>
  availableActions(...args).map((a) => a.id)

/**
 * What a viewer may do comes from the status and the relation together. These
 * cases exist because neither alone is enough, and because deleting either half
 * of that rule has to fail something.
 */
describe("availableActions", () => {
  test("an assignee starts work that is still to do", () => {
    expect(ids("todo", ["assignee"])).toEqual(["start", "report_issue"])
  })

  test("an assignee finishes work they have started", () => {
    expect(ids("started", ["assignee"])).toEqual(["finish", "report_issue"])
  })

  test("an assignee has nothing left once it has been checked", () => {
    expect(ids("checked", ["assignee"])).toEqual([])
  })

  test("a checker waits for the work before there is anything to decide", () => {
    expect(ids("todo", ["checker"])).toEqual([])
    expect(ids("started", ["checker"])).toEqual([])
  })

  test("a checker approves or sends back once it is finished", () => {
    expect(ids("finished", ["checker"])).toEqual(["approve", "send_back"])
  })

  test("a verifier audits only after the checker has completed it", () => {
    expect(ids("finished", ["verifier"])).toEqual([])
    expect(ids("checked", ["verifier"])).toEqual(["verify"])
  })

  test("a reminder belongs to work still owed, not to work already finished", () => {
    expect(ids("todo", ["manager_of_dept"])).toEqual(["remind"])
    expect(ids("started", ["manager_of_dept"])).toEqual(["remind"])
    expect(ids("missed", ["manager_of_dept"])).toEqual(["remind"])
    expect(ids("finished", ["manager_of_dept"])).toEqual([])
    expect(ids("checked", ["manager_of_dept"])).toEqual([])
  })

  /**
   * The case the screen exists to prove: somebody holding two relations sees
   * only the one that applies. A checker who also runs the department gets no
   * reminder button on work that has already been handed in.
   */
  test("two relations on one viewer produce only what applies", () => {
    const both: Relation[] = ["checker", "manager_of_dept"]
    expect(ids("finished", both)).toEqual(["approve", "send_back"])
    expect(ids("started", both)).toEqual(["remind"])
  })

  /**
   * One verb, and the specification is unambiguous: `one_time_creator -> close`.
   * This asserted `close, send_back` and the implementation obliged, so a
   * creator could return work to its assignee. The relation never expires,
   * which made it a way to write to a task from another department months
   * later. The test was the reason the fault survived, not the thing that
   * caught it.
   */
  test("the creator of a one-time task closes it, and nothing else", () => {
    const actions = availableActions("finished", ["one_time_creator"], {
      oneTime: true,
    })
    expect(actions.map((a) => a.id)).toEqual(["close"])
    expect(actions[0]?.label).toBe("action.close")
  })

  /**
   * `admin -> all`. `close` was chosen by `oneTime && !has("checker")`, which an
   * admin can never satisfy because `has` already counts them as the checker,
   * so the one relation granted everything was the one refused this verb.
   */
  test("an admin may close a one-time task, which the ternary made impossible", () => {
    expect(
      availableActions("finished", ["admin"], { oneTime: true }).map(
        (a) => a.id,
      ),
    ).toEqual(["approve", "send_back", "close"])
  })

  /**
   * Verifying is a sample audit and it happens once. The status does not move
   * when it does, so nothing else can refuse a second one, and a second one
   * overwrites who audited the task and when.
   */
  test("verify is offered once and not again", () => {
    expect(availableActions("checked", ["verifier"]).map((a) => a.id)).toEqual([
      "verify",
    ])
    expect(
      availableActions("checked", ["verifier"], { verified: true }),
    ).toEqual([])
    expect(availableActions("checked", ["admin"], { verified: true })).toEqual(
      [],
    )
  })

  test("a one-time creator who is also the checker approves rather than closes", () => {
    const actions = availableActions(
      "finished",
      ["one_time_creator", "checker"],
      { oneTime: true },
    )
    expect(actions[0]?.id).toBe("approve")
  })

  test("an admin gets what any relation would give, without holding it", () => {
    expect(ids("finished", ["admin"])).toEqual(["approve", "send_back"])
    expect(ids("checked", ["admin"])).toEqual(["verify"])
  })

  /**
   * The client's answer of 4 August, as a test: work owed is work owed, and a
   * day passing does not write it off. A missed task that offered its assignee
   * nothing was a screen telling somebody they still owed work and giving them
   * no way to hand it in.
   */
  test("a missed task is still the assignee's to hand in", () => {
    expect(ids("missed", ["assignee"])).toEqual(["finish", "start"])
  })

  /**
   * `finish` first and primary, which is the one place `missed` differs from
   * `todo`. Starting a task whose cycle has closed changes nothing anybody is
   * waiting for, and tonight's sweep marks it missed again; finishing is the
   * press that clears the debt.
   */
  test("finishing is what a missed task leads with", () => {
    const actions = availableActions("missed", ["assignee"])
    expect(actions[0]?.id).toBe("finish")
    expect(actions[0]?.primary).toBe(true)
    expect(actions[0]?.consequence).toBe("consequence.finish")
    expect(actions[1]?.id).toBe("start")
    expect(actions[1]?.primary).toBeUndefined()
  })

  /**
   * Widening for the assignee must not widen for anybody else. A task nobody
   * has handed in has nothing to approve, whatever its status says about time.
   */
  test("a missed task gives a checker and a verifier nothing", () => {
    expect(ids("missed", ["checker"])).toEqual([])
    expect(ids("missed", ["verifier"])).toEqual([])
    expect(ids("missed", ["one_time_creator"], { oneTime: true })).toEqual([])
    expect(ids("missed", [])).toEqual([])
  })

  /**
   * `report_issue` stays on `todo|started`, which is where `lifecycle.json`
   * names it. The client answered whether the work is still owed, not whether
   * the flag outlives the cut-off, and this is the line that would move if they
   * ever answer the second question.
   */
  test("reporting an issue does not survive the cut-off", () => {
    expect(ids("missed", ["assignee"])).not.toContain("report_issue")
    expect(ids("todo", ["assignee"])).toContain("report_issue")
    expect(ids("started", ["assignee"])).toContain("report_issue")
  })
})

/**
 * Still owed: the one question every "how much is left" count on the screens is
 * asking, and the reason all of them give the same answer.
 */
describe("isOwed", () => {
  test("work not yet done is owed, whether or not its time ran out", () => {
    expect(isOwed("todo")).toBe(true)
    expect(isOwed("started")).toBe(true)
    expect(isOwed("missed")).toBe(true)
  })

  test("work handed in or approved is not owed", () => {
    expect(isOwed("finished")).toBe(false)
    expect(isOwed("checked")).toBe(false)
  })

  /** The list and the predicate are one answer, so they cannot disagree. */
  test("the list is exactly the statuses the predicate accepts", () => {
    expect([...OWED].sort()).toEqual(ALL.filter(isOwed).sort())
  })
})

/**
 * The two lists /kpi counts a month with, in SQL, beside the predicates the
 * screens ask per row.
 *
 * They exist so that an aggregate over thirty days and a badge on one row
 * cannot come to mean different things by "done" or by "late". ELEVA-116 is a
 * person being judged on figures they cannot see; the figures being computed
 * two ways is the same defect one layer down.
 */
describe("DONE and OPEN", () => {
  test("done is handed in or approved, and nothing else", () => {
    expect([...DONE].sort()).toEqual(["checked", "finished"])
  })

  /** The five statuses split in two, with nothing in both and nothing left out. */
  test("done and owed partition the statuses", () => {
    expect([...DONE, ...OWED].sort()).toEqual([...ALL].sort())
    expect(DONE.filter((s) => OWED.includes(s))).toEqual([])
  })

  /**
   * Narrower than OWED on purpose: `missed` is owed and is never late.
   * `lifecycle.json` defines late as `status IN (todo, started)`.
   */
  test("open is what is eligible to be late", () => {
    expect([...OPEN].sort()).toEqual(["started", "todo"])
    expect(OPEN.every((s) => OWED.includes(s))).toBe(true)
    expect(OPEN.includes("missed")).toBe(false)
  })

  /**
   * The list and the function that reads it, checked against each other rather
   * than against a copy of the same list.
   */
  test("open is exactly what isLateOnDay will call late on a past day", () => {
    expect(
      ALL.filter((s) => isLateOnDay(s, "2026-07-01", "2026-08-04")).sort(),
    ).toEqual([...OPEN].sort())
  })
})

/**
 * Late is never stored. It is asked at the moment somebody looks, and only of
 * work that is still open: finishing late does not make a finished task late.
 */
describe("isLate", () => {
  const due = new Date("2026-08-02T23:59:00+07:00")
  const after = new Date("2026-08-03T08:00:00+07:00")
  const before = new Date("2026-08-02T20:00:00+07:00")

  test("open work past its date is late", () => {
    expect(isLate("todo", due, after)).toBe(true)
    expect(isLate("started", due, after)).toBe(true)
  })

  test("open work before its date is not", () => {
    expect(isLate("todo", due, before)).toBe(false)
  })

  test("work already handed in is never late, whatever the clock says", () => {
    expect(isLate("finished", due, after)).toBe(false)
    expect(isLate("checked", due, after)).toBe(false)
    expect(isLate("missed", due, after)).toBe(false)
  })

  test("a task with no date cannot be late", () => {
    expect(isLate("todo", null, after)).toBe(false)
  })
})

/**
 * The distinction the screens are built on: late is about the day, missed is
 * about the cycle, and only one of them is ever written down.
 */
describe("isLateOnDay", () => {
  test("a task due today is not late, at any hour anywhere", () => {
    expect(isLateOnDay("todo", "2026-08-04", "2026-08-04")).toBe(false)
    expect(isLateOnDay("started", "2026-08-04", "2026-08-04")).toBe(false)
  })

  test("a task open from yesterday is late", () => {
    expect(isLateOnDay("todo", "2026-08-03", "2026-08-04")).toBe(true)
    expect(isLateOnDay("started", "2026-08-03", "2026-08-04")).toBe(true)
  })

  test("a task not yet due is not late", () => {
    expect(isLateOnDay("todo", "2026-08-05", "2026-08-04")).toBe(false)
  })

  /**
   * Work that is done is not late, and neither is `missed`, which is
   * `lifecycle.json`'s own definition: `status IN (todo, started)`. The screens
   * draw Missed where they would otherwise draw Late, rather than both.
   */
  test("finished, checked and missed are never late", () => {
    for (const status of ["finished", "checked", "missed"] as const) {
      expect(isLateOnDay(status, "2026-07-01", "2026-08-04")).toBe(false)
    }
  })

  /**
   * And nothing is lost by that, because nothing is stored. The same instance,
   * the same due date, picked back up: it reads late again the moment its
   * status says somebody still owes it.
   */
  test("a missed task picked back up reads late again", () => {
    expect(isLateOnDay("missed", "2026-07-30", "2026-08-04")).toBe(false)
    expect(isLateOnDay("started", "2026-07-30", "2026-08-04")).toBe(true)
    expect(isLateOnDay("todo", "2026-07-30", "2026-08-04")).toBe(true)
  })

  /**
   * The whole reason this exists next to `isLate`. A day is a day: comparing
   * `YYYY-MM-DD` strings cannot be moved by the hour or by the machine's zone,
   * and the previous system dated every row a day early by doing otherwise.
   */
  test("it compares days and never moments", () => {
    expect(isLateOnDay("todo", "2026-12-31", "2027-01-01")).toBe(true)
    expect(isLateOnDay("todo", "2027-01-01", "2026-12-31")).toBe(false)
  })
})
