import { and, eq, inArray, isNull, sql } from "drizzle-orm"

import { db } from "@/lib/db/client"
import {
  holidays,
  people,
  recurringTemplates,
  taskInstances,
} from "@/lib/db/schema"
import { activeCovers } from "@/lib/services/covers"
import { recordEvents } from "@/lib/services/events"
import { dispatchPending } from "@/lib/services/notifications"
import { addDays, dayIn, type WorkPattern } from "@/lib/calendar"
import { dutyHolderOn } from "@/lib/cover"
import {
  instanceKey,
  isPastCutOff,
  toGenerate,
  type Recurrence,
  type Template,
} from "@/lib/recurrence"

/**
 * Turning duties into work, on one given day.
 *
 * Every rule about which duty is due when lives in `lib/recurrence.ts` and is
 * tested there against a calendar with no database in it. This file does three
 * things and no thinking: read what exists, ask that module, write the answer.
 * The moment a date is computed here instead, there are two answers to the same
 * question and only one of them has tests.
 *
 * Idempotent, and the unique index on (recurring_template_id, due_date) is what
 * buys it: a second run collides with the first instead of adding to it. The
 * consequence is worth restating, because it is the same one the previous
 * system got wrong: a wrong date is permanent. A rerun collides with the
 * mistake rather than correcting it.
 */

/**
 * `general` never generates, by definition. `once` is a single task with a
 * deadline rather than a recurrence, so it is not this module's business
 * either: nothing repeats, and there is nothing to decide each morning.
 */
const REPEATS = ["daily", "weekly", "monthly", "quarterly"] as const

async function holidaySet(): Promise<Set<string>> {
  const rows = await db.select({ day: holidays.holidayDate }).from(holidays)
  return new Set(rows.map((r) => r.day))
}

/**
 * The duties that can produce work on `date`, each with the person it falls to
 * and the week that person actually works, because that decides whether a
 * Saturday exists for them at all.
 *
 * ## Why this takes a date now
 *
 * Because a temporary cover is only in force on some days. Between two Bangkok
 * calendar days the duties of a position go to a stand-in instead of its holder,
 * and on every other day they go back. So the same template resolves to two
 * different people depending on the day being generated, and the day has to
 * arrive here to be asked about.
 *
 * ## Why the decision is not in this query
 *
 * The rows this fetches are facts: who holds the position, and what covers
 * exist. Which of them wins on `date` is decided by `dutyHolderOn` in
 * lib/cover.ts, which is pure and tested against a calendar with no database in
 * it, the same arrangement `toGenerate` and lib/recurrence.ts already have. A
 * window with two inclusive ends written into a WHERE clause is a boundary no
 * test can move, and getting it wrong is silent: the stand-in simply never
 * receives the first or the last day's work.
 *
 * A position with several holders is a case the client's spreadsheet does not
 * contain, since all 193 people hold one of 193 positions, but the database now
 * does: lib/db/seed-demo.ts transfers one person onto a colleague's position on
 * purpose, so that this branch and the vacant one either side of it can be
 * looked at instead of argued about. The task is left unassigned rather than
 * given to a holder picked at random, and ELEVA-137 records the question rather
 * than answering it here. A cover wins over both of those, which is how somebody
 * stands in for a job nobody currently holds.
 *
 * The week it falls back to is the schema's default, read from one place rather
 * than written twice. It used to say `mon_fri`, which was the default when this
 * was written and stopped being it on 4 August, when the client answered that
 * the factory works six days and all 193 rows were changed. Nobody noticed,
 * because a constant does not fail: the duties on a vacant or shared position
 * quietly stopped generating on Saturdays, on a week nobody in the roster has.
 * Measured at the time: 193 people are `mon_sat` and 0 are `mon_fri`.
 */
