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

import {
  jobDescriptionRows,
  jobDescriptionSummary,
  ownJobDescription,
  type Chair,
} from "@/lib/job-description-view"

/**
 * The overview is a list of people, and the job descriptions belong to
 * positions. Everything worth testing here lives in that mismatch: 193 people
 * hold 193 positions, but not one to one, and the screen has to say something
 * true about the chairs where the count does not work out.
 */

/** A chair with nothing remarkable about it, to be overridden a field at a time. */
function chair(over: Partial<Chair> = {}): Chair {
  return {
    positionId: "p1",
    title: "Officer",
    department: "Accounting",
    jobDescription: null,
    generalDuties: 0,
    recurringDuties: 0,
    holders: [],
    ...over,
  }
}

const holder = (id: string, name: string, archived = false) => ({
  id,
  name,
  archived,
})

describe("jobDescriptionRows", () => {
  test("a position nobody holds still gets a row, with no person on it", () => {
    const rows = jobDescriptionRows([chair({ title: "ช่างเลเซอร์" })])

    expect(rows).toHaveLength(1)
    expect(rows[0]!.person).toBeNull()
    expect(rows[0]!.title).toBe("ช่างเลเซอร์")
  })

  /**
   * The vacancy the demonstration database carries is of this kind: somebody
   * left and the chair stayed empty. A row that still named her would say she
   * holds a job she has left.
   */
  test("a position whose only holder has left reads as vacant", () => {
    const rows = jobDescriptionRows([
      chair({ holders: [holder("h1", "นัชริดา ป้อมคำ", true)] }),
    ])

    expect(rows).toHaveLength(1)
    expect(rows[0]!.person).toBeNull()
  })

  /**
   * `liveTemplates` refuses to pick one of two holders for a task. Here there
   * is no such problem: both of them do this job and both need to read what it
   * is, so both get a line.
   */
  test("a position with two holders becomes two rows", () => {
    const rows = jobDescriptionRows([
      chair({
        holders: [holder("h1", "อรัญญา"), holder("h2", "โรมา")],
      }),
    ])

    expect(rows).toHaveLength(2)
    expect(rows.map((r) => r.person?.name).sort()).toEqual(["อรัญญา", "โรมา"])
  })

  test("the prose is reported as present or absent, never as text to read", () => {
    const [written] = jobDescriptionRows([
      chair({ jobDescription: "Runs the department." }),
    ])
    const [blank] = jobDescriptionRows([chair({ jobDescription: "   " })])

    expect(written!.hasText).toBe(true)
    expect(blank!.hasText).toBe(false)
  })

  /**
   * The order the screen exists for. Whoever opens it is looking for the jobs
   * nobody has described yet, so those come first and the well-documented ones
   * sink.
   */
  test("the emptiest job descriptions come first", () => {
    const rows = jobDescriptionRows([
      chair({
        positionId: "rich",
        title: "Rich",
        generalDuties: 4,
        jobDescription: "Written.",
      }),
      chair({ positionId: "bare", title: "Bare" }),
      chair({ positionId: "some", title: "Some", generalDuties: 2 }),
    ])

    expect(rows.map((r) => r.title)).toEqual(["Bare", "Some", "Rich"])
  })

  test("at equal duties, the one with no prose is the one still to write", () => {
    const rows = jobDescriptionRows([
      chair({
        positionId: "a",
        title: "Written",
        generalDuties: 2,
        jobDescription: "Written.",
      }),
      chair({ positionId: "b", title: "Unwritten", generalDuties: 2 }),
    ])

    expect(rows.map((r) => r.title)).toEqual(["Unwritten", "Written"])
  })

  /**
   * Thai collation, named rather than left to the host, for the reason
   * `lib/team-view.ts` gives: the machine in Bangkok and the laptop have to
   * agree on the order.
   *
   * These two names are chosen because they disagree. `โรมา` opens with a
   * leading vowel, which Thai collates with the consonant behind it — so it
   * sorts under `ร`, ahead of `อ`. Comparing the strings as they are written
   * puts them the other way round, which is what the second assertion pins:
   * drop the locale and this test fails rather than quietly reordering a
   * roster of 193 Thai names.
   */
  test("names break the tie, in Thai collation and not by code point", () => {
    const rows = jobDescriptionRows([
      chair({ positionId: "p2", holders: [holder("h2", "โรมา")] }),
      chair({ positionId: "p1", holders: [holder("h1", "อรัญญา")] }),
    ])

    expect(rows.map((r) => r.person?.name)).toEqual(["โรมา", "อรัญญา"])
    expect(["โรมา", "อรัญญา"].sort()).toEqual(["อรัญญา", "โรมา"])
  })

  test("a vacant chair sorts by its position title, having no name to sort by", () => {
    const rows = jobDescriptionRows([
      chair({ positionId: "z", title: "Zebra" }),
      chair({ positionId: "a", title: "Antelope" }),
    ])

    expect(rows.map((r) => r.title)).toEqual(["Antelope", "Zebra"])
  })

  test("recurring duties are carried through but do not decide the order", () => {
    const rows = jobDescriptionRows([
      chair({ positionId: "a", title: "Busy", recurringDuties: 40 }),
      chair({ positionId: "b", title: "Idle", recurringDuties: 1 }),
    ])

    expect(rows.map((r) => r.title)).toEqual(["Busy", "Idle"])
    expect(rows.map((r) => r.recurringDuties)).toEqual([40, 1])
  })
})

