/* ==================================================================
   Paper Evaluation pipeline — dedicated 3-step flow
   ================================================================== */

const EVIcon = window.AppUI.Icon;

/* ---------- The system prompt that drives Gemini's parser ---------- */
const PARSER_SYSTEM_PROMPT = `You are a question paper parser for a bilingual K-12 education system in India. Your job is to convert raw question paper text into structured JSON.

# Task

Parse the question paper provided in the user message into the exact JSON schema specified below. Return ONLY the JSON object — no prose, no markdown fences, no commentary.

# Output schema

{
  "test_id": null,
  "sections": [
    {
      "section_id": "A",
      "title": "string",
      "marks_per_question": 1,
      "total_questions": 6,
      "section_total_marks": 6,
      "questions": [
        {
          "q_no": 1,
          "text": "string",
          "type": "mcq | short | long | numerical | derivation",
          "options": ["string", "string", "string", "string"],
          "prerequisite_tags": ["kebab-case-string", "kebab-case-string"]
        }
      ]
    }
  ],
  "parsing_flags": [
    {
      "severity": "error | warning",
      "section_id": "string | null",
      "q_no": "number | string | null",
      "message": "string"
    }
  ]
}

# Field rules

- test_id: always null. The application assigns it.
- section_id: single uppercase letter, sequential (A, B, C, ...).
- title: short descriptive name, in English, derived from the section heading (e.g., "MCQ", "Short Answer Type Questions", "Broad Answer Type Questions").
- marks_per_question: integer. Every section has uniform marks across its questions. Infer from the section header, or by dividing section total marks by question count. If inference is impossible or inconsistent, set to null and add a parsing flag.
- total_questions: integer. Count of questions in the section.
- section_total_marks: integer. Must equal marks_per_question × total_questions. If the source paper states a section total that conflicts with this product, set marks_per_question to null and add a parsing flag with severity "error".
- q_no: usually an integer. If the source paper has duplicate numbers, keep the first as the integer and suffix subsequent ones as strings ("12b", "12c", etc.) — also add a warning flag.
- text: the question text verbatim, in the original script (Bengali, Hindi, or English). Preserve all chemical formulas, equations, and inline LaTeX. Do not translate. Do not summarize. If a question contains multiple sub-parts glued together with "or" / "অথবা" / "या", keep them as one text block but add a warning flag suggesting the teacher split them.
- type: one of "mcq", "short", "long", "numerical", "derivation". Infer from question structure:
  - mcq: has lettered options (a)(b)(c)(d) or (i)(ii)(iii)(iv)
  - numerical: asks for a calculated numeric value with units
  - derivation: asks to "derive", "prove", "show that"
  - short: 1–3 mark questions without options, typically one-line answers
  - long: 3+ mark questions requiring multi-step explanations
- options: include this field ONLY when type is "mcq". Omit entirely otherwise (do not include as null or empty array). Strip the (a)(b)(c)(d) prefixes — give only the option content. Preserve original language.
- prerequisite_tags: 2–4 short kebab-case concept strings in English (e.g., "redox-balancing", "ethylene-glycol", "iupac-nomenclature"). These are best-effort topic tags Gemini infers — the teacher refines them later to canonical concept IDs.

# Parsing flags

Add to the parsing_flags array whenever you encounter:

1. Duplicate q_no in the source. Severity: "warning".
2. Section marks math doesn't reconcile (total ≠ per_question × count). Severity: "error". Set marks_per_question to null in that section.
3. A question text appears truncated (e.g., ends with a colon, refers to "the following diagram" with nothing after it). Severity: "warning".
4. A question contains alternative sub-questions joined by "or" / "অথবা". Severity: "warning". Suggest splitting.
5. Section header is ambiguous about marks distribution. Severity: "warning".
6. Question count appears off (e.g., section says "answer any 10 of the following" but lists 15). Severity: "warning".
7. The text appears to reference a diagram, image, or table not present in the input. Severity: "warning".

Each flag must reference the specific section_id and q_no where applicable. If a flag is paper-wide, set both to null.

If there are no issues, return an empty array: "parsing_flags": [].

# Language handling

- Preserve the original script of every question. Bengali stays in Bengali, Hindi in Hindi, English in English. No transliteration, no translation.
- prerequisite_tags are always in English, kebab-case.
- section title is always in English.

# What you must NOT do

- Do not invent questions or options that aren't in the source.
- Do not "correct" what looks like a typo in the source — preserve it and flag if relevant.
- Do not fill in marks values you cannot justify from the source.
- Do not include markdown code fences around the JSON.
- Do not include any text before or after the JSON object.
- Do not return null for required scalar fields except marks_per_question (which may be null when math fails).

# Validation before returning

Before returning the JSON, internally verify:
- Each section's total_questions equals the actual length of its questions array.
- For each section where marks_per_question is not null: section_total_marks equals marks_per_question × total_questions.
- Every mcq question has an options array; no non-mcq question has one.
- All q_no values within a section are unique (after suffixing duplicates).

If any check fails, fix the data and the flags before returning.

# Now parse the following question paper:`;

/* ---------- Seed data ----------
   `PASTE_SEED` and `STRUCTURED_JSON` are no longer hard-coded — they come from
   the backend (POST /papers/parse returns a structured response that conforms
   to the schema described in PARSER_SYSTEM_PROMPT above). The values below are
   empty placeholders so the screens render before any paper is parsed.
*/
const PASTE_SEED = "";
const STRUCTURED_JSON = { test_id: null, sections: [], parsing_flags: [] };

/* helper: flatten new-schema sections into the cockpit's internal question shape */
// Section-letter → 1-based section index. "A" → 1, "B" → 2, "C" → 3, etc.
// Used in the right-panel evaluation cards to render Q numbers as "1.4" for
// Section A · Q4 — disambiguates from Q4 in Section B / C when the right
// panel shows questions sequentially without section headers.
function sectionIndex(letter) {
  if (!letter) return 0;
  const c = String(letter).trim().toUpperCase().charCodeAt(0);
  if (c >= 65 && c <= 90) return c - 64;     // A..Z → 1..26
  return 0;
}
function qLabel(q) {
  const idx = sectionIndex(q?.sectionId);
  return idx > 0 ? `${idx}.${q.num}` : `${q.num}`;
}

// MCQ verdict signal — used by the right-pane card header. Returns
// { mark: "match" | "miss" | "partial", color, icon } based on score ratio.
function verdictFor(score, max) {
  const m = Number(max) || 0;
  const s = Number(score) || 0;
  if (m === 0) return { mark: "miss", color: "var(--ink-3)", icon: "·", label: "—" };
  if (s >= m)  return { mark: "match",   color: "var(--green)", icon: "✓", label: "Correct" };
  if (s <= 0)  return { mark: "miss",    color: "var(--red)",   icon: "✗", label: "Incorrect" };
  return        { mark: "partial",        color: "var(--amber)", icon: "~", label: "Partial" };
}

function flattenForCockpit(json) {
  const flat = [];
  json.sections.forEach(sec => {
    sec.questions.forEach(q => {
      const marks = q.marks ?? sec.marks_per_question ??
        (sec.section_total_marks && sec.total_questions ? Math.round(sec.section_total_marks / sec.total_questions) : 1);
      flat.push({
        // Local id (sidebar key, selection set) — namespaced so Section A's Q1
        // doesn't collide with Section B's Q1.
        id: `${sec.section_id}-Q${q.q_no}`,
        // Real DB UUID (used when POSTing to /api/evaluations); undefined for
        // paste-flow questions that haven't been persisted yet.
        uuid: q.uuid || null,
        num: q.q_no,
        type: q.type === "mcq" ? "mcq" : q.type === "long" ? "long_answer" : q.type === "short" ? "short_answer" : q.type,
        marks,
        stem: q.text,
        options: q.options ? q.options.map((t, i) => ({ k: "abcd"[i], text: t })) : undefined,
        concepts: q.prerequisite_tags || [],
        sub_questions: q.sub_questions || [],
        sectionId: sec.section_id,
        sectionLabel: `Section ${sec.section_id} — ${sec.title}`,
      });
    });
  });
  return flat;
}

/* ==================================================================
   STEP 1 — Paste questions  (with prompt viewer)
   ================================================================== */
