import { and, eq, isNull, type SQL } from "drizzle-orm"

import { db } from "@/lib/db/client"
import { taskInstances, taskSubmissions } from "@/lib/db/schema"
import {
  availableActions,
  type TaskAction,
  type TaskStatus,
} from "@/lib/task-actions"
import type { ViewerRelations } from "@/lib/task-relations"
import { recordEvent, type NewEvent } from "@/lib/services/events"
import { dispatchAfterCommit } from "@/lib/services/notifications"
import { taskDetail } from "@/lib/services/task-detail"
import type { SignedIn } from "@/lib/services/session"

/**
 * The write side of `/tasks/[id]`: one function per verb the page can offer.
 *
 * ## Why every one of them asks the same question again
 *
 * The page renders `availableActions`, so a viewer only sees the buttons that
 * apply to them. That is a convenience and not a permission: the button lives
 * in a browser, and what arrives here is a task id and a verb, either of which
 * anybody signed in can type. So each verb reloads the task, recomputes the
 * viewer's relations to it from the row that is in the database now, and
 * refuses unless the verb it is about to perform is in the list. Invariant 3,
 * asked on the way in.
 *
 * `permitted` reaches for `taskDetail` rather than a query of its own, and that
 * is the point rather than a shortcut. It reads the assignee, the checker, the
 * verifier, the creator and the department in one statement and hands them to
 * `relationsOf`; a second query written here to answer the permission
 * separately is a second query that can drift from the first, and the drift
 * would show up as a screen offering a button that the server then refuses.
 *
 * ## Refusals rather than throws
 *
 * Same shape as `createDuty` in lib/services/duties.ts. A refusal is an answer
 * the screen has to say something about, and an exception is not: a stale page
 * that offers Approve on work somebody else already approved is an ordinary
 * event in a factory where two people share a department.
 */

export type Verb = TaskAction["id"]

export type Refusal =
  /** The id names no row. Nothing here distinguishes it from one out of reach. */
  | "no_such_task"
  /** The viewer holds no relation that permits this verb at this status. */
  | "not_allowed"
  /** A verb whose whole content is the note, given without one. */
  | "note_required"
  /**
   * Somebody moved the task between the check and the write. Not an error and
   * not a refusal of the person: the answer is to show them the task again.
   */
  | "already_moved"

export type CommandResult = { ok: true } | { ok: false; reason: Refusal }

/**
 * The gate, with no database in it, so what is being enforced can be read and
 * tested on its own.
 *
 * A wrapper over `availableActions` and deliberately nothing more. The rule
 * about which relation may do what at which status is written once, in
 * lib/task-actions.ts, and this is the sentence that turns "what the page may
 * draw" into "what the server will accept". If the two were ever computed by
 * different code, one of them would be the security hole.
 */
export function permits(
  verb: Verb,
  status: TaskStatus,
  { relations, isOneTime }: ViewerRelations,
): boolean {
  return availableActions(status, relations, { oneTime: isOneTime }).some(
    (a) => a.id === verb,
  )
}

type Permitted =
  | { ok: true; status: TaskStatus }
  | { ok: false; reason: "no_such_task" | "not_allowed" }

async function permitted(
  user: SignedIn,
  id: string,
  verb: Verb,
): Promise<Permitted> {
  const task = await taskDetail(user, id)
  if (!task) return { ok: false, reason: "no_such_task" }
  // `task.actions` was computed a line ago by `availableActions`, from the row
  // just read and the viewer just loaded from the session. It is not the list
  // the browser was holding.
  if (!permits(verb, task.status, task)) {
    return { ok: false, reason: "not_allowed" }
  }
  return { ok: true, status: task.status }
}

/**
 * The write, with the status the gate saw carried into the WHERE clause.
 *
 * Between the check and the update, somebody else can move the task; several
 * departments hold two people and an admin can act anywhere, so two hands on
 * one row is a normal Tuesday rather than a thought experiment. Naming the
 * expected status makes the update a no-op instead of a second approval, and
 * zero rows back is how that is noticed.
 */
