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

const EVIcon = window.KXUI.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:"14px 24px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:14, background:"var(--bg-1)" }}>
        <PipelineRail step={1}/>
        <span style={{ marginLeft:"auto", display:"flex", gap:8 }}>
          <button className="btn sm" onClick={()=>setShowPrompt(true)}>
            <EVIcon name="sparkle" size={11}/> View parser prompt
          </button>
          <button className="btn sm" onClick={onCancel}>Cancel</button>
        </span>
      </div>

      <div style={{ flex:1, overflow:"auto", padding:"24px 32px", maxWidth: 980, margin:"0 auto", width:"100%" }}>
        <div style={{ marginBottom: 18 }}>
          <h2 style={{ color:"var(--ink-0)", margin:"0 0 4px", fontFamily:"'Instrument Serif', serif", fontWeight:400, fontSize: 30 }}>Paste the question paper</h2>
          <p style={{ color:"var(--ink-2)", margin:0, fontSize:13 }}>
            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 style={{ display:"flex", gap:8, marginBottom:10, alignItems:"center" }}>
          <span className="label" style={{ marginRight:"auto" }}>Question paper text</span>
          <select className="select" style={{ width:140, padding:"4px 10px", fontSize:11 }} defaultValue="auto">
            <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>
        </div>

        <textarea
          className="textarea"
          value={text}
          onChange={e=>setText(e.target.value)}
          spellCheck={false}
          style={{
            width:"100%", minHeight: 360, resize:"vertical",
            fontFamily:"'Hind Siliguri', 'IBM Plex Mono', monospace",
            fontSize: 14, lineHeight: 1.6,
            background:"var(--bg-1)", border:"1px solid var(--line)",
            borderRadius: 8, padding: "16px 18px", color:"var(--ink-0)",
          }}
          placeholder="Paste the full question paper here…"
        />

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

        <div style={{ marginTop: 24, padding: 14, background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 8 }}>
          <div style={{ fontSize: 11, color:"var(--ink-3)", textTransform:"uppercase", letterSpacing: ".08em", marginBottom:8, display:"flex", alignItems:"center", gap: 8 }}>
            <span>What Gemini extracts</span>
            <span className="pill mono" style={{ fontSize: 10 }}>schema v1</span>
            <button className="btn sm ghost" style={{ marginLeft:"auto", fontSize: 11 }} onClick={()=>setShowPrompt(true)}>See full prompt →</button>
          </div>
          <div style={{ display:"grid", gridTemplateColumns:"repeat(4, 1fr)", gap: 10, fontSize: 12 }}>
            {[
              ["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} style={{ padding:"8px 10px", background:"var(--bg-2)", borderRadius:6, border:"1px solid var(--line-soft)" }}>
                <div style={{ color:"var(--ink-0)", fontWeight:500, marginBottom:2 }}>{k}</div>
                <div style={{ color:"var(--ink-3)", fontSize:11 }}>{v}</div>
              </div>
            ))}
          </div>
        </div>
      </div>

      <div style={{ padding:"14px 32px", borderTop:"1px solid var(--line)", display:"flex", alignItems:"center", gap:10, background:"var(--bg-1)" }}>
        <span className="muted" style={{ fontSize:12 }}>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 →
            </button>
          )}
          <button className="btn primary" disabled={text.trim().length < 20} onClick={onNext} style={{ opacity: text.trim().length < 20 ? 0.5 : 1 }}>
            <EVIcon name="sparkle" size={12}/> Structure with Gemini  →
          </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 (
    <div onClick={onClose} style={{
      position:"fixed", inset: 0, background:"rgba(8,6,4,0.65)",
      display:"grid", placeItems:"center", zIndex: 200, padding: 32,
    }}>
      <div onClick={e=>e.stopPropagation()} style={{
        width: "min(880px, 100%)", maxHeight:"86vh", display:"flex", flexDirection:"column",
        background:"var(--bg-1)", border:"1px solid var(--line-strong)", borderRadius: 10,
        boxShadow:"0 24px 60px rgba(0,0,0,0.6)",
      }}>
        <div style={{ padding:"14px 18px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap: 10 }}>
          <span style={{ color:"var(--accent)" }}><EVIcon name="sparkle" size={15}/></span>
          <div>
            <div style={{ color:"var(--ink-0)", fontSize: 14, fontWeight: 600 }}>Paper structuring prompt</div>
            <div className="muted" style={{ fontSize: 11, marginTop: 2 }}>System prompt sent to Gemini before the pasted paper text</div>
          </div>
          <span className="pill mono" style={{ marginLeft: 6, fontSize: 10 }}>gemini-2.5-flash</span>
          <span className="pill mono" style={{ fontSize: 10 }}>{PARSER_SYSTEM_PROMPT.length.toLocaleString()} chars</span>
          <span style={{ marginLeft:"auto", display:"flex", gap: 6 }}>
            <button className="btn sm" onClick={copy}>{copied ? "✓ Copied" : "Copy"}</button>
            <button className="btn sm" onClick={onClose}>Close</button>
          </span>
        </div>
        <div style={{ flex:1, overflow:"auto", padding: 18 }}>
          <pre style={{
            margin: 0, fontFamily:"'IBM Plex Mono', monospace",
            fontSize: 11.5, lineHeight: 1.6, color:"var(--ink-1)",
            whiteSpace:"pre-wrap", wordBreak:"break-word",
          }}>{PARSER_SYSTEM_PROMPT}</pre>
        </div>
        <div style={{ padding:"10px 18px", borderTop:"1px solid var(--line)", fontSize: 11, color:"var(--ink-3)" }}>
          Editing the prompt is a workspace-admin action — request via Settings → Parsing.
        </div>
      </div>
    </div>
  );
};

/* ==================================================================
   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:"14px 24px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:14, background:"var(--bg-1)" }}>
        <PipelineRail step={2}/>
        <span style={{ marginLeft:"auto", display:"flex", gap:8 }}>
          <button className="btn sm" onClick={onBack}>← 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 style={{ color:"var(--ink-0)", fontFamily:"'Instrument Serif', serif", fontWeight:400, fontSize: 28, margin:"0 0 8px" }}>Structuring your paper</h2>
            <p style={{ color:"var(--ink-2)", margin:"0 0 24px" }}>Gemini is reading the text and converting it into the schema.</p>
            <div className="progress" style={{ height: 6, borderRadius: 4 }}>
              <div style={{ width: `${progress * 100}%`, transition:"width .35s ease" }}/>
            </div>
            <div style={{ marginTop: 16, display:"flex", flexDirection:"column", gap: 6, fontSize: 12, color:"var(--ink-3)", textAlign:"left", padding:"14px 18px", background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 8 }}>
              {["Detecting language", "Identifying sections", "Parsing questions", "Extracting MCQ options", "Tagging prerequisites", "Validating schema"].map((s, i) => (
                <div key={i} style={{ display:"flex", alignItems:"center", gap:8 }}>
                  <span style={{ width:14, color: progress * 6 > i ? "var(--green)" : "var(--ink-4)" }}>
                    {progress * 6 > i ? "✓" : "·"}
                  </span>
                  <span style={{ color: progress * 6 > i ? "var(--ink-1)" : "var(--ink-3)" }}>{s}</span>
                </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:"24px 32px", maxWidth: 1100, margin:"0 auto", width:"100%" }}>
        <div style={{ marginBottom: 18 }}>
          <div style={{ marginBottom: 12 }}>
            <h2 style={{ color:"var(--ink-0)", margin:"0 0 4px", fontFamily:"'Instrument Serif', serif", fontWeight:400, fontSize: 30 }}>Review structured paper</h2>
            <p style={{ color:"var(--ink-2)", margin:0, fontSize:13 }}>
              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" }}>
            <span className="pill mono">schema v1</span>
            <span className="pill mono">gemini-2.5-flash</span>
            <span className="pill"><span className="swatch" style={{ background:"var(--accent)" }}></span>{totalQs} questions · {totalMarks} marks</span>
            {errors.length > 0 && <span className="pill" style={{ background:"rgba(255,107,107,0.12)", borderColor:"rgba(255,107,107,0.3)", color:"var(--red)" }}>{errors.length} error{errors.length>1?"s":""}</span>}
            {warnings.length > 0 && <span className="pill amber">{warnings.length} warning{warnings.length>1?"s":""}</span>}
            <div className="seg" style={{ marginLeft:"auto" }}>
              <button className={view==="cards"?"active":""} onClick={()=>setView("cards")}>Cards</button>
              <button className={view==="json"?"active":""} onClick={()=>setView("json")}>JSON {dirty ? "•" : ""}</button>
            </div>
          </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} style={{
                  padding:"8px 12px", borderRadius: 6,
                  background: isErr ? "rgba(255,107,107,0.07)" : "rgba(246,181,59,0.07)",
                  border: `1px solid ${isErr ? "rgba(255,107,107,0.25)" : "rgba(246,181,59,0.25)"}`,
                  display:"flex", alignItems:"center", gap: 8, fontSize: 12,
                }}>
                  <span style={{ color: isErr ? "var(--red)" : "var(--amber)", fontWeight: 700, fontFamily:"'IBM Plex Mono', monospace", fontSize: 11 }}>
                    {isErr ? "ERROR" : "WARN "}
                  </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:"14px 32px", borderTop:"1px solid var(--line)", display:"flex", alignItems:"center", gap:10, background:"var(--bg-1)" }}>
        <span className="muted" style={{ fontSize:12 }}>Step 2 of 3 · Structured output</span>
        {dirty && view === "json" && (
          <span className="pill amber" style={{ fontSize: 10 }}>unsaved JSON edits</span>
        )}
        <span style={{ marginLeft:"auto", display:"flex", gap: 8 }}>
          <button className="btn" onClick={onReparse}>
            <EVIcon name="sparkle" size={11}/> Re-parse
          </button>
          <button className="btn primary" onClick={onNext} disabled={errors.length > 0 || (dirty && view === "json")}
            style={{ opacity: (errors.length > 0 || (dirty && view === "json")) ? 0.5 : 1 }}
            title={errors.length > 0 ? "Resolve parsing errors first" : dirty ? "Apply JSON edits first" : ""}>
            Open Evaluation cockpit →
          </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="card">
        <div className="card-head" style={{ gap: 10 }}>
          <span style={{ fontFamily:"'IBM Plex Mono', monospace", color:"var(--accent)", fontSize:12 }}>§{sec.section_id}</span>
          <span className="card-title">{sec.title}</span>
          <span className="pill mono" style={{ marginLeft:"auto", fontSize: 10 }}>
            {sec.marks_per_question != null ? `${sec.marks_per_question} mark${sec.marks_per_question>1?"s":""} × ${sec.total_questions}` : "mixed marks"}
          </span>
          <span className="pill" style={{ fontSize: 10 }}>{sec.section_total_marks} total</span>
        </div>
        <div style={{ padding: 0 }}>
          {sec.questions.map(q => (
            <div key={q.q_no} style={{ padding:"12px 14px", borderTop:"1px solid var(--line-soft)", display:"flex", gap: 12 }}>
              <div style={{ width: 36, textAlign:"right", color:"var(--accent)", fontWeight: 600, fontFamily:"'IBM Plex Mono', monospace" }}>
                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:"'Hind Siliguri', system-ui, sans-serif" }}>
                  {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" }}>
                  <span className="pill blue">{q.type}</span>
                  {(q.prerequisite_tags || []).map(c => (
                    <span key={c} className="pill mono" style={{ fontSize: 10 }}>{c}</span>
                  ))}
                </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: 11 }}>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 ghost" onClick={onFormat}>Format</button>
          <button className="btn sm ghost" onClick={onReset} disabled={!dirty} style={{ opacity: dirty ? 1 : 0.5 }}>Reset</button>
          <button className="btn sm primary" onClick={onApply} disabled={!dirty || !!jsonError}
            style={{ opacity: (!dirty || !!jsonError) ? 0.5 : 1 }}>
            ✓ Apply edits
          </button>
        </span>
      </div>
      <textarea
        value={jsonText}
        onChange={e => onChange(e.target.value)}
        spellCheck={false}
        style={{
          width:"100%", minHeight: "62vh", resize:"vertical",
          fontFamily:"'IBM Plex Mono', monospace", fontSize: 12, lineHeight: 1.55,
          background:"var(--bg-1)", border: `1px solid ${jsonError ? "rgba(255,107,107,0.5)" : dirty ? "var(--accent)" : "var(--line)"}`,
          borderRadius: 8, padding: 14, color:"var(--ink-0)",
          whiteSpace:"pre", tabSize: 2,
        }}
      />
      {jsonError ? (
        <div style={{ padding:"8px 12px", background:"rgba(255,107,107,0.08)", border:"1px solid rgba(255,107,107,0.3)", borderRadius: 6, fontSize: 12, color:"var(--red)", fontFamily:"'IBM Plex Mono', monospace" }}>
          ✕ Invalid JSON: {jsonError}
        </div>
      ) : dirty ? (
        <div style={{ padding:"8px 12px", background:"rgba(255,186,90,0.08)", border:"1px solid rgba(255,186,90,0.3)", borderRadius: 6, fontSize: 12, color:"var(--amber)" }}>
          • Unsaved edits — click Apply to commit before opening the cockpit.
        </div>
      ) : (
        <div className="muted" style={{ fontSize: 11 }}>
          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/" + encodeURIComponent("Tarak Sinha X Organic Chemistry.pdf");

/* Auto-generated palette for box colors derived from question index. */
const BOX_PALETTE = [
  "#3b7bf6", "#2fbe5f", "#f5318d", "#6b5bd2",
  "#e5484d", "#ff9f0a", "#0ea5e9", "#c026d3",
];
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.KXApi.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.KXApi.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.KXApi.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.KXApi.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.KXApi.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.KX.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:"10px 20px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:14, background:"var(--bg-1)" }}>
        <PipelineRail step={3}/>
        <span style={{ marginLeft: 18, color:"var(--ink-3)", fontSize:11 }}>·</span>
        {activeTest ? (
          <>
            <span className="mono" style={{ color:"var(--ink-3)", fontSize: 11 }}>{activeTest.id}</span>
            <span style={{ color:"var(--ink-0)", fontSize:13, fontWeight: 500 }}>{activeTest.title}</span>
            <span className="pill mono" style={{ fontSize:10 }}>{activeTest.className} · {activeTest.subject}</span>
            {activeStudent && (
              <span className="pill blue mono" style={{ fontSize: 10 }}>
                {activeStudent.roll_no} · {activeStudent.name}
              </span>
            )}
          </>
        ) : (
          <span style={{ color:"var(--ink-0)", fontSize:13, fontWeight: 500 }}>Tarak Sinha · Organic Chemistry</span>
        )}
        <span className="pill mono" style={{ fontSize:10 }}>{allQuestions.length} questions</span>
        <span className="pill mono" style={{ fontSize:10 }}>{boxes.length} bbox{boxes.length!==1?"es":""}</span>
        <span style={{ marginLeft:"auto", display:"flex", gap:8, alignItems:"center" }}>
          {topbarBusy && <span className="muted mono" style={{ fontSize: 10.5 }}>{topbarBusy}</span>}
          {topbarMsg && <span className="muted mono" style={{ fontSize: 10.5, color: topbarMsg.kind === "err" ? "var(--red)" : "var(--green)" }}>{topbarMsg.text}</span>}
          <button className="btn sm" onClick={saveDraft} disabled={!!topbarBusy}>
            {topbarBusy === "saving" ? "Saving…" : "Save evaluation"}
          </button>
          <button className="btn sm primary" onClick={finaliseReport} disabled={!!topbarBusy || !activeStudent}>
            {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:"#dcdce1" }}>
          <div style={{ padding:"8px 14px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:10, background:"var(--bg-1)", flexWrap:"wrap" }}>
            <div className="seg">
              <button className={tool==="draw"?"active":""} onClick={()=>setTool("draw")}>✎ Draw</button>
              <button className={tool==="select"?"active":""} onClick={()=>setTool("select")}>↖ 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="btn sm ghost" onClick={()=>setPageNum(p => Math.max(1, p-1))} disabled={pageNum<=1} style={{ padding:"4px 6px" }}>‹</button>
              <span className="mono muted" style={{ fontSize: 11, minWidth: 60, textAlign:"center" }}>
                {pageCount ? `${pageNum} / ${pageCount}` : "—"}
              </span>
              <button className="btn sm ghost" onClick={()=>setPageNum(p => Math.min(pageCount, p+1))} disabled={pageNum>=pageCount} style={{ padding:"4px 6px" }}>›</button>
            </span>

            <span style={{ display:"flex", alignItems:"center", gap: 4 }}>
              <button className="btn sm ghost" onClick={()=>setZoom(z => Math.max(0.5, +(z-0.15).toFixed(2)))} style={{ padding:"4px 6px" }}>−</button>
              <span className="mono muted" style={{ fontSize: 11, minWidth: 44, textAlign:"center" }}>{Math.round(zoom*100)}%</span>
              <button className="btn sm ghost" onClick={()=>setZoom(z => Math.min(3, +(z+0.15).toFixed(2)))} style={{ padding:"4px 6px" }}>+</button>
            </span>

            <span style={{ marginLeft:"auto", display:"flex", gap: 6, alignItems:"center" }}>
              <span className="muted" style={{ fontSize: 11 }}>
                {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:"#dcdce1" }}>
            <div style={{ position:"relative", width: renderSize.w, height: renderSize.h, boxShadow:"0 10px 30px rgba(0,0,0,0.5)", background:"#fff" }}>
              <canvas ref={canvasRef} style={{ display:"block", width: renderSize.w, height: renderSize.h }}/>
              {loadErr && (
                <div style={{ position:"absolute", inset:0, display:"grid", placeItems:"center", background:"rgba(20,16,12,0.85)", 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:"#999", fontFamily:"'IBM Plex Mono', monospace", 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) : "#888"}
                    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:"rgba(255,186,90,0.10)",
                    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 rgba(255,255,255,0.9), 0 0 2px rgba(255,255,255,0.9)",
                    }}>
                    {a.type === "tick"  && <span style={{ fontSize: 26, color:"#178a3a" }}>✓</span>}
                    {a.type === "cross" && <span style={{ fontSize: 26, color:"#c9281e" }}>✗</span>}
                    {a.type === "mark"  && (
                      <span style={{
                        fontSize: 13, padding:"2px 6px", borderRadius: 3,
                        background:"rgba(255,255,180,0.92)", color:"#3a2a00",
                        border:"1px solid rgba(180,140,0,0.55)",
                        fontFamily:"'Hind Siliguri', system-ui, sans-serif",
                      }}>{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={12}/>
            <span style={{ fontSize: 11.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)}>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={11}/> 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.KXApi.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:"12px 14px 10px", borderBottom:"1px solid var(--line)", position:"sticky", top:0, background:"var(--bg-1)", zIndex: 1 }}>
        <div style={{ fontSize: 11, color:"var(--ink-3)", textTransform:"uppercase", letterSpacing:".08em", marginBottom: 4 }}>Selection</div>
        <div style={{ color:"var(--ink-0)", fontSize: 13, fontWeight: 500, marginBottom: 6 }}>
          {selectedQids.size} of {selectableQs.length} unassigned · {allQuestions.length - selectableQs.length} done
        </div>
        <div style={{ display:"flex", gap: 6 }}>
          <button className="btn sm ghost" style={{ fontSize: 11 }}
            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 sm ghost" style={{ fontSize: 11 }}
            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:"9px 14px",
                fontSize: 10.5, color:"var(--ink-2)", letterSpacing:".08em", textTransform:"uppercase",
                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-soft)",
              }}>
              <span className={`chk ${triState==="on"?"on":""}`} style={triState==="some" ? { background:"var(--accent)", opacity: 0.5 } : undefined}/>
              <span className="mono" style={{ color:"var(--accent)" }}>§{section.section_id}</span>
              <span>{section.title}</span>
              <span style={{ marginLeft:"auto", color:"var(--ink-3)" }} className="mono">
                {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:"9px 14px",
                    borderLeft: `3px solid ${isSel ? col : "transparent"}`,
                    background: isSel ? "var(--bg-2)" : "transparent",
                    cursor: (locked && !isSel) ? "default" : "pointer",
                    opacity: locked && !isSel ? 0.55 : 1,
                    borderBottom: "1px solid var(--line-soft)",
                    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 className="mono" style={{ color: isSel ? col : "var(--ink-2)", fontSize: 12, fontWeight: 600, textDecoration: locked && !isSel ? "line-through" : "none" }}>Q{q.num}</span>
                      <span className="pill" style={{ fontSize: 10, padding:"1px 5px" }}>{q.marks}m</span>
                      {cnt > 0 && (
                        <span className="pill mono" style={{ fontSize: 9.5, padding:"1px 5px", background: "var(--bg-3)", color: col, borderColor: 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: 18, height: 18, padding: 0,
                            border: `1px solid ${isSel ? col : "var(--line-strong)"}`,
                            background: isSel ? `color-mix(in oklab, ${col} 20%, transparent)` : "var(--bg-2)",
                            color: isSel ? col : "var(--ink-2)",
                            borderRadius: 4, cursor:"pointer", fontSize: 11, lineHeight: 1,
                          }}>
                          {isSel ? "−" : "+"}
                        </button>
                      )}
                    </div>
                    <div style={{ fontSize: 12, color: isSel ? "var(--ink-0)" : "var(--ink-2)", lineHeight: 1.4, fontFamily:"'Hind Siliguri', system-ui, sans-serif",
                      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:"'Hind Siliguri', system-ui, sans-serif",
                            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:"10px 14px", fontSize: 11 }}>No questions in this section.</div>
            )}
          </div>
        );
      })}

      {allQuestions.length === 0 && (
        <div style={{ padding: 20, textAlign:"center" }} className="muted">
          <div style={{ fontSize: 12, marginBottom: 6 }}>No structured questions yet.</div>
          <div style={{ fontSize: 11 }}>You can still draw bboxes — they'll be saved as unassigned.</div>
        </div>
      )}
    </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}1f`,
    cursor: tool === "select" ? (selected ? "move" : "pointer") : "default",
    boxShadow: selected ? `0 0 0 2px rgba(255,255,255,0.18), 0 0 0 4px ${color}80` : "none",
  };
  return (
    <div style={style}
      onClick={onClickBox}
      onMouseDown={selected && tool === "select" ? onMoveStart : undefined}>
      <span style={{
        position:"absolute", top: -18, left: -2,
        background: color, color:"#fff",
        fontSize: 10, fontWeight: 700, padding: "1px 6px", borderRadius: 3,
        fontFamily:"'IBM Plex Mono', monospace", 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: "#fff",
                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:"#fff",
              width: 20, height: 20, borderRadius: "50%",
              fontSize: 12, lineHeight: 1, fontWeight: 700,
              boxShadow:"0 2px 6px rgba(0,0,0,0.4)",
              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:"10px 14px", borderBottom:"1px solid var(--line-soft)" }}>
        <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: 700 }}>Q{qLabel(question)}</span>
          <span className="pill mono" style={{ fontSize: 9.5 }}>{question.marks}m</span>
          <span style={{ flex: 1, fontSize: 11, color:"var(--ink-2)", overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap", fontFamily:"'Hind Siliguri', system-ui, sans-serif" }}>
            {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" && (
          <div style={{ fontSize: 11, color:"var(--amber)" }}>Evaluating…</div>
        )}
        {evalState?.status === "error" && (
          <div style={{ fontSize: 11, color:"var(--red)" }}>Error: {String(evalState.error).slice(0, 100)}</div>
        )}
        {!evalState && (
          <div style={{ display:"flex", gap: 6 }}>
            <button className="btn sm primary" style={{ padding:"3px 8px", fontSize: 11 }}
              onClick={(e)=>{ e.stopPropagation(); onEvaluate(primaryBox.id); }}>
              <EVIcon name="sparkle" size={10}/> Send to Gemini · {boxes.length} bbox{boxes.length>1?"es":""}
            </button>
            <button className="btn sm" style={{ padding:"3px 8px", fontSize: 11 }}
              title="Skip Gemini and fill in marks + feedback yourself"
              onClick={(e)=>{ e.stopPropagation(); onEvaluateManually(primaryBox.id); }}>
              ✎ 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.KXApi.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:"10px 14px", borderBottom:"1px solid var(--line-soft)",
                  borderLeft: isEdited ? `3px solid var(--amber)` : `3px solid ${expanded ? col : "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 className="mono" style={{ color:"var(--ink-0)", fontSize: 12, fontWeight: 700, minWidth: 32 }}>Q{qLabel(question)}</span>
        <span className="pill mono" style={{ fontSize: 9.5, padding:"1px 5px" }}>{question.marks}m</span>
        {isMcq && <span className="pill mono" style={{ fontSize: 9.5, padding:"1px 5px", color:"var(--blue)", borderColor:"rgba(106,168,255,0.3)" }}>MCQ</span>}

        {/* verdict + score */}
        <span style={{ marginLeft: "auto", display:"inline-flex", alignItems:"center", gap: 6,
                       padding:"3px 8px", borderRadius: 12,
                       background: `color-mix(in oklab, ${verdict.color} 14%, transparent)`,
                       border: `1px solid ${verdict.color}` }}>
          <span style={{ color: verdict.color, fontWeight: 700, fontSize: 12, lineHeight: 1 }}>{verdict.icon}</span>
          <span className="mono" style={{ color: verdict.color, fontWeight: 700, fontSize: 12 }}>
            {sumAward.toFixed(1)}/{Number(result.output.max_marks ?? 0)}
          </span>
        </span>
        {isEdited && <span className="pill amber" style={{ fontSize: 9.5, padding:"1px 5px" }}>edited</span>}
      </div>

      {/* Stem — single-line preview always visible under the header */}
      <div style={{ marginLeft: 28, marginTop: 4, fontSize: 11, color:"var(--ink-2)",
                    overflow:"hidden", textOverflow:"ellipsis",
                    display: "-webkit-box", WebkitLineClamp: expanded ? "unset" : 1, WebkitBoxOrient:"vertical",
                    fontFamily:"'Hind Siliguri', system-ui, sans-serif", 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} style={{ padding:"2px 6px", fontSize: 10 }}>
              {isOpenAssign ? "Done" : "Re-assign"}
            </button>
          </div>

          {/* Re-assign drawer */}
          {isOpenAssign && (
            <div style={{ marginBottom: 8, padding: 8, background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 6, maxHeight: 200, 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: 4, background:"var(--bg-3)", borderRadius: 4, 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="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", 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: 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:"'IBM Plex Mono', monospace", 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>
            );
          })}
        </>
      )}

      {/* Feedback */}
      <div className="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", 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: 60, padding:"6px 8px", boxSizing:"border-box",
          background:"var(--bg-2)",
          border:`1px solid ${evalEdits?.feedback != null && evalEdits.feedback !== (result.output.feedback || "") ? "var(--amber)" : "var(--line-strong)"}`,
          borderRadius: 4, color:"var(--ink-0)", fontSize: 11.5, lineHeight: 1.5,
          fontFamily: result.output.feedback_lang === "bn" ? "'Hind Siliguri', serif" : "inherit",
          resize:"vertical",
        }}/>

      {/* Transcription */}
      {result.output.transcription && (
        <details style={{ marginTop: 6 }}>
          <summary className="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", cursor:"pointer" }}>Transcription</summary>
          <div style={{ fontSize: 11, color:"var(--ink-2)", whiteSpace:"pre-wrap", marginTop: 4,
                        fontFamily: "'Hind Siliguri', system-ui, sans-serif", 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 }}>
          ↻ 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) : "#888";
  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="pill mono" style={{ fontSize: 9.5 }}>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.KXApi.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: 4, background:"var(--bg-3)", borderRadius: 4, 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="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", 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:"'IBM Plex Mono', monospace", 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="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", 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="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", 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: 4, color:"var(--ink-0)", fontSize: 11.5, lineHeight: 1.5,
                    fontFamily: activeResult.output.feedback_lang === "bn" ? "'Hind Siliguri', serif" : "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="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", cursor:"pointer" }}>Transcription</summary>
              <div style={{ fontSize: 11, color:"var(--ink-2)", whiteSpace:"pre-wrap", marginTop: 4,
                            fontFamily: "'Hind Siliguri', system-ui, sans-serif", 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" style={{ padding:"3px 8px", fontSize: 11 }}
            onClick={(e)=>{ e.stopPropagation(); onEvaluate(b.id); }}>
            <EVIcon name="sparkle" size={10}/> Send to Gemini
          </button>
          <button className="btn sm" style={{ padding:"3px 8px", fontSize: 11 }}
            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:"12px 14px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:8 }}>
        <EVIcon name="report" size={13}/>
        <span style={{ color:"var(--ink-0)", fontWeight:600, fontSize: 13 }}>Evaluations · by question</span>
        <span className="pill mono" style={{ fontSize: 10, marginLeft:"auto" }}>{groups.length}q · {boxes.length} bbox{boxes.length===1?"":"es"}</span>
      </div>

      {selectedQids.size > 0 && (
        <div style={{ padding:"10px 14px", borderBottom:"1px solid var(--line)", background:"var(--bg-2)" }}>
          <div className="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", marginBottom: 6 }}>
            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="pill mono" style={{ fontSize: 10, color: col, borderColor: 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 ? (
          <div style={{ padding: 20, color:"var(--ink-3)", fontSize: 12, textAlign:"center" }}>
            <div style={{ fontSize: 26, marginBottom: 8, opacity: 0.35 }}>▢</div>
            <div style={{ color:"var(--ink-1)", fontSize: 12.5, fontWeight: 500, marginBottom: 4 }}>No bounding boxes yet</div>
            <div style={{ fontSize: 11.5, lineHeight: 1.5 }}>
              Tick at least one question on the left, then drag on the PDF to create one.
            </div>
          </div>
        ) : (
          <>
            {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 style={{ padding:"10px 14px 4px", fontSize: 10, color:"var(--ink-3)", letterSpacing:".08em", textTransform:"uppercase", 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 }) => {
  const stages = [
    { n: 1, label: "Paste text" },
    { n: 2, label: "Structure" },
    { n: 3, label: "Evaluate" },
  ];
  return (
    <div style={{ display:"flex", alignItems:"center", gap: 10 }}>
      <div style={{ display:"flex", alignItems:"center", gap: 8 }}>
        <div style={{ width: 24, height: 24, borderRadius: 6, background:"var(--ink-btn)", display:"grid", placeItems:"center", color:"#fff", fontWeight: 700, fontSize: 12 }}>P</div>
        <div>
          <div style={{ color:"var(--ink-0)", fontSize: 13, fontWeight: 600, lineHeight: 1 }}>Paper Evaluation</div>
          <div style={{ fontSize: 10, color:"var(--ink-3)", letterSpacing:".06em", textTransform:"uppercase", marginTop: 3 }}>dedicated pipeline</div>
        </div>
      </div>
      <div style={{ width: 1, height: 24, background:"var(--line)", margin:"0 6px" }}/>
      {stages.map((s, i) => (
        <React.Fragment key={s.n}>
          {i > 0 && <span style={{ color: step > i ? "var(--accent)" : "var(--ink-4)", fontSize: 12 }}>→</span>}
          <div style={{
            display:"flex", alignItems:"center", gap:6, padding:"4px 10px", borderRadius: 14,
            background: step === s.n ? "var(--bg-3)" : "transparent",
            border:`1px solid ${step === s.n ? "var(--line-strong)" : "transparent"}`,
          }}>
            <span style={{
              width: 16, height: 16, borderRadius: "50%",
              background: step > s.n ? "var(--green)" : step === s.n ? "var(--accent)" : "var(--bg-3)",
              color: step >= s.n ? "#fff" : "var(--ink-3)",
              display:"grid", placeItems:"center", fontSize: 10, fontWeight: 700,
              fontFamily:"'IBM Plex Mono', monospace",
            }}>{step > s.n ? "✓" : s.n}</span>
            <span style={{ fontSize: 12, color: step === s.n ? "var(--ink-0)" : "var(--ink-3)", fontWeight: step === s.n ? 600 : 400 }}>
              {s.label}
            </span>
          </div>
        </React.Fragment>
      ))}
      <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:"12px 14px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:8 }}>
          <EVIcon name="report" size={13}/>
          <span style={{ color:"var(--ink-0)", fontWeight:600, fontSize: 13 }}>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:"12px 14px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:8 }}>
          <EVIcon name="report" size={13}/>
          <span style={{ color:"var(--ink-0)", fontWeight:600, fontSize: 13 }}>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:"12px 14px", borderBottom:"1px solid var(--line)", display:"flex", alignItems:"center", gap:8 }}>
        <EVIcon name="report" size={13}/>
        <span style={{ color:"var(--ink-0)", fontWeight:600, fontSize: 13 }}>Evaluation report</span>
        <span className="pill mono" style={{ fontSize: 10 }}>{r.model}</span>
        <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 style={{ padding: 14, background:"var(--bg-2)", border:`1px solid ${isApproved ? "rgba(75,201,123,0.4)" : "var(--line-strong)"}`, borderRadius: 8 }}>
          <div className="muted" style={{ fontSize: 10, textTransform:"uppercase", letterSpacing:".08em", marginBottom: 4 }}>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: 76, padding: "4px 8px",
                background: "var(--bg-1)", border: "1px solid var(--line-strong)", borderRadius: 5,
                color: "var(--ink-0)", fontSize: 22, fontFamily:"'IBM Plex Mono', monospace", fontWeight: 600, textAlign:"center",
              }}/>
            <span style={{ color:"var(--ink-3)", fontFamily:"'IBM Plex Mono', monospace", 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: 4, background:"var(--bg-3)", borderRadius: 4, 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 style={{ padding:"10px 12px", background:"var(--bg-2)", border:"1px solid var(--line)", borderRadius: 6,
            fontFamily:"'Hind Siliguri', serif", 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:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--line-soft)", borderRadius: 6, 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" ? "rgba(75,201,123,0.3)" : c.status === "partial" ? "rgba(246,181,59,0.3)" : "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 className="mono" style={{ fontSize: 11, 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} style={{ padding:"8px 10px", background:"rgba(255,107,107,0.06)", border:"1px solid rgba(255,107,107,0.2)", borderRadius: 6, 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 style={{ padding:"10px 12px", background:"var(--bg-2)", border:"1px solid var(--line)", borderRadius: 6, fontFamily:"'Hind Siliguri', serif", 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: 12, borderTop:"1px solid var(--line)", background:"var(--bg-2)" }}>
        <div style={{ display:"grid", gridTemplateColumns:"1fr 1fr", gap: 6, marginBottom: 6 }}>
          <button className={`btn sm ${isApproved ? "success" : ""}`} onClick={()=>onUpdate({ decision: "approved" })}>
            <EVIcon name="check" size={12}/> Approve
          </button>
          <button className="btn sm" onClick={()=>{ onUpdate({ decision: "regen" }); onRegenerate(); }}>
            <EVIcon name="sparkle" size={11}/> 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 style={{ fontSize: 10, color:"var(--ink-3)", textTransform:"uppercase", letterSpacing:".08em", marginBottom: 6, fontWeight: 500 }}>{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.KX.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.KXApi.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.KXApi.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(`/api/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.KXApi.get(`/answer-sheets/${row.sheet_id}/boxes`).catch(() => []),
            window.KXApi.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.KX.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;