const StepPaste = ({ text, setText, onNext, onCancel, onSkipToCockpit }) => {
  const blocks = text.split(/\n\s*\n/).filter(Boolean).length;
  const lines = text.split("\n").length;
  const [showPrompt, setShowPrompt] = useState(false);
  return (
    <div style={{ display:"flex", flexDirection:"column", height:"100%", background:"var(--bg-0)" }}>
      <div style={{ padding:"12px 28px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:14, background:"var(--bg-0)" }}>
        <PipelineRail step={1}/>
        <span style={{ marginLeft:"auto", display:"flex", gap:8 }}>
          <button className="btn" onClick={()=>setShowPrompt(true)}>
            <EVIcon name="sparkle" size={15}/>View parser prompt
          </button>
          <button className="btn" onClick={onCancel}>Cancel</button>
        </span>
      </div>

      <div style={{ flex:1, overflow:"auto", padding:"28px 32px", maxWidth: 1000, margin:"0 auto", width:"100%" }}>
        <div style={{ marginBottom: 22 }}>
          <div className="cx-eyebrow">Step 1 of 3</div>
          <h2 className="cx-h1">Paste the question paper</h2>
          <p style={{ color:"var(--ink-3)", margin:"6px 0 0", fontSize:13, lineHeight:1.5 }}>
            Drop in the raw paper text — typed, copy-pasted, or OCR'd elsewhere. Gemini will structure it into the schema. Bengali, Hindi, and English are auto-detected.
          </p>
        </div>

        <div className="cx-card">
        <div className="cx-card-head" style={{ alignItems:"center" }}>
          <span className="cx-card-title" style={{ marginRight:"auto" }}>Question paper text</span>
          <span style={{ display:"flex", gap:8, alignItems:"center" }}>
          <select className="select" style={{ width:"auto", minWidth:170 }} defaultValue="auto" aria-label="Language">
            <option value="auto">Auto-detect language</option>
            <option value="bn">Bengali</option>
            <option value="hi">Hindi</option>
            <option value="en">English</option>
          </select>
          <button className="btn sm ghost" onClick={()=>setText("")}>Clear</button>
          <button className="btn sm ghost" onClick={()=>setText(PASTE_SEED)}>Load sample</button>
          </span>
        </div>

        <textarea
          className="textarea"
          value={text}
          onChange={e=>setText(e.target.value)}
          spellCheck={false}
          style={{
            width:"100%", minHeight: 360, resize:"vertical",
            fontFamily:"var(--font-mono)",
            fontSize: 13.5, lineHeight: 1.6,
            padding: "14px 16px",
          }}
          placeholder="Paste the full question paper here…"
        />

        <div style={{ display:"flex", alignItems:"center", marginTop:12, gap:14, color:"var(--ink-3)", fontSize:12.5, fontVariantNumeric:"tabular-nums" }}>
          <span>{text.length.toLocaleString()} chars</span>
          <span>·</span>
          <span>{lines} lines</span>
          <span>·</span>
          <span>{blocks} blocks detected</span>
          <span style={{ marginLeft:"auto", display:"flex", alignItems:"center", gap:6 }}>
            <EVIcon name="sparkle" size={13}/>
            <span>Gemini parser · ~3s typical</span>
          </span>
        </div>
        </div>

        <div className="cx-card" style={{ marginTop: 20 }}>
          <div className="cx-card-head" style={{ alignItems:"center" }}>
            <div style={{ display:"flex", alignItems:"center", gap: 8 }}>
              <span className="cx-card-title">What Gemini extracts</span>
              <window.AppUI.StatusBadge tone="grey" icon={false}>schema v1</window.AppUI.StatusBadge>
            </div>
            <button className="btn sm" onClick={()=>setShowPrompt(true)}>See full prompt<EVIcon name="arrowRight" size={13}/></button>
          </div>
          <dl className="ui-details" style={{ "--cols": 4 }}>
            {[
              ["Sections", "A, B, C with totals"],
              ["Questions", "q_no, text, type"],
              ["MCQ options", "Stripped (a)/(b)/(c)/(d)"],
              ["Prerequisite tags", "kebab-case concepts"],
            ].map(([k,v]) => (
              <div key={k} className="ui-detail">
                <div className="ui-detail-label">{k}</div>
                <div className="ui-detail-value">{v}</div>
              </div>
            ))}
          </dl>
        </div>
      </div>

      <div style={{ padding:"12px 32px", borderTop:"1px solid var(--line)", display:"flex", alignItems:"center", gap:10, background:"var(--bg-0)" }}>
        <span className="muted" style={{ fontSize:12.5 }}>Step 1 of 3 · Paste text</span>
        <span style={{ marginLeft:"auto", display:"flex", gap: 8 }}>
          {onSkipToCockpit && (
            <button className="btn" onClick={onSkipToCockpit} title="Open the cockpit with the bundled PDF — bboxes will be unassigned until you tick questions">
              Skip · Open cockpit with PDF<EVIcon name="arrowRight" size={15}/>
            </button>
          )}
          <button className="btn primary" disabled={text.trim().length < 20} onClick={onNext}>
            <EVIcon name="sparkle" size={15}/>Structure with Gemini<EVIcon name="arrowRight" size={15}/>
          </button>
        </span>
      </div>

      {showPrompt && <PromptModal onClose={()=>setShowPrompt(false)}/>}
    </div>
  );
};

/* ---------- System prompt modal ---------- */
const PromptModal = ({ onClose }) => {
  const [copied, setCopied] = useState(false);
  const copy = () => {
    navigator.clipboard?.writeText(PARSER_SYSTEM_PROMPT);
    setCopied(true);
    setTimeout(()=>setCopied(false), 1500);
  };
  return (
    <window.AppUI.Modal open onClose={onClose} size="xl" zIndex={200} icon="sparkle"
      title="Paper structuring prompt"
      sub="System prompt sent to Gemini before the pasted paper text"
      footer={<>
        <span style={{ marginRight:"auto", fontSize: 12.5, color:"var(--ink-3)" }}>
          Editing the prompt is a workspace-admin action — request via Settings → Parsing.
        </span>
        <button className="btn" onClick={copy}><EVIcon name={copied ? "check" : "copy"} size={15}/>{copied ? "Copied" : "Copy"}</button>
        <button className="btn" onClick={onClose}>Close</button>
      </>}>
        <div style={{ display:"flex", gap:6, marginBottom:14 }}>
          <window.AppUI.StatusBadge tone="grey" icon={false}><span className="mono">gemini-2.5-flash</span></window.AppUI.StatusBadge>
          <window.AppUI.StatusBadge tone="grey" icon={false}><span className="mono">{PARSER_SYSTEM_PROMPT.length.toLocaleString()} chars</span></window.AppUI.StatusBadge>
        </div>
        <pre className="cx-tile" style={{
          margin: 0, fontFamily:"var(--font-mono)",
          fontSize: 12, lineHeight: 1.6, color:"var(--ink-1)",
          whiteSpace:"pre-wrap", wordBreak:"break-word", maxHeight:"60vh", overflow:"auto",
        }}>{PARSER_SYSTEM_PROMPT}</pre>
    </window.AppUI.Modal>
  );
};

/* ==================================================================
   STEP 2 — Structuring (loader) → editable JSON preview
   ================================================================== */
const StepStructure = ({ rawText, onNext, onBack, structured, setStructured }) => {
  const [loading, setLoading] = useState(!structured);
  const [view, setView] = useState("cards"); // cards | json
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    if (!loading) return;
    let i = 0;
    const interval = setInterval(() => {
      i++; setProgress(Math.min(i / 6, 1));
      if (i >= 6) {
        clearInterval(interval);
        setStructured(STRUCTURED_JSON);
        setTimeout(() => setLoading(false), 200);
      }
    }, 380);
    return () => clearInterval(interval);
  }, []);

  const data = structured || STRUCTURED_JSON;

  return (
    <div style={{ display:"flex", flexDirection:"column", height:"100%", background:"var(--bg-0)" }}>
      <div style={{ padding:"12px 28px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:14, background:"var(--bg-0)" }}>
        <PipelineRail step={2}/>
        <span style={{ marginLeft:"auto", display:"flex", gap:8 }}>
          <button className="btn" onClick={onBack}><EVIcon name="arrowLeft" size={15}/>Edit text</button>
        </span>
      </div>

      {loading ? (
        <div style={{ flex:1, display:"grid", placeItems:"center", padding: 40 }}>
          <div style={{ textAlign:"center", maxWidth: 480 }}>
            <div style={{ display:"inline-flex", alignItems:"center", gap:10, padding:"6px 14px", borderRadius:999, background:"var(--bg-2)", border:"1px solid var(--line-strong)", color:"var(--ink-1)", marginBottom: 24 }}>
              <span style={{ width:14, height:14, border:"2px solid var(--line-strong)", borderTopColor:"var(--accent)", borderRadius:"50%", animation:"spin 1s linear infinite", display:"inline-block" }}/>
              <span className="mono" style={{ fontSize: 12 }}>gemini-2.5-flash</span>
            </div>
            <h2 className="cx-h1" style={{ margin:"0 0 8px" }}>Structuring your paper</h2>
            <p style={{ color:"var(--ink-3)", margin:"0 0 24px", fontSize: 13 }}>Gemini is reading the text and converting it into the schema.</p>
            <div className="progress" style={{ height: 6, borderRadius: 999 }}>
              <div style={{ width: `${progress * 100}%`, transition:"width .35s ease" }}/>
            </div>
            <div className="cx-card" style={{ marginTop: 18, textAlign:"left", padding:"6px 20px" }}>
              <div className="ui-list">
              {["Detecting language", "Identifying sections", "Parsing questions", "Extracting MCQ options", "Tagging prerequisites", "Validating schema"].map((s, i) => (
                <div key={i} className="ui-row" style={{ minHeight: 40, fontSize: 13 }}>
                  <span style={{ display:"inline-flex", color: progress * 6 > i ? "var(--green)" : "var(--ink-4)" }}>
                    <EVIcon name={progress * 6 > i ? "checkCircle" : "dashedCircle"} size={15}/>
                  </span>
                  <span style={{ color: progress * 6 > i ? "var(--ink-1)" : "var(--ink-3)" }}>{s}</span>
                </div>
              ))}
              </div>
            </div>
          </div>
          <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
        </div>
      ) : (
        <StructuredReview data={data} setData={setStructured} view={view} setView={setView}
          onNext={onNext}
          onReparse={()=>{ setStructured(null); setLoading(true); setProgress(0); }}/>
      )}
    </div>
  );
};

/* ---------- Editable review of parsed JSON ---------- */
const StructuredReview = ({ data, setData, view, setView, onNext, onReparse }) => {
  const totalQs = data.sections.reduce((a, s) => a + s.questions.length, 0);
  const totalMarks = data.sections.reduce((a, s) => a + (s.section_total_marks || 0), 0);
  const errors = (data.parsing_flags || []).filter(f => f.severity === "error");
  const warnings = (data.parsing_flags || []).filter(f => f.severity === "warning");

  // JSON edit state
  const [jsonText, setJsonText] = useState(() => JSON.stringify(data, null, 2));
  const [jsonError, setJsonError] = useState(null);
  const [dirty, setDirty] = useState(false);

  useEffect(() => {
    if (!dirty) setJsonText(JSON.stringify(data, null, 2));
  }, [data]);

  const onJsonChange = (val) => {
    setJsonText(val);
    setDirty(true);
    try {
      JSON.parse(val);
      setJsonError(null);
    } catch (e) {
      setJsonError(e.message);
    }
  };

  const applyJson = () => {
    try {
      const parsed = JSON.parse(jsonText);
      if (!Array.isArray(parsed.sections)) throw new Error("Missing 'sections' array");
      setData(parsed);
      setDirty(false);
      setJsonError(null);
    } catch (e) {
      setJsonError(e.message);
    }
  };

  const formatJson = () => {
    try {
      const parsed = JSON.parse(jsonText);
      setJsonText(JSON.stringify(parsed, null, 2));
      setJsonError(null);
    } catch (e) {
      setJsonError(e.message);
    }
  };

  return (
    <>
      <div style={{ flex:1, overflow:"auto", padding:"28px 32px", maxWidth: 1100, margin:"0 auto", width:"100%" }}>
        <div style={{ marginBottom: 20 }}>
          <div style={{ marginBottom: 14 }}>
            <div className="cx-eyebrow">Step 2 of 3</div>
            <h2 className="cx-h1">Review structured paper</h2>
            <p style={{ color:"var(--ink-3)", margin:"6px 0 0", fontSize:13, lineHeight:1.5 }}>
              Confirm Gemini parsed the questions correctly. The JSON view is fully editable — your edits become the source of truth for evaluation.
            </p>
          </div>
          <span style={{ display:"flex", gap:8, alignItems:"center", flexWrap:"wrap" }}>
            <window.AppUI.StatusBadge tone="grey" icon={false}>schema v1</window.AppUI.StatusBadge>
            <window.AppUI.StatusBadge tone="grey" icon={false}><span className="mono">gemini-2.5-flash</span></window.AppUI.StatusBadge>
            <window.AppUI.StatusBadge tone="blue" icon="list">{totalQs} questions · {totalMarks} marks</window.AppUI.StatusBadge>
            {errors.length > 0 && <window.AppUI.StatusBadge tone="red" icon="xCircle">{errors.length} error{errors.length>1?"s":""}</window.AppUI.StatusBadge>}
            {warnings.length > 0 && <window.AppUI.StatusBadge tone="amber" icon="alertCircle">{warnings.length} warning{warnings.length>1?"s":""}</window.AppUI.StatusBadge>}
            <span style={{ marginLeft:"auto" }}>
              <window.AppUI.Tabs value={view} onChange={setView} size="sm" ariaLabel="Review view"
                items={[{ value:"cards", label:"Cards" }, { value:"json", label: `JSON${dirty ? " •" : ""}` }]}/>
            </span>
          </span>
        </div>

        {/* Parsing flags */}
        {(errors.length + warnings.length > 0) && (
          <div style={{ marginBottom: 14, display:"flex", flexDirection:"column", gap: 6 }}>
            {[...errors, ...warnings].map((f, i) => {
              const isErr = f.severity === "error";
              return (
                <div key={i} className={`ui-alert ${isErr ? "red" : "amber"}`} style={{ display:"flex", alignItems:"center", gap: 8 }}>
                  <EVIcon name={isErr ? "xCircle" : "alertCircle"} size={14}/>
                  <span style={{ fontWeight: 600 }}>{isErr ? "Error" : "Warning"}</span>
                  {f.section_id && <span className="mono" style={{ color:"var(--ink-3)" }}>§{f.section_id}{f.q_no != null ? `·Q${f.q_no}` : ""}</span>}
                  <span style={{ color:"var(--ink-1)" }}>{f.message}</span>
                </div>
              );
            })}
          </div>
        )}

        {view === "cards" ? (
          <CardsView data={data}/>
        ) : (
          <JsonView jsonText={jsonText} onChange={onJsonChange}
            jsonError={jsonError} dirty={dirty}
            onApply={applyJson} onFormat={formatJson}
            onReset={() => { setJsonText(JSON.stringify(data, null, 2)); setDirty(false); setJsonError(null); }}/>
        )}
      </div>

      <div style={{ padding:"12px 32px", borderTop:"1px solid var(--line)", display:"flex", alignItems:"center", gap:10, background:"var(--bg-0)" }}>
        <span className="muted" style={{ fontSize:12.5 }}>Step 2 of 3 · Structured output</span>
        {dirty && view === "json" && (
          <window.AppUI.StatusBadge tone="amber" icon="penLine">Unsaved JSON edits</window.AppUI.StatusBadge>
        )}
        <span style={{ marginLeft:"auto", display:"flex", gap: 8 }}>
          <button className="btn" onClick={onReparse}>
            <EVIcon name="refresh" size={15}/>Re-parse
          </button>
          <button className="btn primary" onClick={onNext} disabled={errors.length > 0 || (dirty && view === "json")}
            title={errors.length > 0 ? "Resolve parsing errors first" : dirty ? "Apply JSON edits first" : ""}>
            Open Evaluation cockpit<EVIcon name="arrowRight" size={15}/>
          </button>
        </span>
      </div>
    </>
  );
};

/* ---------- Cards view (read-only summary) ---------- */
const CardsView = ({ data }) => (
  <div style={{ display:"flex", flexDirection:"column", gap: 14 }}>
    {data.sections.map(sec => (
      <div key={sec.section_id} className="cx-card flush">
        <div className="cx-card-head" style={{ alignItems:"center", paddingBottom: 14 }}>
          <div style={{ display:"flex", alignItems:"center", gap: 10, minWidth: 0 }}>
            <window.AppUI.Avatar name={String(sec.section_id)} size="sm"/>
            <span className="cx-card-title">{sec.title}</span>
          </div>
          <div style={{ display:"flex", gap: 6 }}>
            <window.AppUI.StatusBadge tone="grey" icon={false}>
              {sec.marks_per_question != null ? `${sec.marks_per_question} mark${sec.marks_per_question>1?"s":""} × ${sec.total_questions}` : "mixed marks"}
            </window.AppUI.StatusBadge>
            <window.AppUI.StatusBadge tone="blue" icon={false}>{sec.section_total_marks} total</window.AppUI.StatusBadge>
          </div>
        </div>
        <div style={{ padding: 0 }}>
          {sec.questions.map(q => (
            <div key={q.q_no} style={{ padding:"14px 20px", borderTop:"1px solid var(--line)", display:"flex", gap: 14 }}>
              <div style={{ width: 36, textAlign:"right", color:"var(--ink-0)", fontWeight: 600, fontSize: 13.5 }}>
                Q{q.q_no}
              </div>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ color:"var(--ink-0)", fontSize: 13.5, lineHeight: 1.5, marginBottom: 4, fontFamily:"var(--font-sans)" }}>
                  {q.text}
                </div>
                {q.options && (
                  <div style={{ display:"grid", gridTemplateColumns:"repeat(2, 1fr)", gap: 6, marginTop: 8, marginBottom: 6 }}>
                    {q.options.map((t, i) => (
                      <div key={i} style={{
                        display:"flex", alignItems:"center", gap:6,
                        fontSize: 12.5, color:"var(--ink-1)",
                        padding:"3px 6px", borderRadius: 4,
                      }}>
                        <span className="mono" style={{ color:"var(--ink-3)" }}>({"abcd"[i]})</span>
                        <span>{t}</span>
                      </div>
                    ))}
                  </div>
                )}
                <div style={{ display:"flex", gap: 6, marginTop: 6, flexWrap:"wrap" }}>
                  <window.AppUI.StatusBadge tone="blue" icon={false}>{q.type}</window.AppUI.StatusBadge>
                  {(q.prerequisite_tags || []).map(c => (
                    <window.AppUI.StatusBadge key={c} tone="grey" icon={false}><span className="mono">{c}</span></window.AppUI.StatusBadge>
                  ))}
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    ))}
  </div>
);

/* ---------- Editable JSON view ---------- */
const JsonView = ({ jsonText, onChange, jsonError, dirty, onApply, onFormat, onReset }) => {
  return (
    <div style={{ display:"flex", flexDirection:"column", gap: 10 }}>
      <div style={{ display:"flex", alignItems:"center", gap: 8 }}>
        <span className="muted" style={{ fontSize: 12.5 }}>Edit the structured JSON directly. Click <b>Apply</b> to commit your edits.</span>
        <span style={{ marginLeft:"auto", display:"flex", gap: 6 }}>
          <button className="btn sm" onClick={onFormat}>Format</button>
          <button className="btn sm" onClick={onReset} disabled={!dirty}>Reset</button>
          <button className="btn sm primary" onClick={onApply} disabled={!dirty || !!jsonError}>
            <EVIcon name="check" size={13}/>Apply edits
          </button>
        </span>
      </div>
      <textarea
        className="textarea"
        value={jsonText}
        onChange={e => onChange(e.target.value)}
        spellCheck={false}
        style={{
          width:"100%", minHeight: "62vh", resize:"vertical",
          fontFamily:"var(--font-mono)", fontSize: 12.5, lineHeight: 1.55,
          borderColor: jsonError ? "color-mix(in srgb, var(--red-solid) 50%, transparent)" : dirty ? "var(--accent)" : undefined,
          borderRadius: "var(--radius-tile)", padding: 14,
          whiteSpace:"pre", tabSize: 2,
        }}
      />
      {jsonError ? (
        <div className="ui-alert red" style={{ fontFamily:"var(--font-mono)" }}>
          Invalid JSON: {jsonError}
        </div>
      ) : dirty ? (
        <div className="ui-alert amber">
          Unsaved edits — click Apply to commit before opening the cockpit.
        </div>
      ) : (
        <div className="muted" style={{ fontSize: 12.5 }}>
          JSON is in sync with the parsed output.
        </div>
      )}
    </div>
  );
};

/* ==================================================================
   STEP 3 — Evaluation cockpit (3 panes)
   ------------------------------------------------------------------
   - LEFT     : structured questions w/ per-question + per-section
                multi-select checkboxes
   - MIDDLE   : real PDF (pdf.js) with bbox drawing / editing tools
   - RIGHT    : bbox CRUD panel (list, reassign, delete, evaluate)
   ================================================================== */

const DEFAULT_PDF_URL = "uploads/" + "sample-answer-sheet.pdf";

/* Auto-generated palette for box colors derived from question index. */
const BOX_PALETTE = [
  "var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)",
  "var(--chart-6)", "var(--chart-5)", "var(--chart-7)", "var(--chart-8)",
];
const colorForQuestion = (qid, all) => {
  const idx = Math.max(0, all.findIndex(q => q.id === qid));
  return BOX_PALETTE[idx % BOX_PALETTE.length];
};

let _boxIdCounter = 1;
const newBoxId = () => `b${_boxIdCounter++}_${Date.now().toString(36).slice(-4)}`;

const HANDLES = [
  { id:"nw", cur:"nwse-resize", x:0,   y:0   },
  { id:"n",  cur:"ns-resize",   x:0.5, y:0   },
  { id:"ne", cur:"nesw-resize", x:1,   y:0   },
  { id:"e",  cur:"ew-resize",   x:1,   y:0.5 },
  { id:"se", cur:"nwse-resize", x:1,   y:1   },
  { id:"s",  cur:"ns-resize",   x:0.5, y:1   },
  { id:"sw", cur:"nesw-resize", x:0,   y:1   },
  { id:"w",  cur:"ew-resize",   x:0,   y:0.5 },
];

const StepCockpit = ({ structured, activeTest, activeStudent, onBack, onCancel, pdfUrl, sheetId, rehydrate, setScreen }) => {
  const allQuestions = useMemo(() => flattenForCockpit(structured), [structured]);

  /* ---- selection of questions (multi) ---- */
  const [selectedQids, setSelectedQids] = useState(
    () => new Set(allQuestions[0] ? [allQuestions[0].id] : [])
  );
  const toggleQid = (qid, opts = {}) => {
    setSelectedQids(prev => {
      const next = new Set(prev);
      if (opts.exclusive) { next.clear(); next.add(qid); return next; }
      if (next.has(qid)) next.delete(qid); else next.add(qid);
      return next;
    });
  };
  const toggleSection = (secId, makeOn) => {
    setSelectedQids(prev => {
      const next = new Set(prev);
      // Skip questions that already have a bbox — they're locked.
      const lockedIds = new Set(boxes.flatMap(b => b.qids));
      const qids = allQuestions
        .filter(q => q.sectionId === secId && !lockedIds.has(q.id))
        .map(q => q.id);
      if (makeOn) qids.forEach(id => next.add(id));
      else qids.forEach(id => next.delete(id));
      return next;
    });
  };

  /* ---- PDF state ---- */
  const url = pdfUrl || DEFAULT_PDF_URL;
  const [pdfDoc, setPdfDoc] = useState(null);
  const [pageNum, setPageNum] = useState(1);
  const [pageCount, setPageCount] = useState(0);
  const [renderSize, setRenderSize] = useState({ w: 600, h: 800 });
  const [zoom, setZoom] = useState(1.25);
  const [loadErr, setLoadErr] = useState(null);
  const canvasRef = useRef(null);
  const overlayRef = useRef(null);
  const renderTaskRef = useRef(null);

  // Load the PDF once.
  useEffect(() => {
    if (!window.pdfjsLib) {
      setLoadErr("pdf.js failed to load — check internet connection.");
      return;
    }
    let cancelled = false;
    setLoadErr(null);
    window.pdfjsLib.getDocument(url).promise
      .then(doc => {
        if (cancelled) return;
        setPdfDoc(doc);
        setPageCount(doc.numPages);
        setPageNum(1);
      })
      .catch(err => {
        if (!cancelled) setLoadErr(err?.message || String(err));
      });
    return () => { cancelled = true; };
  }, [url]);

  // Render current page whenever page or zoom changes.
  useEffect(() => {
    if (!pdfDoc || !canvasRef.current) return;
    let cancelled = false;
    (async () => {
      try {
        const page = await pdfDoc.getPage(pageNum);
        const viewport = page.getViewport({ scale: zoom });
        const canvas = canvasRef.current;
        if (!canvas || cancelled) return;
        const ctx = canvas.getContext("2d");
        canvas.width = viewport.width;
        canvas.height = viewport.height;
        setRenderSize({ w: viewport.width, h: viewport.height });
        if (renderTaskRef.current) { try { renderTaskRef.current.cancel(); } catch {} }
        const task = page.render({ canvasContext: ctx, viewport });
        renderTaskRef.current = task;
        await task.promise;
      } catch (e) {
        if (!cancelled && e?.name !== "RenderingCancelledException") {
          setLoadErr(e?.message || String(e));
        }
      }
    })();
    return () => { cancelled = true; };
  }, [pdfDoc, pageNum, zoom]);

  /* ---- Boxes ---- */
  // Each box: { id, page, qids:[...], x, y, w, h }   coords in % of page.
  const [boxes, setBoxes] = useState([]);
  const [selectedBoxId, setSelectedBoxId] = useState(null);

  const createBox = (rect) => {
    const id = newBoxId();
    const qids = [...selectedQids];
    setBoxes(prev => [...prev, { id, page: pageNum, qids, ...rect }]);
    setSelectedBoxId(id);
    // Auto-clear the now-covered questions from the selection so the next draw
    // doesn't accidentally land on top of them again. Questions become "locked"
    // in the sidebar until their last bbox is deleted.
    setSelectedQids(prev => {
      const next = new Set(prev);
      qids.forEach(qid => next.delete(qid));
      return next;
    });
    return id;
  };
  const updateBox = (id, patch) =>
    setBoxes(prev => prev.map(b => b.id === id ? { ...b, ...patch } : b));
  const deleteBox = (id) => {
    // Find the box BEFORE we drop it so we know whether to call the backend.
    const target = boxes.find(b => b.id === id);
    setBoxes(prev => prev.filter(b => b.id !== id));
    setSelectedBoxId(curr => curr === id ? null : curr);
    // Also drop the box's eval state so the right panel re-renders clean.
    setEvalsByBox(prev => { const c = { ...prev }; delete c[id]; return c; });
    // Persisted boxes (those with a backend UUID) need to be deleted server-side
    // too; the backend cascades to evaluations / criteria / misconceptions / reviews.
    if (target?.uuid) {
      window.AppApi.del(`/boxes/${target.uuid}`).catch(err =>
        console.error("[cockpit] backend delete failed:", err),
      );
    }
  };
  const reassignBox = (id, qid, on) => {
    setBoxes(prev => prev.map(b => {
      if (b.id !== id) return b;
      const set = new Set(b.qids);
      if (on) set.add(qid); else set.delete(qid);
      return { ...b, qids: [...set] };
    }));
  };

  /* ---- Tool mode + interaction state ---- */
  const [tool, setTool] = useState("draw"); // "draw" | "select" | "tick" | "cross" | "mark"
  // PDF annotations live alongside bboxes — they're teacher marks layered on top
  // of the page (✓ / ✗ / typed marks). Stored locally; persisted via save (TODO).
  const [annotations, setAnnotations] = useState([]); // [{ id, page, type, x, y, text? }]
  const [drawing, setDrawing] = useState(null);   // { x0, y0, x1, y1 }
  const [drag, setDrag] = useState(null);         // { boxId, mode, startX, startY, orig }

  const toPct = (e) => {
    const rect = overlayRef.current.getBoundingClientRect();
    return {
      x: Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)),
      y: Math.max(0, Math.min(100, ((e.clientY - rect.top) / rect.height) * 100)),
    };
  };

  const onOverlayMouseDown = (e) => {
    if (e.button !== 0) return;
    // Annotation drop: place a tick/cross/mark at the click point.
    if (tool === "tick" || tool === "cross" || tool === "mark") {
      const { x, y } = toPct(e);
      let text;
      if (tool === "mark") {
        text = window.prompt("Mark text (e.g. '-1', 'good', '✓✓'):", "+1");
        if (text == null) return;
      }
      setAnnotations(prev => [...prev, {
        id: newBoxId(), page: pageNum, type: tool, x, y, text,
      }]);
      return;
    }
    if (tool !== "draw") return;
    if (selectedQids.size === 0) return;        // need a target
    const { x, y } = toPct(e);
    setDrawing({ x0: x, y0: y, x1: x, y1: y });
    setSelectedBoxId(null);
  };
  const onOverlayMouseMove = (e) => {
    if (drawing) {
      const { x, y } = toPct(e);
      setDrawing(d => ({ ...d, x1: x, y1: y }));
      return;
    }
    if (drag) {
      const { x, y } = toPct(e);
      const dx = x - drag.startX, dy = y - drag.startY;
      const o = drag.orig;
      let { x: bx, y: by, w: bw, h: bh } = o;
      if (drag.mode === "move") { bx = o.x + dx; by = o.y + dy; }
      else {
        const m = drag.mode;
        if (m.includes("w")) { bx = o.x + dx; bw = o.w - dx; }
        if (m.includes("e")) { bw = o.w + dx; }
        if (m.includes("n")) { by = o.y + dy; bh = o.h - dy; }
        if (m.includes("s")) { bh = o.h + dy; }
        if (bw < 1) { bw = 1; }
        if (bh < 1) { bh = 1; }
      }
      bx = Math.max(0, Math.min(100 - bw, bx));
      by = Math.max(0, Math.min(100 - bh, by));
      updateBox(drag.boxId, { x: bx, y: by, w: bw, h: bh });
    }
  };
  const onOverlayMouseUp = () => {
    if (drawing) {
      const { x0, y0, x1, y1 } = drawing;
      const w = Math.abs(x1 - x0), h = Math.abs(y1 - y0);
      if (w > 1.2 && h > 1.2) {
        createBox({ x: Math.min(x0, x1), y: Math.min(y0, y1), w, h });
      }
      setDrawing(null);
    }
    if (drag) setDrag(null);
  };

  const beginDrag = (e, box, mode) => {
    e.stopPropagation();
    if (tool !== "select") return;
    const { x, y } = toPct(e);
    setSelectedBoxId(box.id);
    setDrag({ boxId: box.id, mode, startX: x, startY: y, orig: { ...box } });
  };

  // Delete key on selected box.
  useEffect(() => {
    const onKey = (e) => {
      if (!selectedBoxId) return;
      if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
      if (e.key === "Delete" || e.key === "Backspace") {
        e.preventDefault();
        deleteBox(selectedBoxId);
      }
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [selectedBoxId]);

  /* ---- Eval state (kept; per-box rather than per-question) ---- */
  const [evalsByBox, setEvalsByBox] = useState({});

  // Rehydrate from the backend on reopen: previously-drawn bboxes (with their
  // question links) and any evaluations attached to them. Runs once when
  // `rehydrate` first arrives AND allQuestions has resolved (so we can map
  // backend question UUIDs to local namespaced ids).
  const rehydratedOnce = useRef(false);
  useEffect(() => {
    if (rehydratedOnce.current) return;
    if (!rehydrate || !allQuestions.length) return;
    rehydratedOnce.current = true;

    // Backend box → local box.
    const localBoxes = (rehydrate.boxes || []).map(b => {
      const qidsLocal = (b.question_ids || [])
        .map(uuid => allQuestions.find(q => q.uuid === uuid)?.id)
        .filter(Boolean);
      return {
        id: b.id, uuid: b.id,
        page: b.page,
        x: Number(b.x) * 100, y: Number(b.y) * 100,
        w: Number(b.w) * 100, h: Number(b.h) * 100,
        qids: qidsLocal,
      };
    });
    setBoxes(localBoxes);

    // Backend evaluations → evalsByBox map (one entry per box, aggregating its questions).
    const byBox = {};
    for (const ev of rehydrate.evaluations || []) {
      const localBoxId = ev.bounding_box_id;
      const q = allQuestions.find(qq => qq.uuid === ev.question_id);
      if (!q) continue;
      const score = ev.state === "confirmed" || ev.state === "overridden"
        ? Number(ev.final_score ?? 0)
        : Number(ev.proposed_score ?? 0);

      // Pull the saved per-criterion breakdown (now returned by /answer-sheets/:id/evaluations).
      // If older rows don't carry it, fall back to a single synthetic "Overall" row sized to
      // the proposed/final score so the headline tally never reads as 0 again.
      const dbCriteria = Array.isArray(ev.criteria) ? ev.criteria : [];
      const rubricMatch = dbCriteria.length
        ? dbCriteria.map(c => ({
            id: c.id,
            desc: c.desc || c.description || "",
            max: Number(c.max ?? c.marks ?? 0),
            awarded: Number(c.awarded ?? 0),
            status: c.status || (Number(c.awarded) === Number(c.max ?? c.marks) ? "match" : Number(c.awarded) > 0 ? "partial" : "miss"),
            evidence: c.evidence || "",
          }))
        : (Number(ev.max_marks) > 0
            ? [{ id: "overall", desc: "Overall score (criteria not persisted for this older row)",
                 max: Number(ev.max_marks), awarded: score,
                 status: score >= Number(ev.max_marks) ? "match" : score > 0 ? "partial" : "miss",
                 evidence: "(hydrated from proposed/final score)" }]
            : []);
      const dbMisc = Array.isArray(ev.misconceptions) ? ev.misconceptions : [];
      const miscList = dbMisc.map(m => typeof m === "string"
        ? { code: m, note: "" }
        : { code: m.code, note: m.note || "" });

      const result = {
        qid: q.id, num: q.num, evaluation_id: ev.id,
        output: {
          max_marks: Number(ev.max_marks),
          transcription: ev.transcription || "",
          rubric_match: rubricMatch,
          proposed_score: score,
          confidence: ev.confidence ?? 0,
          misconceptions: miscList,
          feedback: ev.feedback || "",
          feedback_lang: ev.feedback_lang || "en",
          suggested_next: ev.suggested_next || "",
          model: "gemini-2.5-pro",
          prompt_version: ev.prompt_version || "v1",
          eval_ms: 0,
        },
      };
      if (!byBox[localBoxId]) {
        byBox[localBoxId] = { status: "done", results: [result] };
      } else {
        byBox[localBoxId].results.push(result);
      }
    }
    // Materialise `report` (first question's view) so the legacy panel renders.
    for (const id of Object.keys(byBox)) {
      const primary = byBox[id].results[0];
      byBox[id].report = primary ? {
        question_id: primary.qid, question_num: primary.num,
        max_marks: primary.output.max_marks,
        transcription: primary.output.transcription,
        rubric_match: primary.output.rubric_match,
        proposed_score: primary.output.proposed_score,
        confidence: primary.output.confidence,
        misconceptions: primary.output.misconceptions,
        feedback_bn: primary.output.feedback,
        suggested_next: primary.output.suggested_next,
        model: primary.output.model,
        eval_ms: primary.output.eval_ms,
      } : null;
    }
    setEvalsByBox(byBox);
  }, [rehydrate, allQuestions]);

  // Manual flow: same as Send-to-Gemini but POSTs `manual:true` so the backend
  // skips the model and reserves a blank rubric for the teacher to fill in.
  const evaluateManually = (boxId) => sendToGemini(boxId, { manual: true });

  const sendToGemini = async (boxId, opts = {}) => {
    const box = boxes.find(b => b.id === boxId);
    if (!box || !box.qids.length) return;
    if (!sheetId) {
      setEvalsByBox(prev => ({ ...prev, [boxId]: { status: "error", error: "No answer sheet — open the cockpit from Upload sheets." } }));
      return;
    }
    // Map each local question id → backend UUID. Bail if any qid is missing one
    // (paste-flow without persisted questions).
    const questionUuids = box.qids
      .map(qid => allQuestions.find(qq => qq.id === qid)?.uuid)
      .filter(Boolean);
    if (questionUuids.length === 0) {
      setEvalsByBox(prev => ({ ...prev, [boxId]: { status: "error", error: "Questions not persisted to DB — save the test first." } }));
      return;
    }

    setEvalsByBox(prev => ({
      ...prev,
      [boxId]: { status: "evaluating", results: [], failures: [], total: box.qids.length, report: null },
    }));

    try {
      // 1. Persist the bbox if we haven't yet. The local box may have multiple
      //    questions on it; the backend stores one row with all question links.
      let boxUuid = box.uuid;
      if (!boxUuid) {
        const r = await window.AppApi.post(`/answer-sheets/${sheetId}/boxes`, {
          page: box.page,
          x: box.x / 100, y: box.y / 100, w: box.w / 100, h: box.h / 100,
          question_ids: questionUuids,
        });
        boxUuid = r.id;
        setBoxes(prev => prev.map(b => b.id === boxId ? { ...b, uuid: boxUuid } : b));
      }

      // 2. Evaluate every (box × question) pair. Each call is wrapped so a
      //    single failure doesn't abort the rest, and results stream into the
      //    UI as they arrive (the right panel shows "Evaluating N/M" progress).
      const results = [];
      const failures = [];
      for (const qid of box.qids) {
        const q = allQuestions.find(qq => qq.id === qid);
        if (!q?.uuid) { failures.push({ qid, error: "no question UUID" }); continue; }
        try {
          const evalRes = await window.AppApi.post("/evaluations", {
            test_id: activeTest?._uuid,
            question_id: q.uuid,
            student_id: activeStudent?.student_id,
            bounding_box_id: boxUuid,
            manual: !!opts.manual,
          });
          results.push({ qid, num: q.num, output: evalRes.output, evaluation_id: evalRes.evaluation_id });
        } catch (e) {
          failures.push({ qid, num: q.num, error: e?.message || String(e) });
        }
        // Progressive update so the panel reflects each question as it completes.
        setEvalsByBox(prev => ({
          ...prev,
          [boxId]: {
            ...prev[boxId],
            status: results.length + failures.length >= box.qids.length ? "done" : "evaluating",
            results: [...results],
            failures: [...failures],
          },
        }));
      }

      // Legacy `report` shape (first result) preserved so older renderers still work.
      const primary = results[0];
      const report = primary ? {
        question_id: primary.qid,
        question_num: primary.num,
        max_marks: primary.output.max_marks,
        transcription: primary.output.transcription,
        rubric_match: primary.output.rubric_match,
        proposed_score: primary.output.proposed_score,
        confidence: primary.output.confidence,
        misconceptions: primary.output.misconceptions,
        feedback_bn: primary.output.feedback,
        suggested_next: primary.output.suggested_next,
        model: primary.output.model,
        eval_ms: primary.output.eval_ms,
      } : null;
      setEvalsByBox(prev => ({
        ...prev,
        [boxId]: { status: "done", results, failures, total: box.qids.length, report, decision: null },
      }));
    } catch (e) {
      setEvalsByBox(prev => ({ ...prev, [boxId]: { status: "error", error: e?.message || String(e) } }));
    }
  };

  /* ---- Topbar: Save evaluation / Finalise report ---- */
  const [topbarBusy, setTopbarBusy] = useState(null);  // "saving" | "finalising" | null
  const [topbarMsg, setTopbarMsg] = useState(null);    // { kind, text } | null
  const flashMsg = (kind, text) => {
    setTopbarMsg({ kind, text });
    setTimeout(() => setTopbarMsg(null), 3500);
  };

  // Persist every locally-drawn box that hasn't been POSTed yet. Send-to-Gemini
  // already persists on its own; this saves drafts of boxes the teacher drew
  // but hasn't evaluated, so they survive a refresh.
  const saveDraft = async () => {
    if (!sheetId) { flashMsg("err", "No sheet — open from Upload sheets"); return; }
    setTopbarBusy("saving");
    try {
      const unsaved = boxes.filter(b => !b.uuid);
      for (const box of unsaved) {
        const questionUuids = box.qids
          .map(qid => allQuestions.find(qq => qq.id === qid)?.uuid)
          .filter(Boolean);
        if (!questionUuids.length) continue;
        const r = await window.AppApi.post(`/answer-sheets/${sheetId}/boxes`, {
          page: box.page,
          x: box.x / 100, y: box.y / 100, w: box.w / 100, h: box.h / 100,
          question_ids: questionUuids,
        });
        setBoxes(prev => prev.map(b => b.id === box.id ? { ...b, uuid: r.id } : b));
      }
      flashMsg("ok", unsaved.length ? `Saved · ${unsaved.length} new bbox${unsaved.length !== 1 ? "es" : ""}` : "All up to date");
    } catch (e) {
      flashMsg("err", e?.message || String(e));
    }
    setTopbarBusy(null);
  };

  // Confirm every proposed evaluation for this student → compile the totals →
  // navigate back to the library AND reopen the Upload Sheets roster so the
  // updated row (Finalised · score/max) is visible immediately.
  const finaliseReport = async () => {
    if (!activeTest?.id || !activeStudent?.student_id) {
      flashMsg("err", "No student context"); return;
    }
    setTopbarBusy("finalising");
    try {
      const out = await window.AppApi.post(
        `/tests/${activeTest.id}/students/${activeStudent.student_id}/finalise`, {},
      );
      flashMsg("ok", `Finalised · ${out.score}/${out.max}`);
      // Hand off to the library: open the Upload Sheets modal for this test.
      // Use setScreen instead of onBack — onBack opens the test picker, which is
      // the "Change test" path, not what we want after a finalise.
      window.APP.OPEN_UPLOAD_SHEETS_FOR = activeTest.id;
      setTimeout(() => {
        if (typeof setScreen === "function") setScreen("library");
        else onBack?.();
      }, 1200);
    } catch (e) {
      flashMsg("err", e?.message || String(e));
    }
    setTopbarBusy(null);
  };

  /* ---- Live preview box while drawing ---- */
  const liveBox = drawing ? {
    x: Math.min(drawing.x0, drawing.x1),
    y: Math.min(drawing.y0, drawing.y1),
    w: Math.abs(drawing.x1 - drawing.x0),
    h: Math.abs(drawing.y1 - drawing.y0),
  } : null;

  const pageBoxes = boxes.filter(b => b.page === pageNum);
  const selectedBox = boxes.find(b => b.id === selectedBoxId) || null;

  const bySection = structured.sections.map(sec => ({
    section: sec,
    questions: allQuestions.filter(q => q.sectionId === sec.section_id),
  }));

  const overlayCursor =
    tool === "draw" ? (selectedQids.size ? "crosshair" : "not-allowed")
    : (tool === "tick" || tool === "cross" || tool === "mark") ? "copy"
    : drag ? "grabbing" : "default";

  const pageAnnotations = annotations.filter(a => a.page === pageNum);
  const deleteAnnotation = (id) => setAnnotations(prev => prev.filter(a => a.id !== id));

  return (
    <div style={{ display:"flex", flexDirection:"column", height:"100%", background:"var(--bg-0)" }}>
      <div style={{ padding:"12px 20px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:14, background:"var(--bg-0)", flexWrap:"wrap" }}>
        {activeStudent
          ? <window.AppUI.Avatar name={activeStudent.name} size="lg"/>
          : null}
        <div style={{ minWidth: 0, flex: "1 1 320px" }}>
          <div className="ui-pagehead-titlerow">
            <span style={{ color:"var(--ink-0)", fontSize:18, fontWeight:600, letterSpacing:"-0.2px" }}>
              {activeStudent ? activeStudent.name : activeTest ? activeTest.title : "Sample answer sheet"}
            </span>
            <PipelineRail step={3} compact/>
          </div>
          <div className="ui-pagehead-meta" style={{ marginTop: 3 }}>
            {activeTest && <span className="mono">{activeTest.id}</span>}
            {activeStudent && <span className="mono">{activeStudent.roll_no}</span>}
            {activeTest && activeStudent && <span>{activeTest.title}</span>}
            {activeTest && <span>{activeTest.className} · {activeTest.subject}</span>}
            <span>{allQuestions.length} questions</span>
            <span>{boxes.length} bbox{boxes.length!==1?"es":""}</span>
          </div>
        </div>
        <span style={{ marginLeft:"auto", display:"flex", gap:8, alignItems:"center" }}>
          {topbarBusy && <window.AppUI.StatusBadge tone="blue" icon="clock">{topbarBusy}</window.AppUI.StatusBadge>}
          {topbarMsg && <window.AppUI.StatusBadge tone={topbarMsg.kind === "err" ? "red" : "green"}>{topbarMsg.text}</window.AppUI.StatusBadge>}
          <button className="btn" onClick={saveDraft} disabled={!!topbarBusy}>
            <EVIcon name="check" size={15}/>{topbarBusy === "saving" ? "Saving…" : "Save evaluation"}
          </button>
          <button className="btn primary" onClick={finaliseReport} disabled={!!topbarBusy || !activeStudent}>
            <EVIcon name="flag" size={15}/>{topbarBusy === "finalising" ? "Finalising…" : "Finalise report"}
          </button>
        </span>
      </div>

      <div style={{ flex: 1, display:"grid", gridTemplateColumns:"300px 1fr 380px", gap: 0, minHeight: 0 }}>
        {/* LEFT — Questions list */}
        <QuestionSidebar
          bySection={bySection}
          allQuestions={allQuestions}
          selectedQids={selectedQids}
          toggleQid={toggleQid}
          toggleSection={toggleSection}
          boxes={boxes}
          colorForQuestion={(qid)=>colorForQuestion(qid, allQuestions)}
        />

        {/* MIDDLE — PDF viewer with bbox CRUD */}
        <main style={{ overflow:"hidden", display:"flex", flexDirection:"column", background:"var(--grey-solid)" }}>
          <div style={{ padding:"8px 14px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:10, background:"var(--bg-1)", flexWrap:"wrap" }}>
            <div className="ui-tabs sm" role="toolbar" aria-label="Annotation tools">
              <button className={tool==="draw"?"active":""} onClick={()=>setTool("draw")}><EVIcon name="penLine" size={14}/>Draw</button>
              <button className={tool==="select"?"active":""} onClick={()=>setTool("select")}><EVIcon name="move" size={14}/>Select</button>
              <button className={tool==="tick"?"active":""} onClick={()=>setTool("tick")}
                title="Click on the page to drop a green tick"
                style={{ color: tool==="tick" ? "var(--green)" : undefined, fontWeight: 700 }}>✓</button>
              <button className={tool==="cross"?"active":""} onClick={()=>setTool("cross")}
                title="Click on the page to drop a red cross"
                style={{ color: tool==="cross" ? "var(--red)" : undefined, fontWeight: 700 }}>✗</button>
              <button className={tool==="mark"?"active":""} onClick={()=>setTool("mark")}
                title="Click to drop a typed mark on the page"
                style={{ fontSize: 11 }}>✎m</button>
            </div>

            <span style={{ display:"flex", alignItems:"center", gap: 4 }}>
              <button className="ui-iconbtn sm" aria-label="Previous page" onClick={()=>setPageNum(p => Math.max(1, p-1))} disabled={pageNum<=1}><EVIcon name="chevronLeft" size={15}/></button>
              <span className="muted" style={{ fontSize: 12.5, minWidth: 60, textAlign:"center", fontVariantNumeric:"tabular-nums" }}>
                {pageCount ? `${pageNum} / ${pageCount}` : "—"}
              </span>
              <button className="ui-iconbtn sm" aria-label="Next page" onClick={()=>setPageNum(p => Math.min(pageCount, p+1))} disabled={pageNum>=pageCount}><EVIcon name="chevronRight" size={15}/></button>
            </span>

            <span style={{ display:"flex", alignItems:"center", gap: 4 }}>
              <button className="ui-iconbtn sm" aria-label="Zoom out" onClick={()=>setZoom(z => Math.max(0.5, +(z-0.15).toFixed(2)))} style={{ fontSize: 16 }}>−</button>
              <span className="muted" style={{ fontSize: 12.5, minWidth: 44, textAlign:"center", fontVariantNumeric:"tabular-nums" }}>{Math.round(zoom*100)}%</span>
              <button className="ui-iconbtn sm" aria-label="Zoom in" onClick={()=>setZoom(z => Math.min(3, +(z+0.15).toFixed(2)))}><EVIcon name="plus" size={14}/></button>
            </span>

            <span style={{ marginLeft:"auto", display:"flex", gap: 6, alignItems:"center" }}>
              <span className="muted" style={{ fontSize: 12.5 }}>
                {tool === "draw"
                  ? (selectedQids.size
                      ? `Drag to add bbox for ${selectedQids.size} q${selectedQids.size>1?"s":""}`
                      : "Pick at least one question →")
                  : "Click a bbox to edit · drag handles to resize · Del to remove"}
              </span>
            </span>
          </div>

          <div style={{ flex:1, overflow:"auto", padding: 18, display:"flex", justifyContent:"center", background:"var(--grey-solid)" }}>
            <div style={{ position:"relative", width: renderSize.w, height: renderSize.h, boxShadow:"0 10px 30px color-mix(in srgb, var(--ink-0) 50%, transparent)", background:"var(--surface)" }}>
              <canvas ref={canvasRef} style={{ display:"block", width: renderSize.w, height: renderSize.h }}/>
              {loadErr && (
                <div style={{ position:"absolute", inset:0, display:"grid", placeItems:"center", background:"color-mix(in srgb, var(--ink-0) 85%, transparent)", color:"var(--red)", fontSize: 12, padding: 20, textAlign:"center" }}>
                  Failed to load PDF<br/><span className="mono">{loadErr}</span>
                </div>
              )}
              {!pdfDoc && !loadErr && (
                <div style={{ position:"absolute", inset:0, display:"grid", placeItems:"center", color:"var(--ink-4)", fontFamily:"var(--font-mono)", fontSize: 12 }}>
                  Loading PDF…
                </div>
              )}

              <div
                ref={overlayRef}
                onMouseDown={onOverlayMouseDown}
                onMouseMove={onOverlayMouseMove}
                onMouseUp={onOverlayMouseUp}
                onMouseLeave={onOverlayMouseUp}
                onClick={(e) => { if (e.target === overlayRef.current && tool === "select") setSelectedBoxId(null); }}
                style={{
                  position:"absolute", inset: 0, userSelect:"none",
                  cursor: overlayCursor,
                }}
              >
                {pageBoxes.map(b => (
                  <BBoxView
                    key={b.id}
                    box={b}
                    color={b.qids[0] ? colorForQuestion(b.qids[0], allQuestions) : "var(--ink-3)"}
                    label={boxLabel(b, allQuestions)}
                    selected={b.id === selectedBoxId}
                    tool={tool}
                    onClickBox={(e) => { e.stopPropagation(); if (tool === "select") setSelectedBoxId(b.id); }}
                    onMoveStart={(e) => beginDrag(e, b, "move")}
                    onHandleStart={(e, hid) => beginDrag(e, b, hid)}
                    onDelete={() => deleteBox(b.id)}
                  />
                ))}
                {liveBox && (
                  <div style={{
                    position:"absolute",
                    left: `${liveBox.x}%`, top: `${liveBox.y}%`,
                    width: `${liveBox.w}%`, height: `${liveBox.h}%`,
                    border:"2px dashed var(--accent)",
                    background:"color-mix(in srgb, var(--amber-solid) 10%, transparent)",
                    pointerEvents:"none",
                  }}/>
                )}
                {/* Teacher annotations — ticks / crosses / typed marks dropped on the page */}
                {pageAnnotations.map(a => (
                  <div key={a.id}
                    onClick={(e) => { e.stopPropagation(); deleteAnnotation(a.id); }}
                    title="Click to remove"
                    style={{
                      position:"absolute",
                      left: `${a.x}%`, top: `${a.y}%`,
                      transform: "translate(-50%, -50%)",
                      cursor:"pointer", userSelect:"none",
                      pointerEvents:"auto",
                      fontWeight: 700,
                      textShadow: "0 0 6px color-mix(in srgb, var(--surface) 90%, transparent), 0 0 2px color-mix(in srgb, var(--surface) 90%, transparent)",
                    }}>
                    {a.type === "tick"  && <span style={{ fontSize: 26, color:"var(--green)" }}>✓</span>}
                    {a.type === "cross" && <span style={{ fontSize: 26, color:"var(--red)" }}>✗</span>}
                    {a.type === "mark"  && (
                      <span style={{
                        fontSize: 13, padding:"2px 6px", borderRadius: 3,
                        background:"color-mix(in srgb, var(--amber-solid) 92%, transparent)", color:"var(--amber)",
                        border:"1px solid color-mix(in srgb, var(--amber-solid) 55%, transparent)",
                        fontFamily:"var(--font-sans)",
                      }}>{a.text || "?"}</span>
                    )}
                  </div>
                ))}
              </div>
            </div>
          </div>

          <div style={{ padding:"8px 14px", borderTop:"1px solid var(--line)", background:"var(--bg-1)", display:"flex", alignItems:"center", gap:10 }}>
            <EVIcon name="move" size={14}/>
            <span style={{ fontSize: 12.5, color:"var(--ink-2)" }}>
              {selectedBox
                ? <>Selected · <span className="mono">{selectedBox.w.toFixed(1)}% × {selectedBox.h.toFixed(1)}%</span> @ page {selectedBox.page}</>
                : `${pageBoxes.length} bbox${pageBoxes.length!==1?"es":""} on this page · ${boxes.length} total`}
            </span>
            <span style={{ marginLeft:"auto", display:"flex", gap: 8 }}>
              {selectedBox && (
                <>
                  <button className="btn sm danger" onClick={()=>deleteBox(selectedBox.id)}><EVIcon name="trash" size={13}/>Delete</button>
                  <button className="btn sm primary" onClick={()=>sendToGemini(selectedBox.id)}
                    disabled={!selectedBox.qids.length || evalsByBox[selectedBox.id]?.status === "evaluating"}>
                    {evalsByBox[selectedBox.id]?.status === "evaluating" ? "Evaluating…" : (<><EVIcon name="sparkle" size={13}/>Evaluate</>)}
                  </button>
                </>
              )}
              {!selectedBox && (
                <button className="btn sm" disabled={!boxes.length} onClick={()=>{
                  // Wipe locally + delete every persisted box on the backend.
                  const persisted = boxes.filter(b => b.uuid);
                  setBoxes([]);
                  setSelectedBoxId(null);
                  setEvalsByBox({});
                  for (const b of persisted) {
                    window.AppApi.del(`/boxes/${b.uuid}`).catch(err =>
                      console.error("[cockpit] backend delete failed:", err),
                    );
                  }
                }}>Clear all</button>
              )}
            </span>
          </div>
        </main>

        {/* RIGHT — Bbox CRUD panel */}
        <aside style={{ borderLeft:"1px solid var(--line)", background:"var(--bg-1)", display:"flex", flexDirection:"column", overflow:"hidden" }}>
          <BBoxPanel
            allQuestions={allQuestions}
            selectedQids={selectedQids}
            boxes={boxes}
            selectedBoxId={selectedBoxId}
            setSelectedBoxId={(id, box) => {
              setSelectedBoxId(id);
              if (box && box.page !== pageNum) setPageNum(box.page);
            }}
            onDelete={deleteBox}
            onReassign={reassignBox}
            onSetTool={setTool}
            colorForQuestion={(qid)=>colorForQuestion(qid, allQuestions)}
            evalsByBox={evalsByBox}
            onEvaluate={sendToGemini}
            onEvaluateManually={evaluateManually}
          />
        </aside>
      </div>
    </div>
  );
};

/* short label for a bbox displayed on the page */
function boxLabel(b, allQuestions) {
  if (!b.qids.length) return "unassigned";
  if (b.qids.length === 1) {
    const q = allQuestions.find(qq => qq.id === b.qids[0]);
    return q ? `Q${q.num}` : b.qids[0];
  }
  return `${b.qids.length} qs`;
}

/* ---------- LEFT sidebar with multi-select ---------- */
const QuestionSidebar = ({ bySection, allQuestions, selectedQids, toggleQid, toggleSection, boxes, colorForQuestion }) => {
  const boxCountFor = (qid) => boxes.filter(b => b.qids.includes(qid)).length;
  // A question is "locked" once it has ≥1 bbox. Locked rows can't be re-selected,
  // and select-all / section-toggle treat them as out-of-scope.
  const isLocked = (qid) => boxCountFor(qid) > 0;
  const selectableQs = allQuestions.filter(q => !isLocked(q.id));
  const allSelected = selectableQs.length > 0 && selectableQs.every(q => selectedQids.has(q.id));
  return (
    <aside style={{ borderRight:"1px solid var(--line)", background:"var(--bg-1)", overflow:"auto", display:"flex", flexDirection:"column" }}>
      <div style={{ padding:"14px 16px 12px", borderBottom:"1px solid var(--line)", position:"sticky", top:0, background:"var(--bg-1)", zIndex: 1 }}>
        <div className="ui-section-label" style={{ marginBottom: 4 }}>Selection</div>
        <div style={{ color:"var(--ink-0)", fontSize: 13.5, fontWeight: 600, marginBottom: 8 }}>
          {selectedQids.size} of {selectableQs.length} unassigned · {allQuestions.length - selectableQs.length} done
        </div>
        <div style={{ display:"flex", gap: 6 }}>
          <button className="btn xs"
            disabled={selectableQs.length === 0}
            onClick={() => selectableQs.forEach(q => {
              if (allSelected) toggleQid(q.id);
              else if (!selectedQids.has(q.id)) toggleQid(q.id);
            })}>
            {allSelected ? "Clear all" : "Select all"}
          </button>
          <button className="btn xs ghost"
            disabled={selectedQids.size===0}
            onClick={() => [...selectedQids].forEach(id => toggleQid(id))}>
            Clear
          </button>
        </div>
      </div>

      {bySection.map(({ section, questions }) => {
        const inSection = questions.filter(q => !isLocked(q.id)).map(q => q.id);
        const lockedInSection = questions.length - inSection.length;
        const onCount = inSection.filter(id => selectedQids.has(id)).length;
        const triState = inSection.length === 0 ? "locked" : onCount === 0 ? "off" : onCount === inSection.length ? "on" : "some";
        return (
          <div key={section.section_id}>
            <div
              onClick={() => triState !== "locked" && toggleSection(section.section_id, triState !== "on")}
              style={{
                padding:"10px 16px",
                fontSize: 11.5, color:"var(--ink-2)", letterSpacing:".05em", textTransform:"uppercase", fontWeight: 500,
                display:"flex", alignItems:"center", gap:8,
                cursor: triState === "locked" ? "default" : "pointer",
                opacity: triState === "locked" ? 0.55 : 1,
                background:"var(--bg-2)", borderTop:"1px solid var(--line)", borderBottom:"1px solid var(--line)",
              }}>
              <span className={`chk ${triState==="on"?"on":""}`} style={triState==="some" ? { background:"var(--accent)", opacity: 0.5 } : undefined}/>
              <span style={{ color:"var(--ink-0)", fontWeight: 600 }}>{section.section_id}</span>
              <span>{section.title}</span>
              <span style={{ marginLeft:"auto", color:"var(--ink-3)", fontVariantNumeric:"tabular-nums", letterSpacing: 0 }}>
                {onCount}/{inSection.length}{lockedInSection > 0 ? ` · ${lockedInSection} done` : ""}
              </span>
            </div>
            {questions.map(q => {
              const isSel = selectedQids.has(q.id);
              const cnt = boxCountFor(q.id);
              const locked = cnt > 0;
              const col = colorForQuestion(q.id);
              return (
                <div key={q.id}
                  // Locked + selected = "queued for another bbox" — clicking again deselects.
                  onClick={(e)=> { if (!locked || isSel) toggleQid(q.id, { exclusive: e.altKey }); }}
                  title={locked && !isSel ? `${cnt} bbox already · click ⊕ to add another` : undefined}
                  style={{
                    padding:"10px 16px",
                    boxShadow: isSel ? `inset 3px 0 0 ${col}` : "none",
                    background: isSel ? "var(--surface-active)" : "transparent",
                    cursor: (locked && !isSel) ? "default" : "pointer",
                    opacity: locked && !isSel ? 0.55 : 1,
                    borderBottom: "1px solid var(--line)",
                    display:"flex", gap: 9,
                  }}>
                  <span className={`chk ${isSel?"on":""}`} style={isSel ? { background: col, borderColor: col } : (locked ? { background: "var(--bg-3)", borderColor: "var(--line-strong)" } : undefined)}>
                    {locked && !isSel && <span style={{ color:"var(--green)", fontSize: 10, lineHeight: 1 }}>✓</span>}
                  </span>
                  <div style={{ flex:1, minWidth: 0 }}>
                    <div style={{ display:"flex", alignItems:"center", gap:6, marginBottom: 3 }}>
                      <span style={{ color: "var(--ink-0)", fontSize: 13, fontWeight: 600, textDecoration: locked && !isSel ? "line-through" : "none" }}>Q{q.num}</span>
                      <span className="ui-id" style={{ fontSize: 11.5 }}>{q.marks}m</span>
                      {cnt > 0 && (
                        <span className="ui-badge tint grey" style={{ boxShadow: `inset 0 0 0 1px ${col}` }}>
                          <span style={{ width: 6, height: 6, borderRadius: 2, border: `1.5px solid ${col}` }}/>{cnt}
                        </span>
                      )}
                      {/* ⊕ Add another bbox: bypasses the lock so a multi-region answer
                          can be split across several boxes for the same question. */}
                      {locked && (
                        <button onClick={(e) => { e.stopPropagation(); toggleQid(q.id, { exclusive: e.altKey }); }}
                          title="Draw another bbox for this question"
                          style={{
                            marginLeft:"auto", width: 22, height: 22, padding: 0,
                            border: `1px solid ${isSel ? col : "var(--line-strong)"}`,
                            background: isSel ? `color-mix(in oklab, ${col} 20%, transparent)` : "var(--surface)",
                            color: isSel ? col : "var(--ink-2)",
                            borderRadius: 999, cursor:"pointer", fontSize: 13, lineHeight: 1,
                          }}>
                          {isSel ? "−" : "+"}
                        </button>
                      )}
                    </div>
                    <div style={{ fontSize: 12.5, color: isSel ? "var(--ink-1)" : "var(--ink-3)", lineHeight: 1.4, fontFamily:"var(--font-sans)",
                      display:"-webkit-box", WebkitLineClamp:2, WebkitBoxOrient:"vertical", overflow:"hidden",
                    }}>
                      {q.stem || <span className="muted">(no stem)</span>}
                    </div>
                    {/* Sub-questions preview for Long answers */}
                    {q.type === "long_answer" && Array.isArray(q.sub_questions) && q.sub_questions.length > 0 && (
                      <div style={{ marginTop: 6, paddingLeft: 8, borderLeft: `2px solid ${locked ? "var(--line-soft)" : col}` }}>
                        {q.sub_questions.map((sq, i) => (
                          <div key={sq.id || i} style={{
                            display:"flex", gap: 6, alignItems:"baseline",
                            fontSize: 11, color:"var(--ink-2)", lineHeight: 1.45,
                            fontFamily:"var(--font-sans)",
                            marginTop: i === 0 ? 0 : 2,
                          }}>
                            <span className="mono" style={{ color:"var(--ink-3)", minWidth: 14 }}>
                              {String.fromCharCode(97 + i)}.
                            </span>
                            <span style={{ flex: 1, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
                              {sq.text || <span className="muted">(empty)</span>}
                            </span>
                            {sq.marks != null && (
                              <span className="mono muted" style={{ fontSize: 9.5 }}>{Number(sq.marks)}m</span>
                            )}
                          </div>
                        ))}
                      </div>
                    )}
                  </div>
                </div>
              );
            })}
            {questions.length === 0 && (
              <div className="muted" style={{ padding:"12px 16px", fontSize: 12.5 }}>No questions in this section.</div>
            )}
          </div>
        );
      })}

      {allQuestions.length === 0 && (
        <window.AppUI.EmptyState icon="list" compact title="No structured questions yet"
          hint="You can still draw bboxes — they'll be saved as unassigned."/>
      )}
    </aside>
  );
};

/* ---------- The bbox rectangle + handles ---------- */
const BBoxView = ({ box, color, label, selected, tool, onClickBox, onMoveStart, onHandleStart, onDelete }) => {
  const style = {
    position:"absolute",
    left: `${box.x}%`, top: `${box.y}%`,
    width: `${box.w}%`, height: `${box.h}%`,
    border: `2px solid ${color}`,
    background: `color-mix(in srgb, ${color} 12%, transparent)`,
    cursor: tool === "select" ? (selected ? "move" : "pointer") : "default",
    boxShadow: selected ? `0 0 0 2px color-mix(in srgb, var(--surface) 18%, transparent), 0 0 0 4px color-mix(in srgb, ${color} 50%, transparent)` : "none",
  };
  return (
    <div style={style}
      onClick={onClickBox}
      onMouseDown={selected && tool === "select" ? onMoveStart : undefined}>
      <span style={{
        position:"absolute", top: -18, left: -2,
        background: color, color:"var(--on-solid)",
        fontSize: 10, fontWeight: 700, padding: "1px 6px", borderRadius: 3,
        fontFamily:"var(--font-mono)", whiteSpace:"nowrap",
        pointerEvents:"none",
      }}>{label}</span>
      {selected && tool === "select" && (
        <>
          {HANDLES.map(h => (
            <div key={h.id}
              onMouseDown={(e) => onHandleStart(e, h.id)}
              style={{
                position:"absolute",
                left: `calc(${h.x*100}% - 5px)`,
                top:  `calc(${h.y*100}% - 5px)`,
                width: 10, height: 10,
                background: "var(--surface)",
                border: `2px solid ${color}`,
                borderRadius: 2,
                cursor: h.cur,
              }}
            />
          ))}
          <button
            onMouseDown={(e)=>e.stopPropagation()}
            onClick={(e) => { e.stopPropagation(); onDelete(); }}
            title="Delete bbox (Del)"
            style={{
              position:"absolute", top: -22, right: -8,
              background:"var(--red)", color:"var(--on-solid)",
              width: 20, height: 20, borderRadius: "50%",
              fontSize: 12, lineHeight: 1, fontWeight: 700,
              boxShadow:"0 2px 6px color-mix(in srgb, var(--ink-0) 40%, transparent)",
              cursor:"pointer",
            }}>×</button>
        </>
      )}
    </div>
  );
};

// One card per question — lists every bbox linked to it, shows the editable
// rubric / feedback, and a single Send-to-Gemini button that aggregates all
// regions for this question. Mirrors BBoxRow's edit machinery, just keyed off
// a question instead of a single bbox.
const QuestionEvalCard = ({ question, boxes, allQuestions, selectedBoxId, setSelectedBoxId, onSetTool,
                            onDelete, onReassign, onEvaluate, onEvaluateManually, colorForQuestion,
                            evalState, isOpenAssign, onToggleAssign }) => {
  const col = colorForQuestion(question.id);
  const result = evalState?.results?.[0];
  const isMcq = question.type === "mcq";

  const [edits, setEdits] = useState({});
  const [expanded, setExpanded] = useState(false);   // collapsed by default — header shows verdict + score
  const [saveBusy, setSaveBusy] = useState(false);
  const [saveErr, setSaveErr] = useState(null);
  const [saveOk, setSaveOk] = useState(null);
  const evalEdits = result ? edits[result.evaluation_id] : null;

  // Tap any bbox chip to select that bbox on the canvas (so the teacher can
  // resize / delete it). Send-to-Gemini just uses the first bbox — the backend
  // gathers them all internally.
  const primaryBox = boxes[0];

  if (!result) {
    return (
      <div style={{ padding:"12px 16px", borderBottom:"1px solid var(--line)" }}>
        <div style={{ display:"flex", alignItems:"center", gap: 6, marginBottom: 6 }}>
          <span style={{ width: 10, height: 10, background: col, borderRadius: 2, flexShrink: 0 }}/>
          <span style={{ color:"var(--ink-0)", fontSize: 13, fontWeight: 600 }}>Q{qLabel(question)}</span>
          <span className="ui-badge tint grey">{question.marks}m</span>
          <span style={{ flex: 1, fontSize: 12.5, color:"var(--ink-3)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", fontFamily:"var(--font-sans)" }}>
            {question.stem}
          </span>
        </div>
        <div style={{ display:"flex", flexWrap:"wrap", gap: 4, marginBottom: 6 }}>
          {boxes.map(b => (
            <span key={b.id} className="pill mono"
              onClick={(e) => { e.stopPropagation(); setSelectedBoxId(b.id, b); onSetTool("select"); }}
              style={{
                fontSize: 10, cursor:"pointer",
                color: selectedBoxId === b.id ? col : "var(--ink-2)",
                borderColor: selectedBoxId === b.id ? col : "var(--line-strong)",
                background: selectedBoxId === b.id ? `color-mix(in oklab, ${col} 22%, transparent)` : "transparent",
                fontWeight: selectedBoxId === b.id ? 700 : 500,
              }}>
              ▢ p{b.page} · {b.w.toFixed(0)}×{b.h.toFixed(0)}
            </span>
          ))}
        </div>
        {evalState?.status === "evaluating" && (
          <window.AppUI.StatusBadge tone="amber" icon="clock">Evaluating…</window.AppUI.StatusBadge>
        )}
        {evalState?.status === "error" && (
          <div className="ui-alert red">Error: {String(evalState.error).slice(0, 100)}</div>
        )}
        {!evalState && (
          <div style={{ display:"flex", gap: 6 }}>
            <button className="btn sm primary"
              onClick={(e)=>{ e.stopPropagation(); onEvaluate(primaryBox.id); }}>
              <EVIcon name="sparkle" size={13}/>Send to Gemini · {boxes.length} bbox{boxes.length>1?"es":""}
            </button>
            <button className="btn sm"
              title="Skip Gemini and fill in marks + feedback yourself"
              onClick={(e)=>{ e.stopPropagation(); onEvaluateManually(primaryBox.id); }}>
              <EVIcon name="penLine" size={13}/>Evaluate manually
            </button>
          </div>
        )}
      </div>
    );
  }

  // --- evaluated state: edit-and-save UX ---
  const effAward = (cId, orig) =>
    evalEdits?.criteria && Object.prototype.hasOwnProperty.call(evalEdits.criteria, cId)
      ? Number(evalEdits.criteria[cId]) : Number(orig);
  const effFeedback = evalEdits?.feedback != null ? evalEdits.feedback : (result.output.feedback || "");
  const sumAward = (result.output.rubric_match || []).reduce((a, c) => a + effAward(c.id, c.awarded), 0);
  const isEdited = !!evalEdits && (
    (evalEdits.feedback != null && evalEdits.feedback !== (result.output.feedback || ""))
    || Object.keys(evalEdits.criteria || {}).length > 0
  );
  const setCriterion = (cId, val) => {
    const max = Number((result.output.rubric_match || []).find(c => c.id === cId)?.max ?? 0);
    const clamped = Math.max(0, Math.min(max, Number(val) || 0));
    setEdits(prev => ({
      ...prev,
      [result.evaluation_id]: {
        ...(prev[result.evaluation_id] || {}),
        criteria: { ...(prev[result.evaluation_id]?.criteria || {}), [cId]: clamped },
      },
    }));
  };
  const setFeedback = (val) => {
    setEdits(prev => ({
      ...prev,
      [result.evaluation_id]: { ...(prev[result.evaluation_id] || {}), feedback: val },
    }));
  };
  const resetEdits = () => {
    setEdits(prev => { const c = { ...prev }; delete c[result.evaluation_id]; return c; });
    setSaveOk(null); setSaveErr(null);
  };
  const saveOverride = async () => {
    setSaveBusy(true); setSaveErr(null); setSaveOk(null);
    try {
      const final_criteria = (result.output.rubric_match || []).map(c => ({
        criterion_id: c.id,
        awarded: effAward(c.id, c.awarded),
      }));
      await window.AppApi.post(`/evaluations/${result.evaluation_id}/override`, {
        final_score: Number(sumAward.toFixed(2)),
        final_feedback: effFeedback,
        final_criteria,
        override_reason: "manual teacher edit",
      });
      // Local result mutation so future renders use the new numbers.
      result.output.proposed_score = sumAward;
      result.output.feedback = effFeedback;
      result.output.rubric_match = (result.output.rubric_match || []).map(c => ({
        ...c, awarded: effAward(c.id, c.awarded),
      }));
      resetEdits();
      setSaveOk("Saved");
      setTimeout(() => setSaveOk(null), 2200);
    } catch (e) { setSaveErr(e?.message || String(e)); }
    setSaveBusy(false);
  };

  // Verdict + score for the always-visible header strip. We compute it from
  // the LIVE edited rubric (sumAward, not result.output.proposed_score) so the
  // header reflects in-progress changes before save.
  const verdict = verdictFor(sumAward, Number(result.output.max_marks ?? 0));

  return (
    <div style={{ padding:"12px 16px", borderBottom:"1px solid var(--line)",
                  boxShadow: isEdited ? `inset 3px 0 0 var(--amber-solid)` : expanded ? `inset 3px 0 0 ${col}` : "none",
                  background: expanded ? "var(--surface)" : "transparent" }}>
      {/* ========== COMPACT HEADER — always visible ========== */}
      <div onClick={() => setExpanded(e => !e)}
        style={{ display:"flex", alignItems:"center", gap: 8, cursor:"pointer", userSelect:"none" }}>
        {/* expand chevron */}
        <span style={{ color:"var(--ink-3)", fontSize: 11, width: 12, display:"inline-block", textAlign:"center", flexShrink: 0,
                       transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition:"transform 0.12s" }}>›</span>
        {/* color dot */}
        <span style={{ width: 8, height: 8, background: col, borderRadius: 2, flexShrink: 0 }}/>
        {/* Q-pill in section.q form */}
        <span style={{ color:"var(--ink-0)", fontSize: 13, fontWeight: 600, minWidth: 32 }}>Q{qLabel(question)}</span>
        <span className="ui-badge tint grey">{question.marks}m</span>
        {isMcq && <span className="ui-badge tint blue">MCQ</span>}

        {/* verdict + score */}
        <span style={{ marginLeft: "auto", display:"inline-flex", alignItems:"center", gap: 6,
                       padding:"2px 9px", minHeight: 22, borderRadius: 999,
                       background: `color-mix(in oklab, ${verdict.color} 12%, transparent)`,
                       border: `1px solid color-mix(in oklab, ${verdict.color} 45%, transparent)` }}>
          <span style={{ color: verdict.color, fontWeight: 700, fontSize: 12, lineHeight: 1 }}>{verdict.icon}</span>
          <span style={{ color: verdict.color, fontWeight: 600, fontSize: 12.5, fontVariantNumeric:"tabular-nums" }}>
            {sumAward.toFixed(1)}/{Number(result.output.max_marks ?? 0)}
          </span>
        </span>
        {isEdited && <span className="ui-badge tint amber">edited</span>}
      </div>

      {/* Stem — single-line preview always visible under the header */}
      <div style={{ marginLeft: 28, marginTop: 4, fontSize: 12.5, color:"var(--ink-3)",
                    overflow:"hidden", textOverflow:"ellipsis",
                    display: "-webkit-box", WebkitLineClamp: expanded ? "unset" : 1, WebkitBoxOrient:"vertical",
                    fontFamily:"var(--font-sans)", lineHeight: 1.4 }}>
        {question.stem}
      </div>

      {/* ========== EXPANDED BODY — only when toggled open ========== */}
      {expanded && (
        <div style={{ marginTop: 8 }} onClick={(e) => e.stopPropagation()}>

          {/* Linked bboxes — chips + Boxes toggle on its own row */}
          <div style={{ display:"flex", alignItems:"center", gap: 6, marginBottom: 6 }}>
            <div style={{ flex: 1, display:"flex", flexWrap:"wrap", gap: 4 }}>
              {boxes.map(b => {
                const isSel = selectedBoxId === b.id;
                return (
                  <span key={b.id} className="pill mono"
                    onClick={(e) => { e.stopPropagation(); setSelectedBoxId(b.id, b); onSetTool("select"); }}
                    title={`page ${b.page} · ${b.w.toFixed(1)}% × ${b.h.toFixed(1)}%`}
                    style={{
                      fontSize: 10, cursor:"pointer",
                      color: isSel ? col : "var(--ink-2)",
                      borderColor: isSel ? col : "var(--line-strong)",
                      background: isSel ? `color-mix(in oklab, ${col} 22%, transparent)` : "transparent",
                      fontWeight: isSel ? 700 : 500,
                      display:"inline-flex", alignItems:"center", gap: 4,
                    }}>
                    ▢ p{b.page}
                    <button onClick={(e) => { e.stopPropagation(); onDelete(b.id); }}
                      title="Delete this bbox" style={{ marginLeft: 2, background:"none", border:"none", color:"var(--red)", cursor:"pointer", padding: 0, fontSize: 11, lineHeight: 1 }}>×</button>
                  </span>
                );
              })}
              <span className="muted mono" style={{ fontSize: 10 }}>· {boxes.length} region{boxes.length>1?"s":""}</span>
            </div>
            <button className="btn sm ghost" onClick={onToggleAssign}>
              {isOpenAssign ? "Done" : "Re-assign"}
            </button>
          </div>

          {/* Re-assign drawer */}
          {isOpenAssign && (
            <div className="cx-tile" style={{ marginBottom: 10, padding: 10, maxHeight: 220, overflow:"auto" }}>
              <div className="muted" style={{ fontSize: 10, marginBottom: 4 }}>Toggle which questions each bbox covers:</div>
              {boxes.map(b => (
                <div key={b.id} style={{ marginBottom: 6, paddingBottom: 6, borderBottom:"1px solid var(--line-soft)" }}>
                  <div className="mono muted" style={{ fontSize: 10, marginBottom: 2 }}>▢ p{b.page} · {b.w.toFixed(1)}% × {b.h.toFixed(1)}%</div>
                  {allQuestions.map(q => {
                    const on = b.qids.includes(q.id);
                    return (
                      <label key={q.id} style={{ display:"flex", alignItems:"center", gap: 6, padding:"2px 0", fontSize: 11, cursor:"pointer", color:"var(--ink-1)" }}>
                        <input type="checkbox" checked={on} onChange={() => onReassign(b.id, q.id, !on)}/>
                        <span className="mono" style={{ color: colorForQuestion(q.id), fontWeight: 600 }}>Q{qLabel(q)}</span>
                        <span style={{ fontSize: 10.5, color:"var(--ink-3)", flex:1, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{q.stem?.slice(0, 50)}</span>
                      </label>
                    );
                  })}
                </div>
              ))}
            </div>
          )}

          {/* Confidence — non-MCQ only (MCQ is binary, no analysis) */}
          {!isMcq && result.output.confidence != null && (
            <div style={{ display:"flex", alignItems:"center", gap: 6, marginBottom: 8, fontSize: 11 }}>
              <span className="muted">Confidence</span>
              <div style={{ flex:1, height: 6, background:"var(--track)", borderRadius: 999, overflow:"hidden" }}>
                <div style={{ width: `${Number(result.output.confidence) * 100}%`, height:"100%",
                              background: result.output.confidence > 0.8 ? "var(--green)" : result.output.confidence > 0.5 ? "var(--amber)" : "var(--red)" }}/>
              </div>
              <span className="mono" style={{ color:"var(--ink-1)" }}>{Math.round(Number(result.output.confidence) * 100)}%</span>
            </div>
          )}

      {/* Editable rubric — hidden entirely for MCQ (binary, no analysis) */}
      {!isMcq && Array.isArray(result.output.rubric_match) && result.output.rubric_match.length > 0 && (
        <>
          <div className="ui-section-label" style={{ marginTop: 6, marginBottom: 4 }}>Rubric · editable</div>
          {result.output.rubric_match.map((c, i) => {
            const award = effAward(c.id, c.awarded);
            const dot = award === Number(c.max) ? "var(--green)" : award > 0 ? "var(--amber)" : "var(--red)";
            const overridden = evalEdits?.criteria && Object.prototype.hasOwnProperty.call(evalEdits.criteria, c.id);
            return (
              <div key={c.id || i} style={{ padding:"5px 0", borderTop: i > 0 ? "1px solid var(--line-soft)" : "none" }}>
                <div style={{ display:"flex", alignItems:"center", gap: 8, fontSize: 12.5 }}>
                  <span style={{ width: 6, height: 6, borderRadius:"50%", background: dot, flexShrink: 0 }}/>
                  <span style={{ flex: 1, color:"var(--ink-1)" }}>{c.desc || c.description}</span>
                  <input type="number" min={0} max={Number(c.max)} step={0.5}
                    value={award}
                    onChange={e => setCriterion(c.id, e.target.value)}
                    onClick={e => e.stopPropagation()}
                    style={{
                      width: 56, padding:"3px 6px", textAlign:"right",
                      background:"var(--surface)", border: `1px solid ${overridden ? "var(--amber)" : "var(--line-strong)"}`,
                      borderRadius: 6, color:"var(--ink-0)", fontVariantNumeric:"tabular-nums", fontSize: 12.5,
                    }}/>
                  <span className="mono muted" style={{ fontSize: 10.5, minWidth: 22 }}>/ {Number(c.max)}</span>
                </div>
                {c.evidence && (
                  <div className="muted" style={{ fontSize: 10.5, marginLeft: 12, marginTop: 3, fontStyle:"italic", lineHeight: 1.4 }}>
                    "{c.evidence}"
                  </div>
                )}
              </div>
            );
          })}
        </>
      )}

      {/* Feedback */}
      <div className="ui-section-label" style={{ marginTop: 8, marginBottom: 4 }}>
        Feedback · editable
      </div>
      <textarea
        value={effFeedback}
        onChange={e => setFeedback(e.target.value)}
        onClick={e => e.stopPropagation()}
        placeholder="Student-facing feedback for this question…"
        style={{
          width:"100%", minHeight: 64, padding:"8px 10px", boxSizing:"border-box",
          background:"var(--surface)",
          border:`1px solid ${evalEdits?.feedback != null && evalEdits.feedback !== (result.output.feedback || "") ? "var(--amber)" : "var(--line-strong)"}`,
          borderRadius: "var(--radius-ctl)", color:"var(--ink-0)", fontSize: 12.5, lineHeight: 1.5,
          fontFamily: result.output.feedback_lang === "bn" ? "var(--font-sans)" : "inherit",
          resize:"vertical",
        }}/>

      {/* Transcription */}
      {result.output.transcription && (
        <details style={{ marginTop: 6 }}>
          <summary className="ui-section-label" style={{ cursor:"pointer" }}>Transcription</summary>
          <div style={{ fontSize: 11, color:"var(--ink-2)", whiteSpace:"pre-wrap", marginTop: 4,
                        fontFamily: "var(--font-sans)", lineHeight: 1.5 }}>
            {result.output.transcription}
          </div>
        </details>
      )}

      {/* Action row */}
      <div style={{ display:"flex", alignItems:"center", gap: 6, marginTop: 8 }}>
        <span style={{ fontSize: 11, flex: 1 }}>
          {saveErr  && <span style={{ color:"var(--red)" }}>{saveErr}</span>}
          {saveOk   && <span style={{ color:"var(--green)" }}>{saveOk}</span>}
          {!saveErr && !saveOk && isEdited && <span className="muted">Unsaved changes</span>}
        </span>
        <button className="btn sm ghost" onClick={(e) => { e.stopPropagation(); onEvaluate(primaryBox.id); }}
          title="Re-send all regions to Gemini" style={{ padding:"2px 6px", fontSize: 10 }}>
          <EVIcon name="refresh" size={13}/>Re-evaluate
        </button>
        <button className="btn sm ghost" disabled={!isEdited || saveBusy}
          onClick={(e) => { e.stopPropagation(); resetEdits(); }}
          style={{ padding:"2px 8px", fontSize: 11 }}>Reset</button>
        <button className="btn sm primary" disabled={!isEdited || saveBusy}
          onClick={(e) => { e.stopPropagation(); saveOverride(); }}
          style={{ padding:"3px 10px", fontSize: 11 }}>
          {saveBusy ? "Saving…" : "Save changes"}
        </button>
      </div>
        </div>
      )}
    </div>
  );
};

/* ---------- RIGHT panel: bbox list & CRUD ---------- */
// Single bbox row: header + pills + (when selected & evaluated) a per-question
// carousel with rubric breakdown and feedback. One question's details at a time
// — pills are clickable and ‹ / › buttons cycle through them.
const BBoxRow = ({ b, isSel, isOpenAssign, onToggleAssign, setSelectedBoxId, onSetTool,
                   onDelete, onReassign, onEvaluate, onEvaluateManually, allQuestions, colorForQuestion, evalState }) => {
  const primaryQid = b.qids[0];
  const col = primaryQid ? colorForQuestion(primaryQid) : "var(--ink-3)";
  const results = evalState?.results || [];
  const failures = evalState?.failures || [];

  const [activeIdx, setActiveIdx] = useState(0);
  const safeIdx = Math.min(activeIdx, Math.max(0, results.length - 1));
  const activeResult = results[safeIdx];

  // Teacher overrides — keyed by evaluation_id so each question on a multi-Q
  // bbox carries its own edit buffer. { criteria: { [crit_id]: awarded }, feedback }
  const [edits, setEdits] = useState({});
  const [saveBusy, setSaveBusy] = useState(false);
  const [saveErr, setSaveErr] = useState(null);
  const [saveOk, setSaveOk] = useState(null);
  const evalEdits = activeResult ? edits[activeResult.evaluation_id] : null;

  // Totals: sum across whatever has been evaluated so far + expected sum from the bbox's questions.
  const sumAwarded = results.reduce((a, r) => a + Number(r.output?.proposed_score ?? 0), 0);
  const sumReceivedMax = results.reduce((a, r) => a + Number(r.output?.max_marks ?? 0), 0);
  const expectedMax = b.qids.reduce((a, qid) => {
    const q = allQuestions.find(qq => qq.id === qid);
    return a + Number(q?.marks ?? 0);
  }, 0);
  const totalMax = sumReceivedMax || expectedMax;

  return (
    <div onClick={() => { setSelectedBoxId(b.id, b); onSetTool("select"); }}
      style={{
        padding:"10px 14px",
        borderBottom:"1px solid var(--line-soft)",
        borderLeft:`3px solid ${isSel ? col : "transparent"}`,
        background: isSel ? "var(--bg-2)" : "transparent",
        cursor:"pointer",
      }}>
      {/* Header */}
      <div style={{ display:"flex", alignItems:"center", gap: 6, marginBottom: 6 }}>
        <span style={{ width: 10, height: 10, background: col, borderRadius: 2, flexShrink: 0 }}/>
        <span className="mono" style={{ color:"var(--ink-0)", fontSize: 11.5, fontWeight: 600 }}>{b.id.slice(0, 10)}…</span>
        <span className="ui-badge tint grey">p{b.page}</span>
        <span style={{ marginLeft:"auto", display:"flex", gap: 6 }}>
          <button className="btn sm ghost" onClick={(e)=>{ e.stopPropagation(); onToggleAssign(); }}
            style={{ padding:"2px 6px", fontSize: 10 }}>
            {isOpenAssign ? "Done" : "Assign"}
          </button>
          <button className="btn sm ghost danger" onClick={(e)=>{ e.stopPropagation(); onDelete(b.id); }}
            style={{ padding:"2px 6px", fontSize: 10 }}>Delete</button>
        </span>
      </div>

      {/* Question pills — clickable when results exist, sets the active card */}
      <div style={{ display:"flex", flexWrap:"wrap", gap: 4, marginBottom: 6 }}>
        {b.qids.length === 0 && <span className="pill amber" style={{ fontSize: 10 }}>unassigned</span>}
        {b.qids.map(qid => {
          const q = allQuestions.find(qq => qq.id === qid);
          if (!q) return null;
          const c2 = colorForQuestion(qid);
          const idx = results.findIndex(r => r.qid === qid);
          const hasResult = idx >= 0;
          const isActive = isSel && hasResult && idx === safeIdx;
          const failed = failures.some(f => f.qid === qid);
          return (
            <span key={qid} className="pill mono"
              onClick={(e) => {
                if (!hasResult) return;
                e.stopPropagation();
                setActiveIdx(idx);
                setSelectedBoxId(b.id, b);
              }}
              style={{
                fontSize: 10, color: c2, borderColor: c2,
                background: isActive ? `color-mix(in oklab, ${c2} 22%, transparent)` : "transparent",
                opacity: failed ? 0.5 : 1,
                cursor: hasResult ? "pointer" : "default",
                fontWeight: isActive ? 700 : 500,
              }}>
              Q{q.num}
              {failed && <span style={{ marginLeft: 4, color: "var(--red)" }}>!</span>}
            </span>
          );
        })}
      </div>

      <div className="mono muted" style={{ fontSize: 10.5 }}>
        x:{b.x.toFixed(1)} y:{b.y.toFixed(1)} · {b.w.toFixed(1)}×{b.h.toFixed(1)}
      </div>

      {/* Summary line */}
      {evalState && (
        <div style={{ marginTop: 6, fontSize: 11 }}>
          {evalState.status === "evaluating" ? (
            <span style={{ color: "var(--amber)" }}>
              Evaluating… <span className="mono">{results.length + failures.length}/{evalState.total ?? b.qids.length}</span>
            </span>
          ) : evalState.status === "error" ? (
            <span style={{ color: "var(--red)" }}>Error: {String(evalState.error).slice(0, 80)}</span>
          ) : (
            <span style={{ color: "var(--green)" }}>
              Evaluated · <span className="mono">{sumAwarded.toFixed(1)}/{Number(totalMax).toFixed(1)}</span>
              {results.length > 1 && <span className="muted"> · {results.length} question{results.length>1?"s":""}</span>}
              {failures.length > 0 && <span style={{ color:"var(--red)" }}> · {failures.length} failed</span>}
            </span>
          )}
        </div>
      )}

      {/* Carousel — only when selected and at least one result exists */}
      {isSel && activeResult && (
        <div onClick={(e) => e.stopPropagation()}
          style={{ marginTop: 10, padding: 10, background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 6 }}>
          {results.length > 1 && (
            <div style={{ display:"flex", alignItems:"center", gap: 6, marginBottom: 8 }}>
              <button className="btn sm ghost" disabled={safeIdx === 0}
                onClick={() => setActiveIdx(i => Math.max(0, i - 1))}
                style={{ padding:"2px 7px" }}>‹</button>
              <span className="mono muted" style={{ flex: 1, textAlign:"center", fontSize: 11 }}>
                Q{activeResult.num} · {safeIdx + 1} of {results.length}
              </span>
              <button className="btn sm ghost" disabled={safeIdx === results.length - 1}
                onClick={() => setActiveIdx(i => Math.min(results.length - 1, i + 1))}
                style={{ padding:"2px 7px" }}>›</button>
            </div>
          )}

          {(() => null)() /* the IIFE below pulls the active result's effective values for the renderer */}
          {(() => {
            // Effective per-criterion award (teacher override if present, else Gemini).
            const effAward = (cId, orig) =>
              evalEdits?.criteria && Object.prototype.hasOwnProperty.call(evalEdits.criteria, cId)
                ? Number(evalEdits.criteria[cId]) : Number(orig);
            const effFeedback = evalEdits?.feedback != null ? evalEdits.feedback : (activeResult.output.feedback || "");
            const sumAward = (activeResult.output.rubric_match || []).reduce(
              (a, c) => a + effAward(c.id, c.awarded), 0,
            );
            const isEdited = !!evalEdits && (
              evalEdits.feedback != null && evalEdits.feedback !== (activeResult.output.feedback || "")
              || Object.keys(evalEdits.criteria || {}).length > 0
            );
            const setCriterion = (cId, val) => {
              const max = Number((activeResult.output.rubric_match || []).find(c => c.id === cId)?.max ?? 0);
              const clamped = Math.max(0, Math.min(max, Number(val) || 0));
              setEdits(prev => ({
                ...prev,
                [activeResult.evaluation_id]: {
                  ...(prev[activeResult.evaluation_id] || {}),
                  criteria: { ...(prev[activeResult.evaluation_id]?.criteria || {}), [cId]: clamped },
                },
              }));
            };
            const setFeedback = (val) => {
              setEdits(prev => ({
                ...prev,
                [activeResult.evaluation_id]: {
                  ...(prev[activeResult.evaluation_id] || {}),
                  feedback: val,
                },
              }));
            };
            const resetEdits = () => {
              setEdits(prev => { const c = { ...prev }; delete c[activeResult.evaluation_id]; return c; });
              setSaveOk(null); setSaveErr(null);
            };
            const saveOverride = async () => {
              if (!activeResult.evaluation_id) { setSaveErr("No evaluation id"); return; }
              setSaveBusy(true); setSaveErr(null); setSaveOk(null);
              try {
                const final_criteria = (activeResult.output.rubric_match || []).map(c => ({
                  criterion_id: c.id,
                  awarded: effAward(c.id, c.awarded),
                }));
                await window.AppApi.post(`/evaluations/${activeResult.evaluation_id}/override`, {
                  final_score: Number(sumAward.toFixed(2)),
                  final_feedback: effFeedback,
                  final_criteria,
                  override_reason: "manual teacher edit",
                });
                // Replace this result's output locally so future renders use the new numbers.
                results[safeIdx] = {
                  ...activeResult,
                  output: {
                    ...activeResult.output,
                    proposed_score: sumAward,
                    feedback: effFeedback,
                    rubric_match: (activeResult.output.rubric_match || []).map(c => ({
                      ...c,
                      awarded: effAward(c.id, c.awarded),
                    })),
                  },
                };
                resetEdits();
                setSaveOk("Saved");
                setTimeout(() => setSaveOk(null), 2200);
              } catch (e) { setSaveErr(e?.message || String(e)); }
              setSaveBusy(false);
            };

            return (
              <>
                {/* Score for the active question — auto-recomputed from criteria */}
                <div style={{ display:"flex", justifyContent:"space-between", alignItems:"baseline",
                              padding:"6px 10px", background:"var(--bg-2)", borderRadius: 4, marginBottom: 8,
                              border: isEdited ? "1px solid var(--amber)" : "none" }}>
                  <span className="muted" style={{ fontSize: 10.5, textTransform:"uppercase", letterSpacing:".08em" }}>
                    Q{activeResult.num} score {isEdited && <span style={{ color:"var(--amber)" }}>· edited</span>}
                  </span>
                  <span className="mono" style={{ color: isEdited ? "var(--amber)" : "var(--green)", fontWeight: 600, fontSize: 13 }}>
                    {sumAward.toFixed(1)}/{Number(activeResult.output.max_marks ?? 0)}
                  </span>
                </div>

                {/* Confidence */}
                {activeResult.output.confidence != null && (
                  <div style={{ display:"flex", alignItems:"center", gap: 6, marginBottom: 8, fontSize: 11 }}>
                    <span className="muted">Confidence</span>
                    <div style={{ flex:1, height: 6, background:"var(--track)", borderRadius: 999, overflow:"hidden" }}>
                      <div style={{ width: `${Number(activeResult.output.confidence) * 100}%`, height:"100%",
                                    background: activeResult.output.confidence > 0.8 ? "var(--green)" : activeResult.output.confidence > 0.5 ? "var(--amber)" : "var(--red)" }}/>
                    </div>
                    <span className="mono" style={{ color:"var(--ink-1)" }}>{Math.round(Number(activeResult.output.confidence) * 100)}%</span>
                  </div>
                )}

                {/* Editable rubric */}
                {Array.isArray(activeResult.output.rubric_match) && activeResult.output.rubric_match.length > 0 && (
                  <>
                    <div className="ui-section-label" style={{ marginTop: 6, marginBottom: 4 }}>Rubric · editable</div>
                    {activeResult.output.rubric_match.map((c, i) => {
                      const award = effAward(c.id, c.awarded);
                      const dot = award === Number(c.max) ? "var(--green)" : award > 0 ? "var(--amber)" : "var(--red)";
                      const overridden = evalEdits?.criteria && Object.prototype.hasOwnProperty.call(evalEdits.criteria, c.id);
                      return (
                        <div key={c.id || i} style={{ padding:"5px 0", borderTop: i > 0 ? "1px solid var(--line-soft)" : "none" }}>
                          <div style={{ display:"flex", alignItems:"center", gap: 6, fontSize: 11 }}>
                            <span style={{ width: 6, height: 6, borderRadius:"50%", background: dot, flexShrink: 0 }}/>
                            <span style={{ flex: 1, color:"var(--ink-1)" }}>{c.desc || c.description}</span>
                            <input type="number" min={0} max={Number(c.max)} step={0.5}
                              value={award}
                              onChange={e => setCriterion(c.id, e.target.value)}
                              onClick={e => e.stopPropagation()}
                              style={{
                                width: 50, padding:"2px 4px", textAlign:"right",
                                background:"var(--bg-2)", border: `1px solid ${overridden ? "var(--amber)" : "var(--line-strong)"}`,
                                borderRadius: 3, color:"var(--ink-0)", fontFamily:"var(--font-mono)", fontSize: 11,
                              }}/>
                            <span className="mono muted" style={{ fontSize: 10.5, minWidth: 22 }}>/ {Number(c.max)}</span>
                          </div>
                          {c.evidence && (
                            <div className="muted" style={{ fontSize: 10.5, marginLeft: 12, marginTop: 3, fontStyle:"italic", lineHeight: 1.4 }}>
                              "{c.evidence}"
                            </div>
                          )}
                        </div>
                      );
                    })}
                  </>
                )}

                {/* Misconceptions (read-only for now) */}
                {Array.isArray(activeResult.output.misconceptions) && activeResult.output.misconceptions.length > 0 && (
                  <>
                    <div className="ui-section-label" style={{ marginTop: 8, marginBottom: 4 }}>Misconceptions</div>
                    {activeResult.output.misconceptions.map((m, i) => (
                      <div key={i} style={{ fontSize: 11, color:"var(--violet)", padding:"2px 0" }}>
                        <span className="pill violet" style={{ fontSize: 10 }}>{m.code}</span>
                        <span className="muted" style={{ marginLeft: 6 }}>{m.note}</span>
                      </div>
                    ))}
                  </>
                )}

                {/* Editable feedback */}
                <div className="ui-section-label" style={{ marginTop: 8, marginBottom: 4 }}>
                  Feedback · editable
                </div>
                <textarea
                  value={effFeedback}
                  onChange={e => setFeedback(e.target.value)}
                  onClick={e => e.stopPropagation()}
                  placeholder="Student-facing feedback for this question…"
                  style={{
                    width:"100%", minHeight: 64, padding:"6px 8px", boxSizing:"border-box",
                    background:"var(--bg-2)",
                    border:`1px solid ${evalEdits?.feedback != null && evalEdits.feedback !== (activeResult.output.feedback || "") ? "var(--amber)" : "var(--line-strong)"}`,
                    borderRadius: "var(--radius-ctl)", color:"var(--ink-0)", fontSize: 12.5, lineHeight: 1.5,
                    fontFamily: activeResult.output.feedback_lang === "bn" ? "var(--font-sans)" : "inherit",
                    resize:"vertical",
                  }}/>

                {/* Save / reset */}
                <div style={{ display:"flex", alignItems:"center", gap: 6, marginTop: 8 }}>
                  <span style={{ fontSize: 11, flex: 1 }}>
                    {saveErr  && <span style={{ color:"var(--red)" }}>{saveErr}</span>}
                    {saveOk   && <span style={{ color:"var(--green)" }}>{saveOk}</span>}
                    {!saveErr && !saveOk && isEdited && <span className="muted">Unsaved changes</span>}
                  </span>
                  <button className="btn sm ghost" disabled={!isEdited || saveBusy}
                    onClick={(e) => { e.stopPropagation(); resetEdits(); }}
                    style={{ padding:"2px 8px", fontSize: 11 }}>
                    Reset
                  </button>
                  <button className="btn sm primary" disabled={!isEdited || saveBusy}
                    onClick={(e) => { e.stopPropagation(); saveOverride(); }}
                    style={{ padding:"3px 10px", fontSize: 11 }}>
                    {saveBusy ? "Saving…" : "Save changes"}
                  </button>
                </div>
              </>
            );
          })()}

          {/* Transcription (collapsed by default for brevity) */}
          {activeResult.output.transcription && (
            <details style={{ marginTop: 8 }}>
              <summary className="ui-section-label" style={{ cursor:"pointer" }}>Transcription</summary>
              <div style={{ fontSize: 11, color:"var(--ink-2)", whiteSpace:"pre-wrap", marginTop: 4,
                            fontFamily: "var(--font-sans)", lineHeight: 1.5 }}>
                {activeResult.output.transcription}
              </div>
            </details>
          )}
        </div>
      )}

      {/* Assignment editor */}
      {isOpenAssign && (
        <div onClick={(e)=>e.stopPropagation()}
          style={{ marginTop: 8, padding: 8, background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 6, maxHeight: 200, overflow:"auto" }}>
          {allQuestions.map(q => {
            const on = b.qids.includes(q.id);
            return (
              <label key={q.id} style={{ display:"flex", alignItems:"center", gap: 6, padding:"3px 0", fontSize: 11.5, cursor:"pointer", color:"var(--ink-1)" }}>
                <input type="checkbox" checked={on}
                  onChange={() => onReassign(b.id, q.id, !on)}/>
                <span className="mono" style={{ color: colorForQuestion(q.id), fontWeight: 600 }}>Q{q.num}</span>
                <span style={{ fontSize: 10.5, color:"var(--ink-3)", flex:1, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
                  {q.stem?.slice(0, 60)}
                </span>
              </label>
            );
          })}
        </div>
      )}

      {/* Initial-evaluation actions — choose Gemini or manual rubric fill */}
      {!evalState && b.qids.length > 0 && (
        <div style={{ marginTop: 6, display:"flex", gap: 6 }}>
          <button className="btn sm primary"
            onClick={(e)=>{ e.stopPropagation(); onEvaluate(b.id); }}>
            <EVIcon name="sparkle" size={10}/> Send to Gemini
          </button>
          <button className="btn sm"
            title="Skip Gemini and fill in marks + feedback yourself"
            onClick={(e)=>{ e.stopPropagation(); onEvaluateManually(b.id); }}>
            ✎ Evaluate manually
          </button>
        </div>
      )}
    </div>
  );
};

const BBoxPanel = ({
  allQuestions, selectedQids, boxes, selectedBoxId, setSelectedBoxId,
  onDelete, onReassign, onSetTool, colorForQuestion, evalsByBox, onEvaluate, onEvaluateManually,
}) => {
  const [openAssignFor, setOpenAssignFor] = useState(null);

  // Group: one card per question that has ≥1 bbox. Multi-question bboxes show
  // up under each linked question. Unassigned bboxes (no qids) get their own
  // section at the bottom so the teacher can still see + assign them.
  const groups = allQuestions
    .map(q => ({ question: q, boxes: boxes.filter(b => b.qids.includes(q.id)) }))
    .filter(g => g.boxes.length > 0);
  const unassigned = boxes.filter(b => b.qids.length === 0);

  // Merge per-question result across every bbox linked to it — the backend
  // already aggregates all regions per question, so any one bbox's result is
  // the canonical one for that question.
  const evalStateForQuestion = (q, qBoxes) => {
    for (const b of qBoxes) {
      const ev = evalsByBox[b.id];
      const res = ev?.results?.find(r => r.qid === q.id);
      if (res) {
        return {
          status: ev.status,
          // QuestionEvalCard's BBoxRow lookalike uses `results[0]` as the single
          // displayed entry, so synthesise a one-element array for this question.
          results: [res],
          report: ev.report,
          failures: ev.failures,
        };
      }
      // No completed result yet; if a call is in flight, surface the evaluating state.
      if (ev?.status === "evaluating") return { status: "evaluating", results: [], total: 1 };
      if (ev?.status === "error")      return { status: "error", error: ev.error };
    }
    return undefined;
  };

  return (
    <div style={{ display:"flex", flexDirection:"column", height:"100%", overflow:"hidden" }}>
      <div style={{ padding:"14px 16px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:8 }}>
        <span className="cx-card-title" style={{ fontSize: 15 }}>Evaluations</span>
        <span className="muted" style={{ fontSize: 12.5 }}>by question</span>
        <span style={{ marginLeft:"auto" }}>
          <window.AppUI.StatusBadge tone="grey" icon={false}><span style={{ fontVariantNumeric:"tabular-nums" }}>{groups.length}q · {boxes.length} bbox{boxes.length===1?"":"es"}</span></window.AppUI.StatusBadge>
        </span>
      </div>

      {selectedQids.size > 0 && (
        <div style={{ padding:"12px 16px", borderBottom:"1px solid var(--line)", background:"var(--bg-2)" }}>
          <div className="ui-section-label" style={{ marginBottom: 8 }}>
            Drawing for {selectedQids.size} question{selectedQids.size>1?"s":""}
          </div>
          <div style={{ display:"flex", flexWrap:"wrap", gap: 4 }}>
            {[...selectedQids].slice(0, 12).map(qid => {
              const q = allQuestions.find(qq => qq.id === qid);
              if (!q) return null;
              const col = colorForQuestion(qid);
              return (
                <span key={qid} className="ui-badge tint grey" style={{ boxShadow: `inset 0 0 0 1px ${col}`, color: "var(--ink-0)" }}>
                  <span style={{ width: 6, height: 6, borderRadius: "50%", background: col }}/>Q{q.num}
                </span>
              );
            })}
            {selectedQids.size > 12 && <span className="muted" style={{ fontSize: 11 }}>+{selectedQids.size-12} more</span>}
          </div>
        </div>
      )}

      <div style={{ flex:1, overflow:"auto" }}>
        {groups.length === 0 && unassigned.length === 0 ? (
          <window.AppUI.EmptyState icon="dashedCircle" title="No bounding boxes yet" compact
            hint="Tick at least one question on the left, then drag on the PDF to create one."/>
        ) : (
          <>
            {groups.map(({ question, boxes: qBoxes }) => (
              <QuestionEvalCard
                key={question.id}
                question={question}
                boxes={qBoxes}
                allQuestions={allQuestions}
                selectedBoxId={selectedBoxId}
                setSelectedBoxId={setSelectedBoxId}
                onSetTool={onSetTool}
                onDelete={onDelete}
                onReassign={onReassign}
                onEvaluate={onEvaluate}
                onEvaluateManually={onEvaluateManually}
                colorForQuestion={colorForQuestion}
                evalState={evalStateForQuestion(question, qBoxes)}
                isOpenAssign={openAssignFor === question.id}
                onToggleAssign={() => setOpenAssignFor(openAssignFor === question.id ? null : question.id)}
              />
            ))}

            {unassigned.length > 0 && (
              <>
                <div className="ui-section-label" style={{ padding:"12px 16px 6px", background:"var(--bg-2)", borderTop:"1px solid var(--line)" }}>
                  Unassigned bboxes · {unassigned.length}
                </div>
                {unassigned.map(b => (
                  <BBoxRow key={b.id} b={b}
                    isSel={b.id === selectedBoxId}
                    isOpenAssign={openAssignFor === b.id}
                    onToggleAssign={() => setOpenAssignFor(openAssignFor === b.id ? null : b.id)}
                    setSelectedBoxId={setSelectedBoxId}
                    onSetTool={onSetTool}
                    onDelete={onDelete}
                    onReassign={onReassign}
                    onEvaluate={onEvaluate}
                    onEvaluateManually={onEvaluateManually}
                    allQuestions={allQuestions}
                    colorForQuestion={colorForQuestion}
                    evalState={evalsByBox[b.id]}/>
                ))}
              </>
            )}
          </>
        )}
      </div>
    </div>
  );
};

/* ---------- helpers ---------- */
const EvalDot = ({ ev }) => {
  if (!ev) return <span style={{ width:7, height:7, borderRadius:"50%", background:"var(--ink-4)" }}/>;
  if (ev.status === "evaluating") return <span style={{ width:7, height:7, borderRadius:"50%", background:"var(--accent)", animation:"pulse 1.2s ease-in-out infinite" }}/>;
  if (ev.decision === "approved") return <span style={{ color:"var(--green)", fontSize:11 }}>✓</span>;
  if (ev.decision === "regen") return <span style={{ color:"var(--amber)", fontSize: 11 }}>↻</span>;
  return <span style={{ width:7, height:7, borderRadius:"50%", background:"var(--blue)" }}/>;
};

const PipelineRail = ({ step, compact }) => {
  const stages = [
    { n: 1, label: "Paste text" },
    { n: 2, label: "Structure" },
    { n: 3, label: "Evaluate" },
  ];
  return (
    <div style={{ display:"flex", alignItems:"center", gap: 10, flexWrap:"wrap" }}>
      {!compact && (
        <>
          <div style={{ display:"flex", alignItems:"center", gap: 10 }}>
            <div style={{ width: 30, height: 30, borderRadius: "var(--radius-ctl)", background:"var(--primary)", display:"grid", placeItems:"center", color:"var(--primary-ink)" }}>
              <EVIcon name="rubric" size={16}/>
            </div>
            <div>
              <div style={{ color:"var(--ink-0)", fontSize: 14, fontWeight: 600, lineHeight: 1.1 }}>Paper Evaluation</div>
              <div style={{ fontSize: 12, color:"var(--ink-3)", marginTop: 2 }}>Dedicated pipeline</div>
            </div>
          </div>
          <div style={{ width: 1, height: 24, background:"var(--line)", margin:"0 6px" }}/>
        </>
      )}
      <div className="ui-tabs sm" role="list" aria-label="Pipeline steps" style={{ pointerEvents:"none" }}>
        {stages.map((s) => (
          <button key={s.n} type="button" role="listitem" tabIndex={-1}
            className={step === s.n ? "active" : ""} aria-current={step === s.n ? "step" : undefined}>
            {step > s.n
              ? <span style={{ color:"var(--green)", display:"inline-flex" }}><EVIcon name="checkCircle" size={14}/></span>
              : <span style={{
                  width: 16, height: 16, borderRadius: "50%", display:"inline-grid", placeItems:"center",
                  fontSize: 10.5, fontWeight: 600,
                  background: step === s.n ? "var(--primary)" : "transparent",
                  color: step === s.n ? "var(--primary-ink)" : "var(--ink-3)",
                  border: step === s.n ? "0" : "1px solid var(--line-strong)",
                }}>{s.n}</span>}
            {s.label}
          </button>
        ))}
      </div>
      <style>{`@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity: 0.3; } }`}</style>
    </div>
  );
};

/* ---------- Right panel: evaluation report ---------- */
const EvalReportPanel = ({ question, evalState, hasRegion, onUpdate, onRegenerate }) => {
  if (!evalState) {
    return (
      <div style={{ display:"flex", flexDirection:"column", height:"100%" }}>
        <div style={{ padding:"14px 16px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:8 }}>
          <EVIcon name="report" size={15}/>
          <span className="cx-card-title" style={{ fontSize: 15 }}>Evaluation report</span>
          <span style={{ marginLeft:"auto", fontSize: 10, color:"var(--ink-3)" }} className="mono">gemini-2.5-pro</span>
        </div>
        <div style={{ flex:1, display:"grid", placeItems:"center", padding: 24, textAlign:"center", color:"var(--ink-3)" }}>
          <div>
            <div style={{ fontSize: 32, marginBottom: 10, opacity: 0.4 }}><EVIcon name="report" size={36}/></div>
            <div style={{ color:"var(--ink-1)", fontSize: 13, fontWeight: 500, marginBottom: 6 }}>
              {hasRegion ? "Region ready" : "No region selected"}
            </div>
            <div style={{ fontSize: 12, lineHeight: 1.5, maxWidth: 240, margin:"0 auto" }}>
              {hasRegion
                ? "Click 'Send to Gemini' below the PDF to evaluate the selected region against Q" + question.num + "."
                : "Draw a bounding box on the PDF over the student's answer for Q" + question.num + "."}
            </div>
          </div>
        </div>
      </div>
    );
  }

  if (evalState.status === "evaluating") {
    return (
      <div style={{ display:"flex", flexDirection:"column", height:"100%" }}>
        <div style={{ padding:"14px 16px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:8 }}>
          <EVIcon name="report" size={15}/>
          <span className="cx-card-title" style={{ fontSize: 15 }}>Evaluating Q{question.num}</span>
        </div>
        <div style={{ flex:1, padding: 18, display:"flex", flexDirection:"column", gap: 14 }}>
          <div className="muted" style={{ fontSize: 12 }}>Gemini is reviewing the region against the rubric…</div>
          {["Cropping region from PDF…", "Transcribing handwriting…", "Matching against rubric criteria…", "Detecting misconceptions…", "Composing feedback…"].map((s, i) => (
            <div key={i} style={{ display:"flex", alignItems:"center", gap: 8, fontSize: 12 }}>
              <span style={{ width: 10, height: 10, border:"2px solid var(--line-strong)", borderTopColor:"var(--accent)", borderRadius:"50%", animation:"spin 0.9s linear infinite", display:"inline-block" }}/>
              <span style={{ color:"var(--ink-2)" }}>{s}</span>
            </div>
          ))}
          <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
        </div>
      </div>
    );
  }

  const r = evalState.report;
  const totalAwarded = r.rubric_match.reduce((a, c) => a + c.awarded, 0);
  const finalScore = evalState.marksOverride ?? totalAwarded;
  const isApproved = evalState.decision === "approved";

  return (
    <div style={{ display:"flex", flexDirection:"column", height:"100%", overflow:"hidden" }}>
      <div style={{ padding:"14px 16px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:8 }}>
        <EVIcon name="report" size={15}/>
        <span className="cx-card-title" style={{ fontSize: 15 }}>Evaluation report</span>
        <window.AppUI.StatusBadge tone="grey" icon={false}><span className="mono">{r.model}</span></window.AppUI.StatusBadge>
        <span style={{ marginLeft:"auto" }}>
          <button className="btn sm ghost" onClick={()=>onUpdate({ showJson: !evalState.showJson })} title="View raw JSON">
            <span className="mono" style={{ fontSize: 10 }}>{}</span>
          </button>
        </span>
      </div>
      <div style={{ flex:1, overflow:"auto", padding: 14, display:"flex", flexDirection:"column", gap: 14 }}>
        <div className="cx-card tight" style={{ borderColor: isApproved ? "color-mix(in srgb, var(--green-solid) 40%, transparent)" : undefined }}>
          <div className="ui-section-label" style={{ marginBottom: 6 }}>Final marks</div>
          <div style={{ display:"flex", alignItems:"baseline", gap: 4 }}>
            <input type="number" value={finalScore} step="0.5" min="0" max={r.max_marks}
              onChange={e => onUpdate({ marksOverride: parseFloat(e.target.value), decision: null })}
              style={{
                width: 80, padding: "4px 8px",
                background: "var(--surface)", border: "1px solid var(--line-strong)", borderRadius: "var(--radius-ctl)",
                color: "var(--ink-0)", fontSize: 22, fontVariantNumeric:"tabular-nums", fontWeight: 600, textAlign:"center",
              }}/>
            <span style={{ color:"var(--ink-3)", fontVariantNumeric:"tabular-nums", fontSize: 15 }}>/ {r.max_marks}</span>
            <span style={{ marginLeft: "auto", fontSize: 11, color:"var(--ink-3)" }}>
              proposed <span className="mono" style={{ color:"var(--ink-1)" }}>{r.proposed_score}</span>
            </span>
          </div>
          <div style={{ marginTop: 8, display:"flex", alignItems:"center", gap: 6, fontSize: 11 }}>
            <span className="muted">Model confidence</span>
            <div style={{ flex:1, height: 6, background:"var(--track)", borderRadius: 999, overflow:"hidden" }}>
              <div style={{ width: `${r.confidence * 100}%`, height: "100%", background: r.confidence > 0.8 ? "var(--green)" : r.confidence > 0.6 ? "var(--amber)" : "var(--red)" }}/>
            </div>
            <span className="mono" style={{ color:"var(--ink-1)" }}>{Math.round(r.confidence * 100)}%</span>
          </div>
        </div>

        <Section title="Transcribed answer">
          <div className="cx-tile" style={{ fontFamily:"var(--font-sans)", fontSize: 13, lineHeight: 1.55, color:"var(--ink-0)" }}>{r.transcription}</div>
        </Section>

        <Section title={`Rubric breakdown · ${totalAwarded}/${r.max_marks}`}>
          {r.rubric_match.map(c => (
            <div key={c.id} style={{ padding:"10px 12px", background:"var(--surface)", border:"1px solid var(--line)", borderRadius: "var(--radius-ctl)", marginBottom: 6 }}>
              <div style={{ display:"flex", alignItems:"center", gap: 8, marginBottom: 4 }}>
                <span style={{
                  width: 16, height: 16, borderRadius: 3, display:"grid", placeItems:"center", fontSize: 10,
                  background: c.status === "match" ? "var(--green-bg)" : c.status === "partial" ? "var(--amber-bg)" : "var(--bg-1)",
                  color: c.status === "match" ? "var(--green)" : c.status === "partial" ? "var(--amber)" : "var(--ink-3)",
                  border: `1px solid ${c.status === "match" ? "color-mix(in srgb, var(--green-solid) 30%, transparent)" : c.status === "partial" ? "color-mix(in srgb, var(--amber-solid) 30%, transparent)" : "var(--line)"}`,
                }}>{c.status === "match" ? "✓" : c.status === "partial" ? "~" : "—"}</span>
                <span style={{ flex: 1, fontSize: 12, color:"var(--ink-1)", lineHeight: 1.4 }}>{c.desc}</span>
                <span style={{ fontSize: 12.5, fontWeight: 600, fontVariantNumeric:"tabular-nums", color: c.awarded > 0 ? "var(--ink-0)" : "var(--ink-3)" }}>
                  {c.awarded}/{c.max}
                </span>
              </div>
              <div style={{ fontSize: 11, color:"var(--ink-3)", fontStyle:"italic", paddingLeft: 24 }}>{c.evidence}</div>
            </div>
          ))}
        </Section>

        {r.misconceptions.length > 0 && (
          <Section title="Misconceptions">
            {r.misconceptions.map(m => (
              <div key={m.code} className="ui-alert red" style={{ marginBottom: 6 }}>
                <div className="mono" style={{ fontSize: 10, color:"var(--red)", marginBottom: 2 }}>{m.code}</div>
                <div style={{ fontSize: 11.5, color:"var(--ink-1)" }}>{m.note}</div>
              </div>
            ))}
          </Section>
        )}

        <Section title="Feedback for student">
          <div className="cx-tile" style={{ fontFamily:"var(--font-sans)", fontSize: 13, color:"var(--ink-0)", lineHeight: 1.5 }}>
            {r.feedback_bn}
          </div>
        </Section>

        {evalState.showJson && (
          <Section title="Raw JSON">
            <pre style={{ fontSize: 10.5, color:"var(--ink-1)", background:"var(--bg-0)", padding: 10, borderRadius: 4, border:"1px solid var(--line)", maxHeight: 240, overflow:"auto", whiteSpace:"pre" }}>
              {JSON.stringify(r, null, 2)}
            </pre>
          </Section>
        )}
      </div>

      <div style={{ padding: 14, borderTop:"1px solid var(--line)", background:"var(--bg-1)" }}>
        <div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap: 8, marginBottom: 8 }}>
          <button className={`btn ${isApproved ? "success" : "primary"}`} onClick={()=>onUpdate({ decision: "approved" })}>
            <EVIcon name="check" size={15}/>Approve
          </button>
          <button className="btn" onClick={()=>{ onUpdate({ decision: "regen" }); onRegenerate(); }}>
            <EVIcon name="refresh" size={15}/>Regenerate
          </button>
        </div>
        <div style={{ display:"flex", gap: 6 }}>
          <button className="btn sm ghost" style={{ flex: 1 }} onClick={()=>onUpdate({ marksOverride: Math.max(0, finalScore - 0.5) })}>− 0.5</button>
          <button className="btn sm ghost" style={{ flex: 1 }} onClick={()=>onUpdate({ marksOverride: Math.min(r.max_marks, finalScore + 0.5) })}>+ 0.5</button>
          <button className="btn sm ghost" style={{ flex: 1 }} onClick={()=>onUpdate({ marksOverride: null, decision: null })}>Reset marks</button>
        </div>
      </div>
    </div>
  );
};

const Section = ({ title, children }) => (
  <div>
    <div className="ui-section-label" style={{ marginBottom: 8 }}>{title}</div>
    {children}
  </div>
);

/* ==================================================================
   Orchestrator
   ================================================================== */
const PaperEvaluation = ({ initialTest, onChangeTest, setScreen }) => {
  // Path 1 (manual): paste → structure → cockpit.
  // Path 2 (library handoff): EVAL_CONTEXT carries { test, student }; fetch the test's
  // saved structure + the student's uploaded PDF and jump straight to the cockpit.
  const ctx = window.APP.EVAL_CONTEXT;
  const [step, setStep] = useState(initialTest ? 3 : 1);
  const [text, setText] = useState(PASTE_SEED);
  const [structured, setStructured] = useState(null);
  const [pdfUrl, setPdfUrl] = useState(ctx?.test?.pdfUrl || initialTest?.pdfUrl || DEFAULT_PDF_URL);
  const [activeTest] = useState(ctx?.test || initialTest);
  const [activeStudent] = useState(ctx?.student || null);
  const [sheetId, setSheetId] = useState(null);
  const [rehydrate, setRehydrate] = useState(null);  // { boxes: [...], evaluations: [...] } from /answer-sheets/:id

  useEffect(() => {
    if (!activeTest?.id || !activeStudent) return;
    let cancelled = false;
    (async () => {
      try {
        const full = await window.AppApi.get(`/tests/${activeTest.id}/full`);
        if (cancelled) return;
        // Convert /full's nested shape into the cockpit's STRUCTURED_JSON shape.
        // Preserve the real UUID on each question (used by /api/evaluations).
        setStructured({
          test_id: full.meta?.id ?? null,
          sections: (full.sections || []).map(sec => ({
            section_id: sec.section_label,
            title: sec.title || `Section ${sec.section_label}`,
            marks_per_question: sec.marks_per_question ?? null,
            total_questions: sec.questions.length,
            section_total_marks: sec.questions.reduce((a, q) => a + Number(q.marks || 0), 0),
            questions: sec.questions.map(q => ({
              uuid: q.id,
              q_no: q.q_no, text: q.text, type: q.type, marks: Number(q.marks),
              options: (q.options || []).map(o => o.text),
              prerequisite_tags: q.concepts || [],
              sub_questions: (q.sub_questions || []).map(sq => ({ id: sq.id, text: sq.text, marks: sq.marks })),
            })),
          })),
          parsing_flags: [],
        });
        const roster = await window.AppApi.get(`/tests/${activeTest.id}/students`);
        const row = roster.find(r => r.student_id === activeStudent.student_id);
        if (row?.sheet_id) {
          setSheetId(row.sheet_id);
          setPdfUrl(`${window.AppApi.base}/answer-sheets/${row.sheet_id}/file`);
          // Pre-fetch any boxes and evaluations already saved for this sheet so the
          // cockpit reopens with the teacher's prior work intact.
          const [existingBoxes, existingEvals] = await Promise.all([
            window.AppApi.get(`/answer-sheets/${row.sheet_id}/boxes`).catch(() => []),
            window.AppApi.get(`/answer-sheets/${row.sheet_id}/evaluations`).catch(() => []),
          ]);
          if (!cancelled) {
            setRehydrate({ boxes: existingBoxes, evaluations: existingEvals });
          }
        }
      } catch (e) { console.error("[cockpit] load failed:", e); }
    })();
    return () => { cancelled = true; };
  }, [activeTest?.id, activeStudent?.student_id]);

  useEffect(() => () => { window.APP.EVAL_CONTEXT = null; }, []);

  if (step === 1) return <StepPaste text={text} setText={setText}
    onNext={()=>setStep(2)}
    onCancel={()=>setStep(1)}
    onSkipToCockpit={()=>setStep(3)}/>;
  if (step === 2) return <StepStructure rawText={text}
    structured={structured} setStructured={setStructured}
    onBack={()=>setStep(1)} onNext={()=>setStep(3)}/>;
  return <StepCockpit structured={structured || STRUCTURED_JSON}
    activeTest={activeTest}
    activeStudent={activeStudent}
    pdfUrl={pdfUrl}
    sheetId={sheetId}
    rehydrate={rehydrate}
    setScreen={setScreen}
    onBack={()=> initialTest ? onChangeTest?.() : setStep(2)}
    onCancel={()=>setStep(1)}/>;
};

window.PaperEvaluation = PaperEvaluation;