async function write(
  id: string,
  expected: TaskStatus,
  values: Partial<typeof taskInstances.$inferInsert>,
  options: {
    /**
     * An extra condition for a verb whose status does not change, so the status
     * in the WHERE cannot tell a first press from a second one.
     */
    alsoRequire?: SQL
    /**
     * What happened, for the outbox. Written in the same transaction as the
     * change, and only if the change actually landed: an update that finds no
     * row is somebody else's approval, and there is nothing to announce about
     * it. Dispatched after the commit, never inside it.
     */
    event?: NewEvent
  } = {},
): Promise<CommandResult> {
  const { alsoRequire, event } = options
  const where = and(
    eq(taskInstances.id, id),
    eq(taskInstances.status, expected),
    ...(alsoRequire ? [alsoRequire] : []),
  )

  const moved = await db.transaction(async (tx) => {
    const rows = await tx
      .update(taskInstances)
      .set(values)
      .where(where)
      .returning({ id: taskInstances.id })

    if (rows.length === 0) return null
    return event ? await recordEvent(tx, event) : ""
  })

  if (moved === null) return { ok: false, reason: "already_moved" }
  // After the commit, and best effort. The event is durable either way; what
  // this buys is that the screen coming back already has the notification
  // behind it, rather than waiting for the nightly drain.
  if (moved) await dispatchAfterCommit(moved)
  return { ok: true }
}

export async function start(
  user: SignedIn,
  id: string,
): Promise<CommandResult> {
  const gate = await permitted(user, id, "start")
  if (!gate.ok) return gate
  return write(id, gate.status, { status: "started", startedAt: new Date() })
}

/**
 * Finishing, which is the one verb that writes two rows: the task moves, and
 * what the assignee hands in is recorded.
 *
 * Without the submission a checker is shown Approve and Send back with no trace
 * of the work, and Approve becomes a reflex. `task_submissions.note` is NOT
 * NULL for that reason and it is the only field written here. The table also
 * carries `readings`, `delivered_name`, `delivered_url` and `delivered_how`,
 * and they stay null: what the client actually wants an assignee to hand in is
 * ELEVA-121 and still open, so filling those columns would mean inventing the
 * question they answer. The minimum the schema supports, and no more.
 */
export async function finish(
  user: SignedIn,
  id: string,
  note: string,
): Promise<CommandResult> {
  const gate = await permitted(user, id, "finish")
  if (!gate.ok) return gate

  const text = note.trim()
  if (!text) return { ok: false, reason: "note_required" }

  const now = new Date()

  // Both writes or neither, and now three: the event goes with them. A task
  // marked finished with no submission behind it is exactly the state the
  // submission table exists to prevent, and a checker never told is the state
  // the outbox exists to prevent.
  const moved = await db.transaction(async (tx) => {
    const rows = await tx
      .update(taskInstances)
      .set({ status: "finished", finishedAt: now })
      .where(
        and(eq(taskInstances.id, id), eq(taskInstances.status, gate.status)),
      )
      .returning({ id: taskInstances.id })

    if (rows.length === 0) return false

    // One submission per task, and a send-back is what allows a second one. The
    // note that was rejected is overwritten rather than kept, because the table
    // has one row per task and nowhere to put a history; what the checker
    // objected to survives as `sent_back_note` on the task itself. Whether the
    // client wants the earlier hand-in kept is part of ELEVA-121.
    await tx
      .insert(taskSubmissions)
      .values({
        taskInstanceId: id,
        submittedBy: user.personId,
        submittedAt: now,
        note: text,
      })
      .onConflictDoUpdate({
        target: taskSubmissions.taskInstanceId,
        set: { submittedBy: user.personId, submittedAt: now, note: text },
      })

    /**
     * The checker is now waiting on nothing but themselves, and nothing else on
     * any screen tells them. The note is not carried into the event: what was
     * handed in lives in `task_submissions` and is what the checker opens the
     * task to read, and a copy of it in the outbox would be a second version of
     * the same words to keep in step.
     */
    return await recordEvent(tx, {
      type: "task.finished",
      taskInstanceId: id,
      actorPersonId: user.personId,
    })
  })

  if (!moved) return { ok: false, reason: "already_moved" }
  await dispatchAfterCommit(moved)
  return { ok: true }
}

