'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import {
  authenticateParent,
  authenticateStudent,
  createSession,
  destroySession,
  requireParent,
  requireStudent,
} from '@/lib/auth'
import { requestSession, startSession } from '@/lib/agent-sessions'
import { gradeMultipleChoice } from '@/lib/grading-run'
import { createChoreForSet } from '@/lib/chores'
import { recomputeStudentState } from '@/lib/state'
import { isAllowedImageType, saveUpload } from '@/lib/uploads'
import { MAX_HINTS } from '@/lib/prompts'

// ── Auth ────────────────────────────────────────────────────────────────────

export async function loginParent(formData: FormData) {
  const email = String(formData.get('email') ?? '')
  const password = String(formData.get('password') ?? '')
  const parent = await authenticateParent(email, password)
  if (!parent) return { error: 'That email and password did not match.' }
  await createSession({ kind: 'parent', id: parent.id, name: parent.name })
  redirect('/parent')
}

export async function loginStudent(formData: FormData) {
  const slug = String(formData.get('slug') ?? '')
  const passcode = String(formData.get('passcode') ?? '')
  const student = await authenticateStudent(slug, passcode)
  if (!student) return { error: "That passcode didn't work. Try again?" }
  await createSession({
    kind: 'student',
    id: student.id,
    name: student.name,
    slug: student.slug,
  })
  redirect('/student')
}

export async function logout() {
  await destroySession()
  redirect('/login')
}

// ── Parent: generate, review, release ───────────────────────────────────────

/**
 * Starts a transient Claude session on the home Mac mini to write the next set.
 * Returns as soon as the SSH lands — the Mac detaches, writes items over the
 * next minute or two, and POSTs them back as a PENDING_REVIEW set.
 */
export async function generateSetAction(
  studentId: string,
  track: 'ACADEMIC' | 'SPIRITUAL' = 'ACADEMIC',
) {
  await requireParent()
  try {
    // Never refused for being busy: if the mini is occupied the request queues
    // and goes the moment it frees up. "Generate now" should mean now-ish, not
    // "not while something else is happening."
    const { queued } = await requestSession({ kind: 'GENERATE', studentId, track })
    revalidatePath('/parent')
    return { ok: true, queued }
  } catch (err) {
    return { error: err instanceof Error ? err.message : 'Could not start the agent.' }
  }
}

/**
 * Guard: an item may not be edited once a boy is working through the set.
 *
 * Learned the hard way on 2026-08-28 — items were corrected while Jeremiah was
 * mid-sitting, so he could have answered a modelling item under one set of
 * numbers and its matched control under another, which quietly destroys the
 * comparison the pair exists to make. A set that has been opened is frozen.
 */
async function assertEditable(itemId: string): Promise<string | null> {
  const setItems = await db.setItem.findMany({
    where: { itemId },
    include: { set: true, response: true },
  })
  for (const si of setItems) {
    if (si.response) {
      return 'That question has already been answered — editing it now would change what he was actually asked.'
    }
    if (si.set.status === 'IN_PROGRESS') {
      return `${'He has already started this set'} — editing an item mid-sitting can leave a matched pair inconsistent. Wait until he finishes.`
    }
    if (si.set.status === 'COMPLETE') {
      return 'This set is finished — its items are the record of what he was asked.'
    }
  }
  return null
}

export async function updateItemAction(itemId: string, formData: FormData) {
  await requireParent()
  const blocked = await assertEditable(itemId)
  if (blocked) return { error: blocked }

  const prompt = String(formData.get('prompt') ?? '').trim()
  const stimulusRaw = String(formData.get('stimulus') ?? '').trim()
  const criteria = String(formData.get('rubricCriteria') ?? '').trim()
  if (!prompt || !criteria) return { error: 'Prompt and criteria cannot be empty.' }

  const item = await db.item.update({
    where: { id: itemId },
    data: {
      prompt,
      stimulus: stimulusRaw || null,
      rubricCriteria: criteria,
      editedByParent: true,
    },
    include: { setItems: true },
  })
  for (const si of item.setItems) revalidatePath(`/parent/review/${si.setId}`)
  return { ok: true }
}

/**
 * Put back an item the reviewer withheld. The reviewer is a safety net, not an
 * authority — its note stays visible either way.
 */
