import 'server-only'
import { db } from './db'
import { CONSTRUCTS_BY_CODE } from './constructs'
import { rubricHash, rubricVersionFor, GRADER_PROMPT_VERSION } from './rubrics'
import { recomputeStudentState } from './state'

/**
 * Writing the measurement record.
 *
 * The agent posts scores; THIS module writes them. That split is deliberate:
 * Rules 1, 5 and 6 are guarantees of the instrument, and they are enforced here,
 * server-side, where a prompt cannot reach them.
 *
 *   - Scores can only be written per dimension, against dimensions that actually
 *     belong to the item's construct. Anything else is rejected.
 *   - The rubric version and hash are computed HERE and compared against what
 *     the agent echoes back. A mismatch means the agent graded against something
 *     other than the current rubric, and the score is refused rather than
 *     silently recorded as comparable.
 *   - Disagreement between the two passes is measured here, not reported by the
 *     agent.
 */

export interface DimensionScoreIn {
  dimensionCode: string
  value: number
  justification?: string | null
}

export interface RecordGradingInput {
  responseId: string
  pass: number
  scores: DimensionScoreIn[]
  overallNote: string
  /** Echoed back by the agent from its work payload; verified, not trusted. */
  rubricVersion: string
  rubricHash: string
  promptVersion: string
  /** From `claude -p --output-format json` — the model that actually ran. */
  modelId: string
  raw?: unknown
}

export async function recordAiGrading(input: RecordGradingInput): Promise<void> {
  const response = await db.response.findUniqueOrThrow({
    where: { id: input.responseId },
    include: { setItem: { include: { item: true } } },
  })
  const construct = CONSTRUCTS_BY_CODE[response.setItem.item.constructCode]
  if (!construct) throw new Error('Unknown construct on this item')

  // Rule 6, enforced rather than recorded: if the agent graded against a
  // different rubric than the one this server now holds, the score is not
  // comparable to anything and must not enter the record as though it were.
  const expectedVersion = rubricVersionFor(construct.code)
  const expectedHash = rubricHash(construct)
  if (input.rubricVersion !== expectedVersion || input.rubricHash !== expectedHash) {
    throw new Error(
      `Rubric mismatch: agent graded against ${input.rubricVersion}/${input.rubricHash}, server holds ${expectedVersion}/${expectedHash}. Refusing to record — re-run the pass.`,
    )
  }
  if (input.promptVersion !== GRADER_PROMPT_VERSION) {
    throw new Error(
      `Prompt version mismatch: agent used ${input.promptVersion}, server holds ${GRADER_PROMPT_VERSION}.`,
    )
  }

  const dimensions = await db.constructDimension.findMany({
    where: { constructCode: construct.code },
  })

  const rows: { dimensionId: string; value: number; justification: string | null }[] = []
  for (const s of input.scores) {
    const dim = dimensions.find((d) => d.code === s.dimensionCode)
    // A score for a dimension this construct does not have is a bug or a
    // hallucination; either way it has no meaning. Drop it.
    if (!dim) continue
    if (!Number.isFinite(s.value)) continue
    rows.push({
      dimensionId: dim.id,
      value: Math.max(0, Math.min(dim.maxValue, Math.round(s.value))),
      justification: s.justification ?? null,
    })
  }
  if (!rows.length) throw new Error('No valid dimension scores in the payload')

  await db.grading.upsert({
    where: {
      responseId_gradedBy_pass: { responseId: input.responseId, gradedBy: 'AI', pass: input.pass },
    },
    create: {
      responseId: input.responseId,
      gradedBy: 'AI',
      pass: input.pass,
      rubricVersion: input.rubricVersion,
      rubricHash: input.rubricHash,
      promptVersion: input.promptVersion,
      modelId: input.modelId,
      justification: input.overallNote,
      raw: (input.raw ?? null) as object,
      scores: { create: rows },
    },
    update: {
      rubricVersion: input.rubricVersion,
      rubricHash: input.rubricHash,
      promptVersion: input.promptVersion,
      modelId: input.modelId,
      justification: input.overallNote,
      raw: (input.raw ?? null) as object,
      scores: { deleteMany: {}, create: rows },
    },
  })

  await evaluateDisagreement(input.responseId)
  await recomputeStudentState(response.studentId)
}

/**
 * Rule 5. Once both passes are in, compare them. More than one point apart on
 * any dimension flags the response for Eric and holds it out of the estimates
 * until he resolves it.
 *
 * A high flag RATE means the rubric is underspecified — not that the child is
 * inconsistent. That distinction is the reason this is measured at all.
 */
async function evaluateDisagreement(responseId: string): Promise<void> {
  const gradings = await db.grading.findMany({
    where: { responseId, gradedBy: 'AI' },
    include: { scores: { include: { dimension: true } } },
    orderBy: { pass: 'asc' },
  })
  if (gradings.length < 2) return

  const [first, second] = gradings
  const disagreements: string[] = []
  for (const a of first.scores) {
    const b = second.scores.find((s) => s.dimensionId === a.dimensionId)
    if (!b) continue
    if (Math.abs(a.value - b.value) > 1) {
      disagreements.push(`${a.dimension.label}: ${a.value} vs ${b.value}`)
    }
  }

  await db.response.update({
    where: { id: responseId },
    data: {
      flagged: disagreements.length > 0,
      flagReason: disagreements.length
        ? `The two graders disagreed by more than one point — ${disagreements.join('; ')}.`
        : null,
    },
  })
}

/**
 * Multiple choice never reaches the agent. It has an answer key, and spending a
 * model call on it would add noise, not information.
 *
 * It records comprehension ONLY. An MC item says nothing about written
 * expression, and leaving that dimension unobserved rather than guessing is the
 * entire point of the MC/CR pairing in Target 2.
 */
export async function gradeMultipleChoice(responseId: string): Promise<void> {
  const response = await db.response.findUniqueOrThrow({
    where: { id: responseId },
    include: { setItem: { include: { item: true } } },
  })
  const item = response.setItem.item
  if (item.format !== 'MULTIPLE_CHOICE') return

  const correct = item.answerKey !== null && response.choiceKey === item.answerKey
  const dimensions = await db.constructDimension.findMany({
    where: { constructCode: item.constructCode },
  })
  const comprehension = dimensions.find(
    (d) => d.code === 'comprehension_correct' || d.code === 'answer_correct',
  )
  if (!comprehension) return

  await db.grading.upsert({
    where: { responseId_gradedBy_pass: { responseId, gradedBy: 'AI', pass: 1 } },
    create: {
      responseId,
      gradedBy: 'AI',
      pass: 1,
      rubricVersion: 'answer-key',
      rubricHash: 'answer-key',
      promptVersion: 'answer-key',
      modelId: 'none (answer key)',
      justification: `Selected ${response.choiceKey ?? '(nothing)'}; key is ${item.answerKey}.`,
      scores: {
        create: [
          {
            dimensionId: comprehension.id,
            value: correct ? comprehension.maxValue : 0,
            justification: correct
              ? 'Matched the answer key.'
              : 'Did not match the answer key.',
          },
        ],
      },
    },
    update: {},
  })

  await recomputeStudentState(response.studentId)
}