/**
 * A flag and a sentence, and no change of status: reporting a problem is not
 * abandoning the work, and a task that goes on being to-do while somebody deals
 * with the problem is the honest state. `machine/lifecycle.json` is explicit
 * about it.
 *
 * The note is required even though the column is nullable. Nullable is there so
 * that clearing the flag can null all three together; a report with no words in
 * it tells a manager that something is wrong and nothing about what, which is a
 * notification they cannot act on.
 */
export async function reportIssue(
  user: SignedIn,
  id: string,
  note: string,
): Promise<CommandResult> {
  const gate = await permitted(user, id, "report_issue")
  if (!gate.ok) return gate

  const text = note.trim()
  if (!text) return { ok: false, reason: "note_required" }

  return write(id, gate.status, {
    issueReportedAt: new Date(),
    issueReportedBy: user.personId,
    issueNote: text,
  })
}

/** Approval is what completes a task. Nothing moves it afterwards. */
export async function approve(
  user: SignedIn,
  id: string,
): Promise<CommandResult> {
  const gate = await permitted(user, id, "approve")
  if (!gate.ok) return gate
  return write(
    id,
    gate.status,
    {
      status: "checked",
      checkedAt: new Date(),
      checkedBy: user.personId,
    },
    // The loop closes for the person who did the work, and closing it silently
    // is how somebody keeps opening a task to see whether it went through.
    {
      event: {
        type: "task.checked",
        taskInstanceId: id,
        actorPersonId: user.personId,
      },
    },
  )
}

/**
 * Back to the assignee, with the reason.
 *
 * The note is required. Work returned without a word about why is the thing
 * people complain about, and the transition in `machine/lifecycle.json` carries
 * a note precisely so that it cannot happen.
 *
 * It returns to `started` and not to `todo`. The schema's own comment on
 * `sent_back_at` says `finished` to `todo`, and that reading contradicts the
 * row: `started_at` is already set, so a task sitting in `todo` would carry the
 * moment it was started, and the assignee would be offered Start for work that
 * exists and only needs correcting.
 *
 * `finished_at` is left standing rather than nulled. It is true that the work
 * was handed in at that moment, and `sent_back_at` is later, so the pair reads
 * as the rejection it was. Blanking it would delete the only record that a
 * first attempt happened.
 */
export async function sendBack(
  user: SignedIn,
  id: string,
  note: string,
): Promise<CommandResult> {
  const gate = await permitted(user, id, "send_back")
  if (!gate.ok) return gate

  const text = note.trim()
  if (!text) return { ok: false, reason: "note_required" }

  return write(
    id,
    gate.status,
    {
      status: "started",
      sentBackAt: new Date(),
      sentBackBy: user.personId,
      sentBackNote: text,
    },
    /**
     * The note goes into the event as well as onto the task, and the two are
     * not the same fact. `sent_back_note` is the current state of this task and
     * is overwritten by the next send-back; the event is what was said on the
     * day, to the person who was told, and a notification that changes its
     * words later is a notification nobody can trust.
     */
    {
      event: {
        type: "task.sent_back",
        taskInstanceId: id,
        actorPersonId: user.personId,
        payload: { note: text },
      },
    },
  )
}

/**
 * The sample audit, recorded as two timestamps and never as a status.
 *
 * The comment on `taskStatus` in lib/db/schema.ts says why there are five
 * statuses and not six: a task is complete at `checked`, and whether somebody
 * later audited it must not be able to change that. If `verified` were a
 * status, every task nobody happened to sample would sit for ever in a state
 * that reads as incomplete. So this writes the fact and leaves the status
 * exactly where the checker left it.
 */