async function liveTemplates(
  date: string,
): Promise<(Template & { positionId: string; assignee: string | null })[]> {
  const covers = await activeCovers()

  const rows = await db
    .select({
      id: recurringTemplates.id,
      positionId: recurringTemplates.positionId,
      recurrence: recurringTemplates.occurrence,
      dayOfWeek: recurringTemplates.dayOfWeek,
      dayOfMonth: recurringTemplates.dayOfMonth,
      active: recurringTemplates.active,
      holders: sql<
        { id: string; pattern: WorkPattern }[]
      >`coalesce(json_agg(json_build_object('id', ${people.id}, 'pattern', ${people.workPattern})) filter (where ${people.id} is not null), '[]')`,
    })
    .from(recurringTemplates)
    .leftJoin(
      people,
      and(
        eq(people.positionId, recurringTemplates.positionId),
        isNull(people.archivedAt),
      ),
    )
    .where(
      and(
        eq(recurringTemplates.active, true),
        inArray(recurringTemplates.occurrence, [...REPEATS]),
      ),
    )
    .groupBy(recurringTemplates.id)

  return rows.map((r) => {
    const { assignee, pattern } = dutyHolderOn(
      r.positionId,
      r.holders ?? [],
      covers,
      date,
    )
    return {
      id: r.id,
      positionId: r.positionId,
      recurrence: r.recurrence as Recurrence,
      dayOfWeek: r.dayOfWeek ?? undefined,
      dayOfMonth: r.dayOfMonth ?? undefined,
      active: r.active,
      pattern,
      assignee,
    }
  })
}

export type GenerationReport = {
  date: string
  due: number
  created: number
  /** Already there. Not a failure: it is the guarantee doing its job. */
  alreadyThere: number
}

/**
 * Everything that should exist on `date`, created if it does not.
 *
 * `date` is a Bangkok calendar day and defaults to today's. It is a parameter
 * so a missed night can be caught up by naming the day, and so the whole thing
 * can be tested without moving a clock.
 */
export async function generateFor(
  date: string = dayIn(new Date()),
): Promise<GenerationReport> {
  const [templates, days] = await Promise.all([
    liveTemplates(date),
    holidaySet(),
  ])

  const existing = new Set(
    (
      await db
        .select({ templateId: taskInstances.recurringTemplateId })
        .from(taskInstances)
        .where(eq(taskInstances.dueDate, date))
    ).map((r) => instanceKey(r.templateId, date)),
  )

  const wanted = toGenerate(templates, date, existing, days)
  if (wanted.length === 0) {
    return { date, due: existing.size, created: 0, alreadyThere: existing.size }
  }

  const byId = new Map(templates.map((t) => [t.id, t]))
  const written = await db
    .insert(taskInstances)
    .values(
      wanted.map((t) => ({
        recurringTemplateId: t.id,
        dueDate: date,
        assigneePersonId: byId.get(t.id)?.assignee ?? null,
      })),
    )
    // Belt as well as braces. The set above is read before the insert, so two
    // runs starting at the same moment would both decide to write; the index
    // stops the second and this stops it from being an error.
    .onConflictDoNothing()
    .returning({ id: taskInstances.id })

  return {
    date,
    due: existing.size + wanted.length,
    created: written.length,
    alreadyThere: existing.size + (wanted.length - written.length),
  }
}

/**
 * What has run out of time, marked missed.
 *
 * It marks and never removes: an undone task stays open and can still be done,
 * so this writes a status and takes nothing away. That sentence is now true of
 * the rest of the system as well, which it was not: `missed` used to drop a
 * task out of its assignee's list and out of everybody's actions, so the sweep
 * quietly did remove it. The client answered ELEVA-156 on 4 August, and
 * `isOwed` in lib/task-actions.ts is where the answer is written.
 *
 * The cut-off follows each task's own cycle rather than one clock, which is why
 * the recurrence comes back from the template.
 *
 * ## Why the candidates are still `todo|started` and not everything owed
 *
 * `missed` is left out of the WHERE on purpose, and not by oversight now that
 * the three lists include it. This asks which tasks have run out of time since
 * the last run, and a task already marked has: re-marking it would rewrite the
 * same 57 rows every night to the value they already hold.
 *
 * The one case worth naming is the task somebody picks back up. Pressing Start
 * on a missed task moves it to `started`, which puts it back in this query, and
 * tonight it is marked missed again, because it is still past its cycle and
 * still not done. That is the honest answer rather than a loop to break:
 * `started_at` survives, the row stays on the person's list and still offers
 * them Finish, and nothing is taken from anybody. Excluding a task from the
 * sweep because it was once started would be a way of writing work off, which
 * is the one thing the client ruled out.
 */