export async function restoreItemAction(setItemId: string) {
  await requireParent()
  const si = await db.setItem.update({
    where: { id: setItemId },
    data: { droppedByReviewer: false },
  })
  revalidatePath(`/parent/review/${si.setId}`)
  return { ok: true }
}

/** Drop one item from a draft without discarding the whole set. */
export async function dropItemAction(setItemId: string) {
  await requireParent()
  const si = await db.setItem.findUniqueOrThrow({
    where: { id: setItemId },
    include: { set: true, response: true },
  })
  // Same reasoning as assertEditable: never change a sitting that is under way.
  if (si.response || ['IN_PROGRESS', 'COMPLETE'].includes(si.set.status)) {
    return { error: 'He has already started this set — dropping an item now would change it under him.' }
  }
  await db.setItem.delete({ where: { id: setItemId } })
  revalidatePath(`/parent/review/${si.setId}`)
  return { ok: true }
}

export async function approveSetAction(setId: string) {
  await requireParent()
  // Reviewer-dropped items don't count toward the set having content.
  const count = await db.setItem.count({ where: { setId, droppedByReviewer: false } })
  if (count === 0) return { error: 'Every item in this set is dropped — nothing to release.' }

  await db.itemSet.update({
    where: { id: setId },
    data: { status: 'RELEASED', approvedAt: new Date(), releasedAt: new Date() },
  })

  // Attach a chore worth points so the set lands with his other
  // responsibilities. Off by default while the system is being tested — see
  // CHORES_ENABLED in src/lib/chores.ts. Never allowed to fail the release: the
  // chores app being down is not a reason a boy can't do his practice set.
  const chore = await createChoreForSet(setId)

  revalidatePath('/parent')
  if (!chore.ok) {
    return { ok: true, warning: `Released, but the chore wasn't created: ${chore.error}` }
  }
  return { ok: true }
}

export async function discardSetAction(setId: string) {
  await requireParent()
  // Kept as DISCARDED rather than deleted, so the generator's dedup list still
  // knows these items were written and won't reproduce them.
  await db.itemSet.update({ where: { id: setId }, data: { status: 'DISCARDED' } })
  revalidatePath('/parent')
  redirect('/parent')
}

/**
 * Grade one submitted response, or a whole set. Multiple choice is settled here
 * and now — it has an answer key and needs no model. Anything else starts an
 * agent session on the Mac.
 */
export async function gradeResponseAction(responseId: string) {
  await requireParent()
  const response = await db.response.findUnique({
    where: { id: responseId },
    include: { setItem: { include: { item: true } } },
  })
  if (!response) return { error: 'Response not found.' }

  if (response.setItem.item.format === 'MULTIPLE_CHOICE') {
    await gradeMultipleChoice(responseId)
    revalidatePath('/parent')
    return { ok: true }
  }

  try {
    const sessionId = await startSession({ kind: 'GRADE', responseId })
    revalidatePath('/parent')
    return { ok: true, sessionId }
  } catch (err) {
    return { error: err instanceof Error ? err.message : 'Could not start the agent.' }
  }
}

/** Grade everything ungraded in one completed set, in a single agent session. */
export async function gradeSetAction(setId: string) {
  await requireParent()
  // Answer-key items first — free, instant, and they shrink the agent's queue.
  const mcResponses = await db.response.findMany({
    where: {
      gradings: { none: {} },
      setItem: { setId, item: { format: 'MULTIPLE_CHOICE' } },
    },
    select: { id: true },
  })
  for (const r of mcResponses) await gradeMultipleChoice(r.id)

  try {
    const sessionId = await startSession({ kind: 'GRADE', setId })
    revalidatePath('/parent')
    return { ok: true, sessionId }
  } catch (err) {
    return { error: err instanceof Error ? err.message : 'Could not start the agent.' }
  }
}

/**
 * Rule 5 adjudication. Eric's score wins and is stored as a HUMAN grading pass,
 * which unflags the response and lets it count toward state.
 */