export async function verify(
  user: SignedIn,
  id: string,
): Promise<CommandResult> {
  const gate = await permitted(user, id, "verify")
  if (!gate.ok) return gate
  // Belt as well as braces. The gate reads the row and then this writes it, so
  // two verifiers pressing at the same moment both pass the gate. `verify` is
  // the one verb that leaves the status alone, so the status in the WHERE
  // cannot separate them; the audit stamp itself has to.
  // No event. The audit is invisible to the assignee by design: the task was
  // already complete when it was checked, and telling somebody that a sample of
  // finished work was looked at asks them to do nothing at all.
  return write(
    id,
    gate.status,
    {
      verifiedAt: new Date(),
      verifiedBy: user.personId,
    },
    { alsoRequire: isNull(taskInstances.verifiedAt) },
  )
}

/**
 * Closing a one-time task, by whoever created it.
 *
 * ## What close means, against the five statuses there are
 *
 * `checked`, and the same write as `approve`. There is no sixth status and
 * there should not be one: `todo` and `started` say the work is outstanding,
 * `missed` says it ran out of time, and a task somebody deliberately completed
 * is none of those. That leaves `checked`, which is exactly what it means here
 * — the person who asked for the work has looked at it and is satisfied.
 *
 * `availableActions` already says the same thing in the other direction: it
 * offers `close` in place of `approve`, at the same status, to the same effect,
 * and only changes the word. The creator of a one-time duty is its checker in
 * fact, and a `closed` status would give every report two spellings of complete
 * and force each of them to know about both.
 *
 * Only a one-time task, because `close` ends something nothing will reissue. A
 * daily duty issues another instance tomorrow, so closing today's says nothing
 * about it; the remedy there is to deactivate the template. `relationsOf` will
 * not hand out `one_time_creator` on a recurring duty at all, so this is
 * refused before it gets here rather than tested twice.
 */
export async function close(
  user: SignedIn,
  id: string,
): Promise<CommandResult> {
  const gate = await permitted(user, id, "close")
  if (!gate.ok) return gate
  // The same write as `approve`, so the same event. The creator of a one-time
  // task is its checker in fact, and the assignee is owed the same sentence:
  // two spellings of complete would mean every consumer of the outbox learning
  // about both.
  return write(
    id,
    gate.status,
    {
      status: "checked",
      checkedAt: new Date(),
      checkedBy: user.personId,
    },
    {
      event: {
        type: "task.checked",
        taskInstanceId: id,
        actorPersonId: user.personId,
      },
    },
  )
}

/**
 * Nudging the assignee, which is the one verb whose whole effect is the
 * notification.
 *
 * It used to refuse with `not_built`, and the refusal said why: there was no
 * notifications table, nothing sent anything, and writing a `reminded_at` to
 * make the button feel answered would have been a column reporting reminders
 * nobody received. Both halves of that are now false, so the refusal is gone
 * and there is still no `reminded_at`: the event is the record. It carries the
 * words, the moment and who sent them, which is more than a timestamp on the
 * task could hold, and it holds every reminder rather than only the last.
 *
 * The task itself does not move, and must not. A reminder is not a change of
 * state; the work is exactly as owed after it as before, and the only thing
 * that is different is that somebody has been asked about it.
 *
 * A transaction around one insert, because the rule is that an event is written
 * with the change that caused it and an exception to that rule is how the rule
 * stops being true. Here the event *is* the change.
 */
export async function remind(
  user: SignedIn,
  id: string,
  /**
   * The specification calls this action `send_reminder_with_note`, and the note
   * is the whole of it: a nudge with no words is a notification somebody
   * dismisses.
   */
  note: string,
): Promise<CommandResult> {
  const gate = await permitted(user, id, "remind")
  if (!gate.ok) return gate

  const text = note.trim()
  if (!text) return { ok: false, reason: "note_required" }

  const eventId = await db.transaction((tx) =>
    recordEvent(tx, {
      type: "task.reminded",
      taskInstanceId: id,
      actorPersonId: user.personId,
      payload: { note: text },
    }),
  )

  await dispatchAfterCommit(eventId)
  return { ok: true }
}