describe("jobDescriptionSummary", () => {
  /**
   * The number at the top of the screen counts chairs, and the table below it
   * counts people. They differ, and the header has to say so rather than
   * quietly reporting one as the other.
   */
  test("positions and lines are counted separately", () => {
    const summary = jobDescriptionSummary([
      chair({ positionId: "vacant" }),
      chair({
        positionId: "shared",
        holders: [holder("h1", "อรัญญา"), holder("h2", "โรมา")],
      }),
    ])

    expect(summary.positions).toBe(2)
    expect(summary.rows).toBe(3)
  })

  /**
   * The trap this function exists for. Summing a column of the table would
   * count the shared chair twice and report more unwritten positions than the
   * factory has.
   */
  test("a chair with two holders is one unwritten position, not two", () => {
    const summary = jobDescriptionSummary([
      chair({
        positionId: "shared",
        holders: [holder("h1", "อรัญญา"), holder("h2", "โรมา")],
      }),
    ])

    expect(summary.unwritten).toBe(1)
  })

  test("a chair nobody holds still counts as a position to describe", () => {
    expect(jobDescriptionSummary([chair()]).unwritten).toBe(1)
  })

  test("blank prose counts as unwritten, written prose does not", () => {
    const summary = jobDescriptionSummary([
      chair({ positionId: "a", jobDescription: "Runs the department." }),
      chair({ positionId: "b", jobDescription: "  " }),
      chair({ positionId: "c", jobDescription: null }),
    ])

    expect(summary.unwritten).toBe(2)
  })
})

/**
 * The staff side. One person, one position, and the question "what is my job".
 *
 * The interesting case is the ordinary one: on 1 September, 192 positions out
 * of 193 have no prose and half have no duties either, so the screen almost
 * everybody opens is the empty one. What it says there is the whole design.
 */
describe("ownJobDescription", () => {
  const duty = (description: string, occurrence: string, active = true) => ({
    id: description,
    description,
    occurrence,
    active,
  })

  test("the duties that are only read are kept apart from the ones that generate work", () => {
    const own = ownJobDescription({
      jobDescription: null,
      duties: [
        duty("Keep the bench clean", "general"),
        duty("Count the stock", "weekly"),
        duty("Open the workshop", "daily"),
      ],
    })

    expect(own.listed.map((d) => d.description)).toEqual([
      "Keep the bench clean",
    ])
    expect(own.generated.map((d) => d.description)).toEqual([
      "Count the stock",
      "Open the workshop",
    ])
  })

  /**
   * Deactivating a duty stops it generating and leaves its history alone, which
   * makes it a duty the position no longer owes. Reading it on a job
   * description would teach a new holder work nobody does.
   */
  test("a deactivated duty is in neither list", () => {
    const own = ownJobDescription({
      jobDescription: null,
      duties: [
        duty("Retired listing", "general", false),
        duty("Retired routine", "daily", false),
      ],
    })

    expect(own.listed).toHaveLength(0)
    expect(own.generated).toHaveLength(0)
  })

  test("blank prose is no prose, here as everywhere", () => {
    expect(
      ownJobDescription({ jobDescription: "  \n ", duties: [] }).hasText,
    ).toBe(false)
    expect(
      ownJobDescription({ jobDescription: "Runs it.", duties: [] }).hasText,
    ).toBe(true)
  })

  /**
   * The screen almost everybody sees today. Nothing has been written and no
   * duty has been listed, so there is genuinely nothing to read — and the page
   * has to say so rather than draw an empty list under an empty paragraph.
   */
  test("no prose and no duty at all is empty", () => {
    expect(
      ownJobDescription({ jobDescription: null, duties: [] }).isEmpty,
    ).toBe(true)
  })

  /**
   * Not empty, and this is the distinction the flag exists for. A position with
   * no paragraph but with duties on it does describe the job, in the only way
   * anybody has bothered to describe it so far.
   */
  test("duties without prose are still something to read", () => {
    const own = ownJobDescription({
      jobDescription: null,
      duties: [duty("Count the stock", "weekly")],
    })

    expect(own.isEmpty).toBe(false)
  })

  test("prose without duties is something to read too", () => {
    const own = ownJobDescription({ jobDescription: "Runs it.", duties: [] })

    expect(own.isEmpty).toBe(false)
  })

  test("a position whose every duty is deactivated reads as empty", () => {
    const own = ownJobDescription({
      jobDescription: null,
      duties: [duty("Retired", "general", false)],
    })

    expect(own.isEmpty).toBe(true)
  })
})
