/**
 * Rubrics — the measuring instrument.
 *
 * notes/07-ai-grading.md: "The rubric is now the measuring instrument, not the
 * answer key. Test quality is capped by rubric quality." Everything here follows
 * from that, so this file is deliberately verbose and deliberately versioned.
 *
 * CHANGING ANY STRING IN THIS FILE CHANGES THE INSTRUMENT. Bump the version when
 * you do. The hash below is stored on every grading so that an edit made WITHOUT
 * bumping the version is still detectable after the fact — that is the failure
 * Rule 6 exists to catch, and it is the one most likely to happen quietly.
 */

import { createHash } from 'node:crypto'
import type { ConstructDef, DimensionDef } from './constructs'
import { ANCHORS, anchorsFor, hasAnchors } from './anchors'

/**
 * Bump on ANY change to the rubric text below.
 *
 * The `-uncalibrated` suffix is load-bearing, not decorative. Rule 3 requires
 * real grade-level exemplars in the prompt, and Rule 8 requires Eric to
 * hand-grade a pilot before the grader is trusted. Until src/lib/anchors.ts has
 * anchors for a construct, that construct is graded WITHOUT calibration and
 * every score it produces carries this suffix in its permanent audit trail.
 * See gradeSuffix() below — the suffix is applied per construct, not globally,
 * so anchors can be added one construct at a time.
 */
export const RUBRIC_BASE_VERSION = '0.1.0'

/** Bump when the grading prompt's structure changes, independent of rubric text. */
// 0.2.0 (2026-08-28): computation-control items are no longer scored on
// reasoning. Bumped rather than edited silently — Rule 6: a comparison across
// rounds is void if the instructions changed underneath it unrecorded.
export const GRADER_PROMPT_VERSION = '0.2.0'
/**
 * Bump when the item-generation prompt changes.
 * 0.2.0 (2026-08-28): criteria may only require what the prompt actually asked
 * for — added after a boy scored zero for omitting units he was never asked to
 * give.
 */
export const GENERATOR_PROMPT_VERSION = '0.2.0'
/** Bump when the student-feedback prompt changes. */
export const FEEDBACK_PROMPT_VERSION = '0.1.0'

/**
 * The invariant half of the grading instructions — identical for every response
 * ever graded. This is the cacheable prefix, and it is why it contains no item
 * text, no student text, and nothing that varies per request.
 */
const GRADING_PRINCIPLES = `You are scoring one response from one student against one rubric.

WHAT YOU ARE NOT TOLD, AND MUST NOT GUESS AT
You do not know who this student is, their name, their age, which sibling they
are, how they have scored before, or whether this is a first attempt or a
re-test. That is deliberate. If you find yourself reasoning about who wrote this
or how it compares to anything else, stop — you do not have that information and
inventing it corrupts the measurement.

SCORE EACH DIMENSION INDEPENDENTLY
You will be given two or three named dimensions. Score each one on its own
evidence. A response can have a wrong final answer and excellent reasoning; it
can have the right answer and no justification at all. Those must come out as
different numbers on different dimensions. Never let one dimension pull another
toward it, and never emit an overall or holistic score — you are not asked for
one and it would destroy the only signal this instrument produces.

DO NOT PENALISE SURFACE FEATURES
Spelling, punctuation, capitalisation, handwriting and phrasing are scored ONLY
where a dimension explicitly says so. On every other dimension they are
invisible to you. A student who explains a method correctly with three spelling
errors and no capital letters earns FULL credit on reasoning. This is the single
most common way an automated grader produces a fake deficit.

JUDGE AGAINST THE CRITERIA, NOT AGAINST A MODEL ANSWER
You are given the criteria for the item, not an ideal response, because students
express correct reasoning in unexpected ways. A method you did not anticipate,
described in a child's words, is still correct if it works. Do not reward
resemblance to the phrasing you would have used.

CALIBRATE TO THE GRADE, NOT TO AN ADULT STANDARD
An untuned standard for "a clear explanation" is adult-professional, and applied
to a child's writing it under-scores systematically. The result looks exactly
like a real deficit and is not one. When anchor responses are supplied below,
they define the scale — match the response to the closest anchor rather than to
your own sense of good writing. Where they are not supplied, err toward the
generous reading of what the student appears to mean.

EXPLAIN EVERY SCORE
For each dimension, quote or point to the specific part of the response that
drove the number. "Reasoning is unclear" is not usable; "states the total but
never says why 4 boxes were needed" is.`

/** Per-dimension block: label, question, scale, and what each point means. */
function renderDimension(d: DimensionDef): string {
  const scale =
    d.scale === 'BINARY'
      ? '0 or 1 (0 = no, 1 = yes)'
      : `0 to ${d.maxValue} (0 = absent, ${d.maxValue} = fully meets the criterion for this grade)`
  return `- ${d.code} — ${d.label}
  Question: ${d.question}
  Scale: ${scale}`
}

/**
 * The stable, cacheable system prompt for one construct. Byte-identical across
 * every response graded for that construct, which is the whole point: the
 * rubric and anchors sit in the cached prefix and only the response varies.
 *
 * Contains NOTHING that varies per request. No timestamps, no student, no item.
 */
export function rubricSystemPrompt(construct: ConstructDef): string {
  const dims = construct.dimensions.map(renderDimension).join('\n\n')
  const anchors = anchorsFor(construct.code)

  const anchorBlock = anchors.length
    ? `ANCHOR RESPONSES — THESE DEFINE THE SCALE
Real responses at this grade level, with the score each earned and why. Match
the response you are grading to the closest anchor.

${anchors
  .map(
    (a) =>
      `--- Anchor: ${a.dimensionCode} = ${a.value} (grade ${a.grade}) ---
Item asked: ${a.itemSummary}
Response: ${a.response}
Why that score: ${a.justification}`,
  )
  .join('\n\n')}`
    : `ANCHOR RESPONSES: NONE SUPPLIED YET.
No calibration anchors exist for this construct. You are therefore at maximum
risk of applying an adult standard to a child's writing. Compensate: read
generously, score what the student demonstrably understood rather than what they
failed to articulate, and say so in your justification when a response is hard
to score without an anchor. Every score you produce here is recorded as
uncalibrated.`

  return `${GRADING_PRINCIPLES}

CONSTRUCT: ${construct.label}
${construct.description}

DIMENSIONS TO SCORE

${dims}

${anchorBlock}`
}

/**
 * Version string recorded on every grading for this construct. Carries the
 * uncalibrated marker per construct, so adding anchors for math practices does
 * not silently re-label ELA gradings as calibrated.
 */
export function rubricVersionFor(constructCode: string): string {
  return hasAnchors(constructCode)
    ? RUBRIC_BASE_VERSION
    : `${RUBRIC_BASE_VERSION}-uncalibrated`
}

/**
 * Hash of the exact bytes sent. Catches an edit to the rubric text that was not
 * accompanied by a version bump — the quiet drift Rule 6 is aimed at.
 */
export function rubricHash(construct: ConstructDef): string {
  return createHash('sha256').update(rubricSystemPrompt(construct)).digest('hex').slice(0, 16)
}

/** True when nothing in the registry has calibration anchors yet. */
export function graderIsWhollyUncalibrated(): boolean {
  return ANCHORS.length === 0
}
