import 'server-only'
import { db } from './db'
import {
  CONSTRUCTS_BY_CODE,
  constructsFor,
  defaultResponseMode,
  type Track,
} from './constructs'
import { GENERATOR_PROMPT_VERSION } from './rubrics'

/**
 * Item generation, VPS half.
 *
 * The model call itself happens in a transient Claude session on the Mac mini.
 * This module builds the state snapshot the agent is given, and persists what
 * it sends back — validated, because the agent's JSON is untrusted input.
 */

export interface StateSnapshot {
  grade: number
  course_note: string | null
  track: Track
  topics: {
    id: string
    construct_code: string
    label: string
    notes: string | null
    last_covered: string | null
  }[]
  constructs: unknown[]
  previously_used_items: string[]
  /** Questions he has asked that no later item has probed yet. */
  unaddressed_questions: {
    question_id: string
    /** "gave up and asked" or "asked for a hint mid-problem" — different signals. */
    kind: string
    construct_code: string
    the_item: string
    what_he_tried: string | null
    what_he_asked: string
    what_he_was_told: string | null
    asked_on: string
  }[]
}

/**
 * What the generator sees. Stored verbatim on the GenerationRun so a bad set can
 * be explained later rather than guessed at.
 */
export async function buildStateSnapshot(
  studentId: string,
  track: Track = 'ACADEMIC',
): Promise<StateSnapshot> {
  const student = await db.student.findUniqueOrThrow({ where: { id: studentId } })
  const allowed = constructsFor(student.slug, track)

  const states = await db.constructState.findMany({
    where: { studentId },
    include: {
      construct: true,
      dimensions: {
        include: { dimension: true },
        orderBy: { dimension: { sortOrder: 'asc' } },
      },
    },
  })

  // Only this track's history — a spiritual set has no business deduplicating
  // against quail-feed word problems.
  const priorItems = await db.item.findMany({
    where: { studentId, constructCode: { in: allowed } },
    select: { constructCode: true, prompt: true, format: true },
    orderBy: { createdAt: 'desc' },
    take: 250,
  })

  // The sharpest signal available. A score says he found something hard; a
  // question says exactly WHICH idea he could not get hold of, in his own
  // words. Only those not yet followed up — once an item has probed the idea,
  // it stops being an open thread.
  const openQuestions = await db.studentQuestion.findMany({
    where: { studentId, addressedBySetId: null, status: 'ANSWERED' },
    orderBy: { askedAt: 'asc' },
    take: 30,
    include: { response: { include: { setItem: { include: { item: true } } } } },
  })

  // Hint requests carry the same diagnostic weight and often more: he was mid
  // problem, had done some of it, and said exactly which step he could not
  // make. That is a sharper localisation than a question asked afterwards.
  const openHints = await db.hintRequest.findMany({
    where: { studentId, addressedBySetId: null, status: 'ANSWERED' },
    orderBy: { askedAt: 'asc' },
    take: 30,
    include: { setItem: { include: { item: true, response: true } } },
  })

  const constructs = allowed.map((code) => {
    const def = CONSTRUCTS_BY_CODE[code]
    const state = states.find((s) => s.constructCode === code)
    return {
      code,
      label: def?.label ?? code,
      description: def?.description ?? '',
      why_in_project: def?.rationale ?? '',
      status: state
        ? 'has evidence'
        : 'NO BASELINE YET — the first set on this construct is its baseline',
      rounds_seen: state?.roundsSeen ?? 0,
      // Evidence of difficulty, not of ability: these items were not scored
      // because he saw the answer. A high count here is a stronger signal than
      // a low estimate, and it should pull the next set toward this construct.
      items_given_up_on: state?.itemsConceded ?? 0,
      items_attempted_total: state?.itemsSeen ?? 0,
      summary: state?.summary ?? null,
      dimensions:
        state?.dimensions.map((d) => ({
          code: d.dimension.code,
          label: d.dimension.label,
          estimate_pct: d.estimate === null ? null : Math.round(d.estimate * 100),
          items_observed: d.itemsObserved,
          items_excluded_grader_disagreement: d.itemsFlagged,
          movement_pct:
            d.recentEstimate !== null && d.previousEstimate !== null
              ? Math.round((d.recentEstimate - d.previousEstimate) * 100)
              : null,
        })) ?? null,
    }
  })

  const topics =
    track === 'SPIRITUAL'
      ? await db.topic.findMany({
          where: { active: true, constructCode: { in: allowed } },
          orderBy: [{ lastCoveredAt: 'asc' }, { sortOrder: 'asc' }],
        })
      : []

  return {
    grade: student.grade,
    course_note: student.courseNote,
    track,
    topics: topics.map((t) => ({
      id: t.id,
      construct_code: t.constructCode,
      label: t.label,
      notes: t.notes,
      last_covered: t.lastCoveredAt ? t.lastCoveredAt.toISOString().slice(0, 10) : null,
    })),
    constructs,
    previously_used_items: priorItems.map(
      (i) => `[${i.constructCode}/${i.format}] ${i.prompt}`,
    ),
    unaddressed_questions: [
      ...openQuestions.map((q) => ({
        question_id: q.id,
        kind: 'gave up on the item, then asked',
        construct_code: q.response.setItem.item.constructCode,
        the_item: q.response.setItem.item.prompt,
        what_he_tried: q.response.typedText,
        what_he_asked: q.question,
        what_he_was_told: q.answer,
        asked_on: q.askedAt.toISOString().slice(0, 10),
      })),
      ...openHints.map((h) => ({
        question_id: h.id,
        kind: 'asked for a hint mid-problem',
        construct_code: h.setItem.item.constructCode,
        the_item: h.setItem.item.prompt,
        what_he_tried: h.setItem.response?.typedText ?? null,
        what_he_asked: h.question,
        what_he_was_told: h.hint,
        asked_on: h.askedAt.toISOString().slice(0, 10),
      })),
    ],
  }
}