export async function adjudicateAction(responseId: string, formData: FormData) {
  await requireParent()
  const response = await db.response.findUniqueOrThrow({
    where: { id: responseId },
    include: { setItem: { include: { item: true } } },
  })
  const dimensions = await db.constructDimension.findMany({
    where: { constructCode: response.setItem.item.constructCode },
  })

  const scores: { dimensionId: string; value: number }[] = []
  for (const d of dimensions) {
    const raw = formData.get(`dim_${d.code}`)
    if (raw === null || raw === '') continue
    const value = Number(raw)
    if (!Number.isFinite(value)) continue
    scores.push({ dimensionId: d.id, value: Math.max(0, Math.min(d.maxValue, Math.round(value))) })
  }
  if (!scores.length) return { error: 'Enter at least one score.' }

  await db.grading.upsert({
    where: { responseId_gradedBy_pass: { responseId, gradedBy: 'HUMAN', pass: 0 } },
    create: {
      responseId,
      gradedBy: 'HUMAN',
      pass: 0,
      rubricVersion: 'human',
      rubricHash: 'human',
      promptVersion: 'human',
      modelId: 'human',
      justification: String(formData.get('note') ?? '') || null,
      scores: { create: scores },
    },
    update: {
      justification: String(formData.get('note') ?? '') || null,
      scores: { deleteMany: {}, create: scores },
    },
  })

  await db.response.update({
    where: { id: responseId },
    data: { flagged: false, flagReason: null },
  })
  await recomputeStudentState(response.studentId)
  revalidatePath('/parent')
  return { ok: true }
}

/** Rebuild every estimate from stored scores — run after a rubric change. */
export async function recomputeAllStatesAction() {
  await requireParent()
  const students = await db.student.findMany({ select: { id: true } })
  for (const s of students) await recomputeStudentState(s.id)
  revalidatePath('/parent')
  return { ok: true }
}

// ── Parent: the spiritual topic list ────────────────────────────────────────

/**
 * Eric's steering wheel for the spiritual track. The generator may only draw
 * from this list — wandering off it would make the list pointless, and this is
 * the one place he says what they should be learning right now.
 */
export async function addTopicAction(formData: FormData) {
  await requireParent()
  const constructCode = String(formData.get('constructCode') ?? '')
  const label = String(formData.get('label') ?? '').trim()
  const notes = String(formData.get('notes') ?? '').trim()
  if (!label) return { error: 'Give the topic a name.' }

  const construct = await db.construct.findUnique({ where: { code: constructCode } })
  if (!construct) return { error: 'Pick which kind of topic it is.' }

  const last = await db.topic.findFirst({ orderBy: { sortOrder: 'desc' } })
  await db.topic.create({
    data: {
      constructCode,
      label,
      notes: notes || null,
      sortOrder: (last?.sortOrder ?? 0) + 1,
    },
  })
  revalidatePath('/parent/topics')
  return { ok: true }
}

export async function toggleTopicAction(id: string) {
  await requireParent()
  const t = await db.topic.findUniqueOrThrow({ where: { id } })
  await db.topic.update({ where: { id }, data: { active: !t.active } })
  revalidatePath('/parent/topics')
  return { ok: true }
}

export async function deleteTopicAction(id: string) {
  await requireParent()
  await db.topic.delete({ where: { id } })
  revalidatePath('/parent/topics')
  return { ok: true }
}

// ── Student: ask for a hint ─────────────────────────────────────────────────

/**
 * A nudge, while he is still working. Capped: after MAX_HINTS the only honest
 * option left is the full explanation, because a fourth hint is a concession
 * with extra steps and the item would still get scored.
 */
export async function askHintAction(setItemId: string, formData: FormData) {
  const session = await requireStudent()

  const setItem = await db.setItem.findUnique({
    where: { id: setItemId },
    include: { set: true, response: true, hints: true },
  })
  if (!setItem) return { error: 'Question not found.' }
  if (setItem.set.studentId !== session.id) return { error: 'Not your set.' }
  if (setItem.response) return { error: 'You have already answered this one.' }
  if (setItem.droppedByReviewer) return { error: 'That question was withdrawn.' }
  if (!['RELEASED', 'IN_PROGRESS'].includes(setItem.set.status)) {
    return { error: 'This set is not open.' }
  }
  if (setItem.hints.length >= MAX_HINTS) {
    return {
      error: `That's all the hints I can give on this one. If you're still stuck, use "show me how" — that's what it's for.`,
    }
  }

  const question = String(formData.get('question') ?? '').trim()
  if (!question) return { error: 'Tell me what you are stuck on first.' }
  if (question.length > 1000) return { error: 'That is a bit long — can you shorten it?' }

  await db.hintRequest.create({
    data: { setItemId, studentId: session.id, question },
  })

  try {
    await startSession({ kind: 'ANSWER', trigger: 'auto' })
  } catch {
    /* mini busy — the hourly sweep picks it up, hints jump the queue */
  }

  revalidatePath(`/student/take/${setItem.setId}`)
  return { ok: true }
}