export async function markMissedAsOf(
  on: string = dayIn(new Date()),
): Promise<{ on: string; marked: number }> {
  const open = await db
    .select({
      id: taskInstances.id,
      dueDate: taskInstances.dueDate,
      recurrence: recurringTemplates.occurrence,
    })
    .from(taskInstances)
    .innerJoin(
      recurringTemplates,
      eq(recurringTemplates.id, taskInstances.recurringTemplateId),
    )
    .where(inArray(taskInstances.status, ["todo", "started"]))

  const late = open.filter(
    (i) =>
      REPEATS.includes(i.recurrence as (typeof REPEATS)[number]) &&
      isPastCutOff(i.recurrence as Recurrence, i.dueDate, on),
  )

  if (late.length === 0) return { on, marked: 0 }

  /**
   * The status and the events together, because an event that can be written
   * without the change that caused it is a log rather than an outbox.
   *
   * **Nobody is notified of any of them, and that is the decision rather than a
   * gap.** There are 267 missed instances in the database today and this sweep
   * marks more every night; fanning them out would bury the four notifications
   * somebody is actually waiting on, on the first morning. The event is
   * recorded because `architecture.md` §5 names `task.missed` and other
   * consumers may well want it, and `FAN_OUT` in lib/services/notifications.ts
   * is where it is told to reach nobody.
   *
   * No actor, and the null is the truth: no person marked these. The day ran
   * out.
   */
  const marked = await db.transaction(async (tx) => {
    const rows = await tx
      .update(taskInstances)
      .set({ status: "missed" })
      .where(
        inArray(
          taskInstances.id,
          late.map((i) => i.id),
        ),
      )
      .returning({ id: taskInstances.id })

    await recordEvents(
      tx,
      rows.map((r) => ({
        type: "task.missed" as const,
        taskInstanceId: r.id,
        actorPersonId: null,
      })),
    )

    return rows.length
  })

  return { on, marked }
}

/* -------------------------------------------------------------------------- */
/* The nightly run                                                            */
/* -------------------------------------------------------------------------- */

/**
 * How far the job looks, in days either side of the day it runs.
 *
 * **Backwards, seven.** A machine that was off over a long weekend should not
 * leave a hole, and re-generating a day that already exists costs one query and
 * changes nothing. This is what makes the job safe to run late, which matters
 * more than it sounds: the alternative is remembering when it last ran, and a
 * job that keeps state is a job that can be wrong about it.
 *
 * **Forwards, fourteen.** Not further, and the reason is the one property this
 * engine cannot undo. Generation is idempotent on `(template, due_date)`, so an
 * instance written today is written for good: a holiday loaded next week cannot
 * move work that already exists on the day it falls. Every day generated ahead
 * is a day the calendar can no longer correct.
 *
 * Fourteen is enough for My Tasks to answer "this week" honestly and for
 * somebody to see next week coming. The month view will be short at the end of
 * a month, and that is the trade being made: a full month ahead would mean four
 * weeks of dates a late calendar could not fix.
 */
const LOOK_BACK = 7
const LOOK_AHEAD = 14

export type DailyRun = {
  ran: string
  from: string
  to: string
  created: number
  /** Days that already held everything they should. Not a failure. */
  untouched: number
  marked: number
  /** Events the drain found still owed, and the sends they produced. */
  dispatched: number
  notified: number
}

/**
 * Everything the nightly job does, which is three things and no decisions.
 *
 * It takes no arguments in normal use and keeps nothing between runs: the day
 * comes from the clock, in Bangkok, and the window is fixed. Run it twice and
 * the second run creates nothing; run it three days late and it fills the gap.
 * Both are properties of `generateFor`, not of this.
 *
 * The third thing is draining the outbox, and it is here for want of a worker
 * rather than because it belongs to the night. Every command already attempts
 * its own dispatch the moment it commits; this catches whatever that attempt
 * did not manage, after a restart or a channel that was down. Running it twice
 * notifies nobody twice, which is a property of `dispatchPending` and is
 * measured rather than asserted.
 */
export async function runDailyJob(
  today: string = dayIn(new Date()),
): Promise<DailyRun> {
  const from = addDays(today, -LOOK_BACK)
  const to = addDays(today, LOOK_AHEAD)

  let created = 0
  let untouched = 0

  for (let i = -LOOK_BACK; i <= LOOK_AHEAD; i++) {
    const report = await generateFor(addDays(today, i))
    created += report.created
    if (report.created === 0) untouched++
  }

  /**
   * Marking missed comes after generating and not before. A day generated in
   * the catch-up may already be past its cut-off, and running the sweep first
   * would leave it open until tomorrow for no reason.
   */
  const { marked } = await markMissedAsOf(today)

  // Last, so that everything this run wrote is already in the outbox when the
  // drain looks. The sweep's own events reach nobody by design; what this
  // actually recovers is a reminder or an approval whose dispatch failed
  // during the day.
  const { events, sent } = await dispatchPending()

  return {
    ran: today,
    from,
    to,
    created,
    untouched,
    marked,
    dispatched: events,
    notified: sent,
  }
}