const FORMATS = new Set([
  'MULTIPLE_CHOICE',
  'CONSTRUCTED_RESPONSE',
  'ESTIMATE_THEN_JUSTIFY',
  'ERROR_ANALYSIS',
  'CRITIQUE',
  'MODELING',
  'COMPUTATION_CONTROL',
  'REVISE_PARAGRAPH',
  'SOURCE_RESPONSE',
])

export interface GeneratedItemIn {
  construct_code?: unknown
  format?: unknown
  stimulus?: unknown
  prompt?: unknown
  choices?: unknown
  answer_key?: unknown
  rubric_criteria?: unknown
  rationale?: unknown
  pair_group?: unknown
  addresses_question_id?: unknown
}

export interface GeneratedSetIn {
  title?: unknown
  target_plan?: unknown
  items?: unknown
}

function str(v: unknown): string | null {
  return typeof v === 'string' && v.trim() ? v.trim() : null
}

/**
 * Persist what the agent produced. Everything is re-validated here: this is
 * JSON off a pipe, and an item with an unknown construct or a missing rubric
 * would poison the measurement rather than merely look wrong.
 */
export async function persistGeneratedSet(opts: {
  studentId: string
  payload: GeneratedSetIn
  modelId: string
  stateSnapshot: StateSnapshot
  track?: Track
  costUsd?: number | null
}): Promise<{ setId: string; itemCount: number; skipped: number }> {
  const track: Track = opts.track ?? 'ACADEMIC'
  const student = await db.student.findUniqueOrThrow({ where: { id: opts.studentId } })
  const allowed = new Set(constructsFor(student.slug, track))

  const rawItems = Array.isArray(opts.payload.items) ? opts.payload.items : []
  const clean: {
    constructCode: string
    format: string
    stimulus: string | null
    prompt: string
    choices: { key: string; text: string }[] | null
    answerKey: string | null
    rubricCriteria: string
    rationale: string | null
    pairGroup: string | null
    addressesQuestionId: string | null
  }[] = []

  let skipped = 0
  for (const raw of rawItems as GeneratedItemIn[]) {
    const constructCode = str(raw.construct_code)
    const format = str(raw.format)
    const prompt = str(raw.prompt)
    const rubricCriteria = str(raw.rubric_criteria)

    // An item that fails any of these cannot produce trustworthy evidence, so
    // it is dropped rather than stored half-formed.
    if (!constructCode || !allowed.has(constructCode)) { skipped++; continue }
    if (!format || !FORMATS.has(format)) { skipped++; continue }
    if (!prompt || !rubricCriteria) { skipped++; continue }

    let choices: { key: string; text: string }[] | null = null
    if (Array.isArray(raw.choices)) {
      const parsed = (raw.choices as { key?: unknown; text?: unknown }[])
        .map((c) => ({ key: str(c?.key), text: str(c?.text) }))
        .filter((c): c is { key: string; text: string } => !!c.key && !!c.text)
      choices = parsed.length ? parsed : null
    }
    if (format === 'MULTIPLE_CHOICE' && !choices) { skipped++; continue }

    clean.push({
      constructCode,
      format,
      stimulus: str(raw.stimulus),
      prompt,
      choices,
      answerKey: format === 'MULTIPLE_CHOICE' ? str(raw.answer_key) : null,
      rubricCriteria,
      rationale: str(raw.rationale),
      pairGroup: str(raw.pair_group),
      addressesQuestionId: str(raw.addresses_question_id),
    })
  }

  if (!clean.length) throw new Error('No usable items in the agent payload')

  const lastRound = await db.itemSet.findFirst({
    where: { studentId: opts.studentId, track },
    orderBy: { round: 'desc' },
    select: { round: true },
  })

  const set = await db.itemSet.create({
    data: {
      studentId: opts.studentId,
      track,
      round: (lastRound?.round ?? 0) + 1,
      title: str(opts.payload.title) ?? 'Practice set',
      // Eric reviews before any boy can open it.
      status: 'PENDING_REVIEW',
    },
  })

  const run = await db.generationRun.create({
    data: {
      studentId: opts.studentId,
      setId: set.id,
      promptVersion: GENERATOR_PROMPT_VERSION,
      modelId: opts.modelId,
      stateSnapshot: opts.stateSnapshot as unknown as object,
      targetPlan: { plan: str(opts.payload.target_plan) } as object,
      raw: opts.payload as object,
    },
  })

  const created: { id: string; pairGroup: string | null; format: string }[] = []
  let position = 0
  for (const item of clean) {
    const def = CONSTRUCTS_BY_CODE[item.constructCode]
    const row = await db.item.create({
      data: {
        studentId: opts.studentId,
        constructCode: item.constructCode,
        format: item.format as never,
        responseMode:
          item.format === 'MULTIPLE_CHOICE' ? 'TYPED' : defaultResponseMode(def!.subject),
        stimulus: item.stimulus,
        prompt: item.prompt,
        choices: item.choices ? (item.choices as object) : undefined,
        answerKey: item.answerKey,
        rubricCriteria: item.rubricCriteria,
        rationale: item.rationale,
        addressesQuestionId: item.addressesQuestionId,
        generatorPromptVersion: GENERATOR_PROMPT_VERSION,
        generatorModelId: opts.modelId,
        generationRunId: run.id,
      },
    })
    created.push({ id: row.id, pairGroup: item.pairGroup, format: item.format })
    await db.setItem.create({ data: { setId: set.id, itemId: row.id, position: position++ } })
  }

  // Mark the topics this set covered so the generator moves on next time
  // rather than circling the same few.
  if (track === 'SPIRITUAL') {
    const covered = new Set(
      String((opts.payload as { target_plan?: unknown }).target_plan ?? '').match(
        /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
      ) ?? [],
    )
    if (covered.size) {
      await db.topic.updateMany({
        where: { id: { in: [...covered] } },
        data: { lastCoveredAt: new Date() },
      })
    }
  }

  // Close the loop: mark each question this set actually probes, so it stops
  // being an open thread and Eric can see which item followed it up.
  // created[] is built in the same order as clean[], so the index is the link.
  for (const [index, item] of clean.entries()) {
    if (!item.addressesQuestionId) continue
    const row = created[index]
    const mark = {
      addressedBySetId: set.id,
      addressedByItemId: row?.id ?? null,
      addressedAt: new Date(),
    }
    try {
      // The id may belong to either kind; updateMany makes the miss a no-op.
      await db.studentQuestion.updateMany({
        where: { id: item.addressesQuestionId, studentId: opts.studentId },
        data: mark,
      })
      await db.hintRequest.updateMany({
        where: { id: item.addressesQuestionId, studentId: opts.studentId },
        data: mark,
      })
    } catch {
      // A hallucinated question id is not worth failing a whole set over.
    }
  }

  // Wire up matched pairs.
  const groups = new Map<string, typeof created>()
  for (const c of created) {
    if (!c.pairGroup) continue
    if (!groups.has(c.pairGroup)) groups.set(c.pairGroup, [])
    groups.get(c.pairGroup)!.push(c)
  }
  for (const members of groups.values()) {
    if (members.length !== 2) continue
    const [a, b] = members
    const kind =
      a.format === 'MULTIPLE_CHOICE' || b.format === 'MULTIPLE_CHOICE'
        ? 'MC_VS_CONSTRUCTED'
        : 'MODELING_VS_COMPUTATION'
    await db.item.update({ where: { id: a.id }, data: { pairedItemId: b.id, pairKind: kind } })
    await db.item.update({ where: { id: b.id }, data: { pairKind: kind } })
  }

  return { setId: set.id, itemCount: created.length, skipped }
}