// ── Student: concede, and ask about it ──────────────────────────────────────

/**
 * "I'm stuck — show me how."
 *
 * He writes a line about what he tried (anything, including "I don't know where
 * to start"), and gets the explanation immediately — it was written when the set
 * was generated precisely so this is instant.
 *
 * The response is stored as conceded: not graded, excluded from every estimate,
 * counted as evidence of difficulty. What he wrote is kept, because it is
 * usually the part that says WHERE he stalled.
 */
export async function concedeAction(setItemId: string, formData: FormData) {
  const session = await requireStudent()

  const setItem = await db.setItem.findUnique({
    where: { id: setItemId },
    include: { item: true, set: true, response: true },
  })
  if (!setItem) return { error: 'Question not found.' }
  if (setItem.set.studentId !== session.id) return { error: 'Not your set.' }
  if (setItem.droppedByReviewer) return { error: 'That question was withdrawn.' }
  if (setItem.response) return { error: 'Already answered.' }
  if (!['RELEASED', 'IN_PROGRESS'].includes(setItem.set.status)) {
    return { error: 'This set is not open.' }
  }

  const attempt = String(formData.get('attempt') ?? '').trim()

  const previous = await db.response.findFirst({
    where: { setItem: { setId: setItem.setId } },
    orderBy: { submittedAt: 'desc' },
    select: { submittedAt: true },
  })
  const startedAt = previous?.submittedAt ?? setItem.set.startedAt ?? null

  await db.response.create({
    data: {
      setItemId,
      studentId: session.id,
      typedText: attempt || null,
      startedAt,
      timeSpentMs: startedAt ? Date.now() - startedAt.getTime() : null,
      conceded: true,
      concededAt: new Date(),
      hintsUsed: await db.hintRequest.count({ where: { setItemId, status: 'ANSWERED' } }),
    },
  })

  const remaining = await db.setItem.count({
    where: { setId: setItem.setId, response: { is: null }, droppedByReviewer: false },
  })
  if (remaining === 0) {
    await db.itemSet.update({
      where: { id: setItem.setId },
      data: { status: 'COMPLETE', completedAt: new Date() },
    })
    try {
      await startSession({ kind: 'GRADE', setId: setItem.setId, trigger: 'auto' })
    } catch {
      /* nothing left to grade, or the mini is busy — the cron sweeps it */
    }
  }

  revalidatePath(`/student/take/${setItem.setId}`)
  // Land on the explanation and stay there until he chooses to move on —
  // showing the next question straight away would bury the thing he just asked
  // to understand.
  redirect(`/student/take/${setItem.setId}?explain=${setItemId}`)
}

/**
 * A follow-up question after conceding. Genuinely slow — it goes to the mini —
 * so the UI says so rather than pretending to be a chat.
 */
export async function askQuestionAction(responseId: string, formData: FormData) {
  const session = await requireStudent()

  const response = await db.response.findUnique({
    where: { id: responseId },
    include: { setItem: { include: { set: true } } },
  })
  if (!response || response.studentId !== session.id) return { error: 'Not your work.' }
  // Open once he has seen how it works: either he conceded and was shown it
  // mid-set, or the whole set is finished and he is reading it back.
  if (!response.conceded && response.setItem.set.status !== 'COMPLETE') {
    return { error: 'Questions open up once you have seen the explanation.' }
  }

  const question = String(formData.get('question') ?? '').trim()
  if (!question) return { error: 'Type your question first.' }
  if (question.length > 1000) return { error: 'That is a bit long — can you shorten it?' }

  await db.studentQuestion.create({
    data: { responseId, studentId: session.id, question },
  })

  // Try to start it now; if the mini is busy the hourly sweep picks it up, and
  // questions jump the queue ahead of grading.
  try {
    await startSession({ kind: 'ANSWER', trigger: 'auto' })
  } catch {
    /* queued */
  }

  revalidatePath(`/student/take/${response.setItemId}`)
  return { ok: true }
}

// ── Student: take a set ─────────────────────────────────────────────────────

/**
 * useActionState wrapper. Exists so the students' form can bind a real server
 * action and therefore submit WITHOUT JavaScript — worth having for the one
 * flow a ten-year-old actually uses on an iPad.
 */
export async function submitResponseFormAction(
  setItemId: string,
  _prevState: { error?: string; ok?: boolean } | null,
  formData: FormData,
): Promise<{ error?: string; ok?: boolean }> {
  return (await submitResponseAction(setItemId, formData)) ?? {}
}

export async function submitResponseAction(setItemId: string, formData: FormData) {
  const session = await requireStudent()

  const setItem = await db.setItem.findUniqueOrThrow({
    where: { id: setItemId },
    include: { item: true, set: true, response: true },
  })
  if (setItem.set.studentId !== session.id) return { error: 'Not your set.' }
  if (setItem.droppedByReviewer) return { error: 'That question was withdrawn.' }
  if (setItem.response) return { error: 'Already answered.' }
  if (!['RELEASED', 'IN_PROGRESS'].includes(setItem.set.status)) {
    return { error: 'This set is not open.' }
  }

  const typedText = String(formData.get('typedText') ?? '').trim()
  const choiceKey = String(formData.get('choiceKey') ?? '').trim()

  const imagePaths: string[] = []
  for (const entry of formData.getAll('images')) {
    if (!(entry instanceof File) || entry.size === 0) continue
    if (!isAllowedImageType(entry.type)) {
      return { error: 'Photos need to be JPEG, PNG or HEIC-converted images.' }
    }
    imagePaths.push(await saveUpload(entry))
  }

  if (!typedText && !choiceKey && imagePaths.length === 0) {
    return { error: 'Nothing to submit yet.' }
  }

  // Timing. The client sends timeSpentMs when JavaScript is running; the
  // server-side fallback is the gap since the previous item was submitted (or
  // since the set was opened), which needs no JS and cannot be faked from the
  // form. notes/05-caveats.md: a rushed sitting invalidates a test, so this is
  // measured rather than assumed.
  const previous = await db.response.findFirst({
    where: { setItem: { setId: setItem.setId } },
    orderBy: { submittedAt: 'desc' },
    select: { submittedAt: true },
  })
  const startedAt = previous?.submittedAt ?? setItem.set.startedAt ?? null
  const clientMs = Number(formData.get('timeSpentMs')) || null
  const serverMs = startedAt ? Date.now() - startedAt.getTime() : null

  // Travels with the score everywhere it is shown: an estimate that quietly
  // mixed unaided work with nudged work would drift upward and read as growth.
  const hintsUsed = await db.hintRequest.count({
    where: { setItemId, status: 'ANSWERED' },
  })

  await db.response.create({
    data: {
      setItemId,
      studentId: session.id,
      typedText: typedText || null,
      choiceKey: choiceKey || null,
      imagePaths: imagePaths.length ? imagePaths : undefined,
      startedAt,
      timeSpentMs: clientMs ?? serverMs,
      hintsUsed,
    },
  })

  // The set is flipped to IN_PROGRESS when he OPENS it (see the take page), so
  // there is nothing to do here — startedAt is already the moment he began.

  // Complete the set once every item has a response.
  const remaining = await db.setItem.count({
    where: { setId: setItem.setId, response: { is: null }, droppedByReviewer: false },
  })
  if (remaining === 0) {
    await db.itemSet.update({
      where: { id: setItem.setId },
      data: { status: 'COMPLETE', completedAt: new Date() },
    })
    // He's finished — start grading now so the results email follows on its own.
    // A failure here is fine and deliberately swallowed: the hourly cron sweeps
    // ungraded responses, so a busy mini just means the email arrives later. It
    // must never surface to the boy, who has simply handed in his work.
    try {
      await startSession({ kind: 'GRADE', setId: setItem.setId, trigger: 'auto' })
    } catch {
      /* picked up by the cron sweep */
    }
  }

  revalidatePath(`/student/take/${setItem.setId}`)
  return { ok: true }
}
