// Surface 6 — Assessments
//
// Authors the pre-/post-assessment. State lives in `courseSettings.assessments`
// (owned by app.jsx, persisted to IndexedDB with the rest of the draft, mapped
// to the server by `buildCourseSettings`), so nothing here is lost on
// navigation and everything visible reaches the exported ZIP.
//
// Shape — mirrors `AssessmentsSchema` in @dynamo/schema field-for-field, with
// ONE deliberate difference: a group's `moduleId` holds the FE's string module
// id ('M1'), because that is what the UI works with. `buildCourseSettings`
// resolves it to the numeric `ModuleSchema.id` at export. Storing the number
// here would break the moment a module is renumbered.
//
//   assessments = {
//     randomiseGroupOrder: bool,          // ONE course-wide flag (see below)
//     pre:  { enabled, groups: [...] },
//     post: { enabled, groups: [...] },
//   }
//   group    = { moduleId, questionsShown, questions: [...], uid }
//   question = { prompt, answers: [...], correctFeedback, wrongFeedback, uid }
//   answer   = { text, correct, wrongFeedback, uid }
//
// `uid` is FE-only (a stable React key). `buildCourseSettings` builds a clean
// object and never forwards it.
//
// WHAT THIS SURFACE DELIBERATELY DOES NOT OFFER, and why — each verified
// against the pinned Player, not inferred:
//
//  - **Mode / Pass mark.** The runtime's threshold mode still reports
//    `cmi.success_status = "passed"` to the LMS on the FAIL path
//    (`scripts.js:8824` — it never writes "failed" anywhere), and it silently
//    disables module filtering (`scripts.js:1113`). All five reference
//    packages ship completion mode. Offering a pass mark would be a
//    compliance gate that does not gate.
//  - **Background image.** Only applied when `fancyAssessment` is on AND it
//    needs a path-prefix class (`content/` with no language segment) the
//    packager cannot express. Nothing would be bundled.
//  - **Per-question / per-answer images.** The pinned runtime parses answer
//    images but the line that DRAWS them is commented out
//    (`scripts.js:4431`) — an author would upload and see nothing.
//  - ~~**AI "Generate questions".**~~ **REAL as of 2026-08-10.** The old panel
//    fabricated plausible question text behind a fake 700 ms pause and was removed
//    for it. `AssessmentGeneratePanel` below is the genuine article: it POSTs the
//    module's own authored text to `POST /v1/generate-questions`, which calls
//    OpenAI with the pinned prompt at `prompts/generate/question_generator.md`.
//    Every returned question must satisfy the four-answer shape
//    (wiki/concepts/four-answer-shape.md) or it is DROPPED and counted, twice —
//    once in the route, once here before the merge — because `PUT /draft` is
//    all-or-nothing and one malformed question would 400 the whole course.
//    Generation writes the DEFAULT language only, per Omar's decision; the
//    existing Translate flow fills the rest.
//  - **A per-learner shuffle switch.** The Player ALWAYS draws the K questions
//    at random, per learner, per attempt (`scripts.js:9271-9276`) — there is
//    no flag to turn it off. An "off" switch would be the false affordance.
//    The Show-K-of-N hint says so in words instead.
//  - **Per-question roles.** One `default` role exists today; role scoping is
//    campaign Stage 5.
//
// `randomiseGroupOrder` is course-wide on purpose: despite being emitted as
// `<postAssessmentRand>`, the Player applies it to BOTH assessments (the
// shuffle sits in the generator shared by pre and post, `scripts.js:7019`).

function SurfaceAssessments({ course, settings, setSettings, onNavigate, layoutDrafts }) {
  const [tab, setTab] = React.useState('pre');
  const modules = (course && course.modules) || [];
  const assessments = (settings && settings.assessments) || ASSESSMENT_EMPTY;
  const phase = assessments[tab] || { enabled: false, groups: [] };

  // One writer for the whole slice. Every mutation below funnels through it,
  // so there is exactly one place where assessment state reaches app.jsx.
  const patchAssessments = React.useCallback((fn) => {
    setSettings(prev => {
      const current = (prev && prev.assessments) || ASSESSMENT_EMPTY;
      return { ...prev, assessments: fn(current) };
    });
  }, [setSettings]);

  const patchPhase = React.useCallback((which, fn) => {
    patchAssessments(a => {
      const p = a[which] || { enabled: false, groups: [] };
      return { ...a, [which]: fn(p) };
    });
  }, [patchAssessments]);

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg)' }}
         data-screen-label="Surface 6 · Assessments">

      {/* The shared page header (2026-08-14). This screen used to be a 52px bar with
          a 14px title and no sentence — its own idea of a header, on a screen that
          sits beside five others. `SurfaceHeader` is the one style now. */}
      {/* SHORT, because this header carries more than the others: a save door AND
          the pre/post switcher. The first sentence written here was the same length
          as Content mapping's and rendered with an ellipsis — measured, not
          guessed. The room a description has is a property of the screen, not of
          the component. */}
      <window.SurfaceHeader title="Assessments"
        description={<>Questions asked before the course and after it.</>}>
        <AssessmentSaveButton dirtySignal={JSON.stringify(assessments)} />
        <div style={{
          display: 'flex', gap: 2, padding: 3,
          background: 'var(--surface-inset)', borderRadius: 'var(--radius)',
          border: '1px solid var(--border)',
        }}>
          <TabBtn active={tab === 'pre'} onClick={() => setTab('pre')}>
            Pre-assessment{assessments.pre?.enabled ? ' ·' : ''}
          </TabBtn>
          <TabBtn active={tab === 'post'} onClick={() => setTab('post')}>
            Post-assessment{assessments.post?.enabled ? ' ·' : ''}
          </TabBtn>
        </div>
      </window.SurfaceHeader>

      <div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px 32px',
        display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 340px', gap: 20 }}>

        {/* Main column */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
          <AssessmentPhaseCard
            which={tab} phase={phase}
            randomise={assessments.randomiseGroupOrder === true}
            onToggleEnabled={(v) => patchPhase(tab, p => ({ ...p, enabled: v }))}
            onToggleRandomise={(v) => patchAssessments(a => ({ ...a, randomiseGroupOrder: v }))} />

          {/* AI generation, on BOTH tabs: it writes into whichever assessment is
              open, which is what "generate the questions" means when you are
              looking at one of them. */}
          <AssessmentGeneratePanel
            which={tab} course={course} modules={modules}
            layoutDrafts={layoutDrafts}
            defaultLang={(settings && settings.metadata && settings.metadata.defaultLanguage) || 'en'}
            onApply={(perModule, mode) => patchPhase(tab, p => {
              const groups = (p.groups || []).map(g => g);
              perModule.forEach(({ moduleId, questions }) => {
                // Questions arrive already shaped and already filtered twice; the
                // only thing left is to give them the FE-only `uid` React needs.
                const withUids = questions.map(q => ({
                  ...q, uid: window.assessmentOpsUid('q'),
                  answers: (q.answers || []).map(a => ({ ...a, uid: window.assessmentOpsUid('a') })),
                }));
                let at = -1;
                for (let i = 0; i < groups.length; i++) {
                  if (groups[i].moduleId === moduleId) { at = i; break; }
                }
                if (at === -1) {
                  groups.push({
                    uid: window.assessmentOpsUid('g'), moduleId,
                    questionsShown: Math.min(withUids.length, Math.max(1, withUids.length)),
                    questions: withUids,
                  });
                  return;
                }
                // His rule: all-modules REPLACES, one module ADDS.
                const kept = mode === 'replace' ? [] : (groups[at].questions || []);
                const next = kept.concat(withUids);
                groups[at] = { ...groups[at], questions: next,
                  // Re-clamp: a group whose pool shrank under a replace would
                  // otherwise keep a K larger than the pool.
                  questionsShown: Math.max(1, Math.min(
                    groups[at].questionsShown || 1, next.length)) };
              });
              return { ...p, groups };
            })} />

          {/* Omar, 2026-08-10: "Once the pre-assessment is done, there has to be
              a button which enable the Admin to reflect the same questions also
              on the post-assessment." So it lives on the PRE tab, where he
              described reaching for it. The safety is in the dialog, which states
              what it would overwrite before anything happens. */}
          {tab === 'pre' && (
            <AssessmentCopyToPostBar
              assessments={assessments} modules={modules}
              onCopy={(mode, moduleIds, alsoEnable) => patchAssessments(a => {
                const next = window.copyPreToPost(a, mode, moduleIds);
                return alsoEnable
                  ? { ...next, post: { ...next.post, enabled: true } }
                  : next;
              })} />
          )}

          <AssessmentGroupList
            which={tab} phase={phase} modules={modules}
            onPatchPhase={(fn) => patchPhase(tab, fn)} />
        </div>

        {/* Right sidebar */}
        <aside style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
          <AssessmentResultScreenNote which={tab} onNavigate={onNavigate} />
        </aside>
      </div>
    </div>
  );
}

// Save to the server. The debounced autosave only writes IndexedDB, and the
// only other caller of `dynamoSaveDraftToServer` is the layout editor's Save —
// so an author who wrote a whole assessment and never opened a layout had
// nothing on the server until they pressed Build. That is fine until they
// switch machine or clear the browser.
// Now a thin wrapper over the ONE shared implementation (`components.jsx`
// `DraftSaveButton`). It used to be its own hand-written copy, near-identical to
// `LocSaveToServer` in surface-localisation.jsx — and when two more screens
// needed the same control on 2026-08-11, a hand-copied fourth would have been how
// four buttons quietly stopped behaving alike. `dirtySignal` is new here: the old
// copy had none, so its green "Saved" persisted over questions edited afterwards.
function AssessmentSaveButton({ dirtySignal }) {
  // `idleLabel={null}` was passed explicitly here while the shared default was
  // "Saved on this device"; the default is now null for everyone, so keeping it on
  // exactly one of the four call sites would imply the other three differ.
  return <window.DraftSaveButton dirtySignal={dirtySignal}
    title="Write these assessment questions to the server, so they survive a cleared browser and reach the package" />;
}

const ASSESSMENT_EMPTY = {
  randomiseGroupOrder: true,
  pre: { enabled: false, groups: [] },
  post: { enabled: false, groups: [] },
};

let _assessUid = 0;
const assessmentUid = (p) => `${p}-${Date.now().toString(36)}-${_assessUid++}`;
const assessmentClamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));

function assessmentNewAnswer(correct = false) {
  return { uid: assessmentUid('a'), text: {}, correct, wrongFeedback: {} };
}
function assessmentNewQuestion() {
  return {
    uid: assessmentUid('q'),
    prompt: {},
    answers: [assessmentNewAnswer(true), assessmentNewAnswer(false)],
    correctFeedback: {},
    wrongFeedback: {},
  };
}
function assessmentNewGroup(moduleId) {
  return { uid: assessmentUid('g'), moduleId, questionsShown: 1, questions: [] };
}

function TabBtn({ active, children, onClick }) {
  return (
    <button onClick={onClick} className="focusable"
      style={{
        display: 'inline-flex', alignItems: 'center', gap: 6,
        padding: '5px 12px', border: 0, borderRadius: 4,
        flex: '0 0 auto', whiteSpace: 'nowrap',
        background: active ? 'var(--surface)' : 'transparent',
        color: active ? 'var(--text)' : 'var(--text-muted)',
        fontSize: 12.5, fontWeight: active ? 600 : 500,
        fontFamily: 'inherit', cursor: 'default',
        boxShadow: active ? 'var(--shadow-sm)' : 'none',
      }}>
      {children}
    </button>
  );
}

// ── Toggle — small switch with an onChange + tooltip (unlike the bare Switch,
// ── which owns its own state).
// Named `AssessmentToggle` (not `Toggle`) to avoid clobbering the canonical
// controlled `Toggle` in field-widgets.jsx — both files share global scope, so
// a bare `function Toggle` here shadowed the value-based one the layout editors
// rely on (e.g. quiz_gaming Scoring), making those switches render stuck-off.
function AssessmentToggle({ on, onChange, title }) {
  return (
    <button type="button" title={title} aria-pressed={on} className="focusable"
      onClick={() => onChange(!on)}
      style={{ width: 38, height: 22, borderRadius: 999, border: 0, padding: 0,
        cursor: 'default', flex: '0 0 auto', position: 'relative',
        background: on ? 'var(--accent)' : 'var(--border-strong)',
        transition: 'background 150ms' }}>
      <span style={{ position: 'absolute', top: 2, left: on ? 18 : 2,
        width: 18, height: 18, borderRadius: '50%', background: '#fff',
        boxShadow: 'var(--shadow-sm)', transition: 'left 150ms' }} />
    </button>
  );
}

// ── Phase card — the on/off switch that drives <have_pre_a>/<have_post_a>,
// ── plus the one course-wide shuffle flag.
function AssessmentPhaseCard({ which, phase, randomise, onToggleEnabled, onToggleRandomise }) {
  const isPre = which === 'pre';
  const total = (phase.groups || []).reduce((n, g) => n + (g.questions || []).length, 0);
  return (
    <div className="card" style={{ padding: 16 }}>
      <div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <h3 style={{ margin: '0 0 4px', fontSize: 13, fontWeight: 600 }}>
            {isPre ? 'Include a pre-assessment' : 'Include a post-assessment'}
          </h3>
          <p style={{ margin: 0, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
            {isPre
              ? 'Learners answer these before the modules open. Getting a module’s questions right marks that module as already known.'
              : 'Learners answer these after finishing every module. Getting a module’s questions right passes that module.'}
          </p>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 9, flex: '0 0 auto', paddingTop: 2 }}>
          <AssessmentToggle on={phase.enabled === true} onChange={onToggleEnabled}
            title={`Include the ${isPre ? 'pre' : 'post'}-assessment in the exported course`} />
          <span style={{ fontSize: 12, color: 'var(--text-muted)', width: 22 }}>
            {phase.enabled ? 'On' : 'Off'}
          </span>
        </div>
      </div>

      {phase.enabled && total === 0 && (
        <div style={{ marginTop: 12, padding: '9px 11px', borderRadius: 'var(--radius)',
          background: 'var(--warning-bg, rgba(217,119,6,.08))',
          border: '1px solid var(--warning)', fontSize: 12, color: 'var(--warning-text)',
          display: 'flex', gap: 8, alignItems: 'flex-start' }}>
          <I.AlertCircle size={13} style={{ flexShrink: 0, marginTop: 1 }} />
          <span>
            This is switched on but has no questions yet, so the export will stop and say so.
            Add questions below, or switch it back off.
          </span>
        </div>
      )}

      <div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border)',
        display: 'flex', alignItems: 'flex-start', gap: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 9, flex: '0 0 auto' }}>
          <AssessmentToggle on={randomise} onChange={onToggleRandomise}
            title="Shuffle the order the module groups appear in" />
          <span style={{ fontSize: 12.5, fontWeight: 500 }}>Shuffle group order</span>
        </div>
        <p style={{ margin: 0, fontSize: 11.5, color: 'var(--text-faint)', lineHeight: 1.5, flex: 1 }}>
          Changes the order the modules’ question groups appear in. This one setting
          applies to <strong>both</strong> the pre- and the post-assessment — the course format
          only has room for one.
        </p>
      </div>
    </div>
  );
}

// ── Group list — one question group per module, plus the orphan warning. ─────
function AssessmentGroupList({ which, phase, modules, onPatchPhase }) {
  const [confirm, setConfirm] = React.useState(null);
  const [openGroups, setOpenGroups] = React.useState({});
  // Which question rows are expanded. Held HERE rather than inside the row so
  // "Add question" can open the new one — a row that appears collapsed and
  // empty reads as "nothing happened".
  const [openQuestions, setOpenQuestions] = React.useState({});
  const groups = phase.groups || [];

  const byId = new Map(modules.map(m => [m.id, m]));
  const bound = new Set(groups.map(g => g.moduleId));
  const unbound = modules.filter(m => !bound.has(m.id));
  // A group whose module has since been deleted. It cannot be exported (the
  // mapper can only resolve real modules), so say so rather than let the
  // author keep editing questions that will never ship.
  const orphans = groups.filter(g => !byId.has(g.moduleId));

  const mapGroup = (uid, fn) =>
    onPatchPhase(p => ({ ...p, groups: p.groups.map(g => g.uid === uid ? fn(g) : g) }));
  const patchGroup = (uid, patch) => mapGroup(uid, g => ({ ...g, ...patch }));
  const removeGroup = (uid) =>
    onPatchPhase(p => ({ ...p, groups: p.groups.filter(g => g.uid !== uid) }));

  const addGroup = (moduleId) => {
    const g = assessmentNewGroup(moduleId);
    setOpenGroups(o => ({ ...o, [g.uid]: true }));
    onPatchPhase(p => ({ ...p, groups: [...p.groups, g] }));
  };
  const addGroupsForAll = () => {
    if (!unbound.length) return;
    const fresh = unbound.map(m => assessmentNewGroup(m.id));
    setOpenGroups(o => ({ ...o, [fresh[0].uid]: true }));
    onPatchPhase(p => ({ ...p, groups: [...p.groups, ...fresh] }));
  };

  const mapQuestions = (gid, fn) => mapGroup(gid, g => {
    const questions = fn(g.questions || []);
    // Keep K within the pool automatically — an author who deletes questions
    // should not have to go and fix a number to make the export pass.
    return { ...g, questions,
      questionsShown: assessmentClamp(g.questionsShown || 1, 1, Math.max(1, questions.length)) };
  });

  const doConfirm = () => {
    if (!confirm) return;
    if (confirm.kind === 'group') removeGroup(confirm.uid);
    if (confirm.kind === 'question') {
      mapQuestions(confirm.gid, qs => qs.filter(q => q.uid !== confirm.uid));
    }
    setConfirm(null);
  };

  return (
    <div>
      {orphans.length > 0 && (
        <div style={{ padding: 12, marginBottom: 16, background: 'var(--error-bg)',
          border: '1px solid var(--error)', borderRadius: 'var(--radius-md)',
          display: 'flex', alignItems: 'flex-start', gap: 10 }}>
          <I.AlertCircle size={16} style={{ color: 'var(--error-text)', marginTop: 1 }} />
          <div style={{ flex: 1 }}>
            <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--error-text)', marginBottom: 4 }}>
              {orphans.length} question group{orphans.length === 1 ? '' : 's'} point at a module that no longer exists
            </div>
            <p style={{ margin: '0 0 8px', fontSize: 12, color: 'var(--error-text)', lineHeight: 1.5 }}>
              Their questions cannot be included in the course. Remove them, or add the module back.
            </p>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              {orphans.map(g => (
                <button key={g.uid} className="btn sm danger" style={{ height: 26 }}
                  onClick={() => setConfirm({ kind: 'group', uid: g.uid,
                    label: g.moduleId, count: (g.questions || []).length })}>
                  <I.Trash size={11} />Remove {g.moduleId} group
                </button>
              ))}
            </div>
          </div>
        </div>
      )}

      {unbound.length > 0 && (
        <div style={{ padding: 12, marginBottom: 16, background: 'var(--surface-2)',
          border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
          display: 'flex', alignItems: 'flex-start', gap: 10 }}>
          <I.Info size={15} style={{ color: 'var(--text-muted)', marginTop: 1 }} />
          <div style={{ flex: 1 }}>
            <div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 4 }}>
              {unbound.length === 1
                ? '1 module has no questions in this assessment'
                : `${unbound.length} modules have no questions in this assessment`}
            </div>
            <p style={{ margin: '0 0 8px', fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
              A module without questions simply isn’t assessed — it won’t stop the export.
              Add a group for any module you want to test.
            </p>
            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
              {unbound.map(m => (
                <button key={m.id} className="btn sm" style={{ height: 26 }}
                  onClick={() => addGroup(m.id)}>
                  <I.Plus size={11} />{m.id}
                  <span style={{ opacity: 0.85, fontWeight: 400 }}>· {locText(m.title)}</span>
                </button>
              ))}
              {unbound.length > 1 && (
                <button className="btn sm primary" style={{ height: 26 }} onClick={addGroupsForAll}>
                  <I.Plus size={11} />Add all {unbound.length}
                </button>
              )}
            </div>
          </div>
        </div>
      )}

      <div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 10px' }}>
        <h3 style={{ margin: 0, fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap', flex: '0 0 auto' }}>
          Question groups
        </h3>
        <span style={{ fontSize: 11, color: 'var(--text-faint)',
          fontFamily: 'var(--font-mono)', background: 'var(--surface-inset)',
          padding: '2px 6px', borderRadius: 4, flex: '0 0 auto' }}>{groups.length}</span>
        <div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
      </div>

      {groups.length === 0 ? (
        <div style={{ padding: '22px 14px', textAlign: 'center', border: '1px dashed var(--border)',
          borderRadius: 'var(--radius-md)', fontSize: 12.5, color: 'var(--text-faint)' }}>
          No questions yet. Pick a module above to start writing questions for it.
        </div>
      ) : (
        <div style={{ display: 'grid', gap: 8 }}>
          {groups.map(g => (
            <AssessmentGroupCard key={g.uid} group={g} module={byId.get(g.moduleId)}
              open={!!openGroups[g.uid]}
              onToggleOpen={() => setOpenGroups(o => ({ ...o, [g.uid]: !o[g.uid] }))}
              onPatch={patch => patchGroup(g.uid, patch)}
              onDelete={() => setConfirm({ kind: 'group', uid: g.uid,
                label: locText((byId.get(g.moduleId) || {}).title) || g.moduleId,
                count: (g.questions || []).length })}
              openQuestions={openQuestions}
              onToggleQuestion={(quid) => setOpenQuestions(o => ({ ...o, [quid]: !o[quid] }))}
              onAddQuestion={() => {
                const q = assessmentNewQuestion();
                setOpenQuestions(o => ({ ...o, [q.uid]: true }));
                mapQuestions(g.uid, qs => [...qs, q]);
              }}
              onPatchQuestion={(quid, patch) =>
                mapQuestions(g.uid, qs => qs.map(q => q.uid === quid ? { ...q, ...patch } : q))}
              onDeleteQuestion={(quid) => setConfirm({ kind: 'question', gid: g.uid, uid: quid })} />
          ))}
        </div>
      )}

      {confirm && confirm.kind === 'group' && (
        <ConfirmDialog
          title={`Delete the questions for "${confirm.label}"?`}
          body={confirm.count > 0
            ? `This also removes ${confirm.count} question${confirm.count === 1 ? '' : 's'}. This can't be undone.`
            : "This can't be undone."}
          confirmLabel="Delete group"
          onConfirm={doConfirm} onCancel={() => setConfirm(null)} />
      )}
      {confirm && confirm.kind === 'question' && (
        <ConfirmDialog
          title="Delete this question?"
          body="The question and its answers will be removed. This can't be undone."
          confirmLabel="Delete question"
          onConfirm={doConfirm} onCancel={() => setConfirm(null)} />
      )}
    </div>
  );
}

function AssessmentGroupCard({ group, module, open, onToggleOpen, onPatch, onDelete,
  onAddQuestion, onPatchQuestion, onDeleteQuestion, openQuestions, onToggleQuestion }) {
  const questions = group.questions || [];
  const N = questions.length;
  const label = module ? locText(module.title) : 'Module removed';
  return (
    <div className="card">
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 12px' }}>
        <button onClick={onToggleOpen}
          style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0,
            border: 0, background: 'transparent', cursor: 'default',
            fontFamily: 'inherit', color: 'var(--text)', textAlign: 'left', padding: 0 }}>
          <span style={{
            fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
            padding: '2px 6px', borderRadius: 4, background: 'var(--surface-inset)',
            color: 'var(--text-muted)', flex: '0 0 auto',
          }}>{group.moduleId}</span>
          <span className="truncate" style={{ fontSize: 13, fontWeight: 600, minWidth: 0,
            color: module ? 'var(--text)' : 'var(--error-text)' }}>{label}</span>
          <span style={{ fontSize: 11.5, color: 'var(--text-muted)', whiteSpace: 'nowrap', flex: '0 0 auto' }}>
            {N === 0 ? 'no questions' : `asks ${assessmentClamp(group.questionsShown || 1, 1, N)} of ${N}`}
          </span>
        </button>
        <div style={{ display: 'flex', gap: 6, flex: '0 0 auto' }}>
          <button className="btn sm danger" title="Delete group" onClick={onDelete}
            style={{ width: 28, height: 28, padding: 0, justifyContent: 'center' }}>
            <I.Trash size={13} />
          </button>
          <button className="btn sm ghost" title={open ? 'Collapse' : 'Expand'} onClick={onToggleOpen}
            style={{ width: 28, height: 28, padding: 0, justifyContent: 'center' }}>
            {open ? <I.ChevronUp size={14} /> : <I.ChevronDown size={14} />}
          </button>
        </div>
      </div>

      {open && (
        <div style={{ padding: '14px', borderTop: '1px solid var(--border)', display: 'grid', gap: 10 }}>
          <AssessmentCountField questionsShown={group.questionsShown} total={N}
            onChange={v => onPatch({ questionsShown: v })} />
          {N === 0 && (
            <div style={{ fontSize: 12, color: 'var(--text-faint)' }}>
              No questions in this group yet.
            </div>
          )}
          {questions.map((q, i) => (
            <AssessmentQuestionRow key={q.uid} num={i + 1} question={q}
              expanded={!!openQuestions[q.uid]}
              onToggleExpanded={() => onToggleQuestion(q.uid)}
              onPatch={patch => onPatchQuestion(q.uid, patch)}
              onDelete={() => onDeleteQuestion(q.uid)} />
          ))}
          <button className="btn sm ghost" style={{ alignSelf: 'flex-start' }} onClick={onAddQuestion}>
            <I.Plus size={11} />Add question
          </button>
        </div>
      )}
    </div>
  );
}

// ── Ask K of N. Writing more questions than you ask is the point: the Player
// ── draws a different set for every learner, on every attempt.
function AssessmentCountField({ questionsShown, total, onChange }) {
  const max = Math.max(1, total);
  const val = assessmentClamp(questionsShown ?? 1, 1, max);
  return (
    <div style={{ padding: '10px 12px', background: 'var(--surface-inset)',
      borderRadius: 'var(--radius)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '0 0 auto' }}>
        <span style={{ fontSize: 12, fontWeight: 600 }}>Ask</span>
        <input type="number" className="field" min={1} max={max} value={val}
          disabled={total === 0}
          onChange={e => onChange(assessmentClamp(parseInt(e.target.value, 10) || 1, 1, max))}
          style={{ width: 56, height: 28, fontSize: 12.5, textAlign: 'center' }} />
        <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>of {total}</span>
      </div>
      <span style={{ fontSize: 11, color: 'var(--text-faint)', flex: 1, minWidth: 200, lineHeight: 1.45 }}>
        {total > val
          ? `Each learner is asked ${val} of these ${total} questions, picked at random — a different set each attempt.`
          : 'Write more questions than you ask and each learner gets a different, randomly picked set.'}
      </span>
    </div>
  );
}

function AssessmentQuestionRow({ num, question, expanded, onToggleExpanded, onPatch, onDelete }) {
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  const promptText = locText(question.prompt, lang);
  const answers = question.answers || [];
  const correctCount = answers.filter(a => a.correct === true).length;
  // The Player has exactly two question shapes and picks by counting correct
  // answers — there is no author-facing control, and inventing one would let
  // the two disagree.
  const kind = correctCount > 1 ? 'Select all that apply' : 'One correct answer';

  const patchAnswer = (uid, patch) =>
    onPatch({ answers: answers.map(a => a.uid === uid ? { ...a, ...patch } : a) });

  return (
    <div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', background: 'var(--surface)' }}>
      <div onClick={onToggleExpanded}
        style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto auto',
          gap: 10, alignItems: 'center', padding: '8px 10px', cursor: 'default' }}>
        <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
          color: 'var(--text-muted)' }}>Q{num}</span>
        <span className="truncate" style={{ fontSize: 13,
          color: promptText ? 'var(--text)' : 'var(--text-faint)' }}>
          {promptText || 'Untitled question'}
        </span>
        <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{answers.length} answers</span>
        {correctCount === 0
          ? <I.AlertCircle size={13} style={{ color: 'var(--error-text)' }} />
          : <span style={{ width: 13 }} />}
      </div>

      {expanded && (
        <div style={{ padding: '0 12px 12px', display: 'flex', flexDirection: 'column', gap: 12 }}>
          <ControlledLocalized label="Question prompt" value={question.prompt}
            onChange={v => onPatch({ prompt: v })}
            placeholder="Type the question…" style={{ fontSize: 13 }} />

          <div>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
              <span style={{ fontSize: 12.5, fontWeight: 500 }}>Answers</span>
              <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>· {kind}</span>
            </div>
            {correctCount === 0 && (
              <div style={{ fontSize: 11.5, color: 'var(--error-text)', marginBottom: 6 }}>
                Tick the correct answer. Without one, a learner can never pass this module.
              </div>
            )}
            {correctCount > 1 && answers.some(a => a.correct !== true && locText(a.wrongFeedback, lang)) && (
              <div style={{ fontSize: 11.5, color: 'var(--text-muted)', marginBottom: 6 }}>
                On a “select all that apply” question the course player can’t tell which single
                answer to respond to, so per-answer notes aren’t shown — everyone who gets it wrong
                sees the “Feedback after a wrong answer” message below.
              </div>
            )}
            <div style={{ display: 'grid', gap: 8 }}>
              {answers.map((a, i) => (
                <AssessmentAnswerRow key={a.uid} letter={String.fromCharCode(65 + i)} answer={a}
                  onPatch={patch => patchAnswer(a.uid, patch)}
                  onDelete={answers.length > 2
                    ? () => onPatch({ answers: answers.filter(x => x.uid !== a.uid) })
                    : null} />
              ))}
            </div>
            <button className="btn sm ghost" style={{ marginTop: 8 }}
              onClick={() => onPatch({ answers: [...answers, assessmentNewAnswer(false)] })}>
              <I.Plus size={11} />Add answer
            </button>
          </div>

          <ControlledLocalized label="Feedback after a correct answer"
            value={question.correctFeedback} multiline
            onChange={v => onPatch({ correctFeedback: v })}
            placeholder="Explain why this is right…" />

          <ControlledLocalized label="Feedback after a wrong answer"
            value={question.wrongFeedback} multiline
            onChange={v => onPatch({ wrongFeedback: v })}
            placeholder="Shown for any wrong answer that has no note of its own…" />

          <div style={{ display: 'flex', alignItems: 'center' }}>
            <div style={{ flex: 1 }} />
            <button className="btn sm ghost danger" onClick={onDelete} title="Delete question">
              <I.Trash size={12} />Delete
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

function AssessmentAnswerRow({ letter, answer, onPatch, onDelete }) {
  const [showNote, setShowNote] = React.useState(false);
  const lang = React.useContext(LocDefaultLangContext) || 'en';
  const hasNote = !!locText(answer.wrongFeedback, lang);
  return (
    <div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)',
      padding: '8px 10px', background: 'var(--surface-2)' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
        <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
          color: 'var(--text-muted)', width: 12, flex: '0 0 auto' }}>{letter}</span>
        <button type="button" className="focusable" title="Mark this answer correct"
          aria-pressed={answer.correct === true}
          onClick={() => onPatch({ correct: !(answer.correct === true) })}
          style={{ width: 20, height: 20, flex: '0 0 auto', padding: 0, borderRadius: 4,
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
            cursor: 'default',
            border: '1px solid ' + (answer.correct ? 'var(--success)' : 'var(--border-strong)'),
            background: answer.correct ? 'var(--success)' : 'var(--surface)',
            color: '#fff' }}>
          {answer.correct && <I.Check size={12} />}
        </button>
        <div style={{ flex: 1, minWidth: 0 }}>
          <ControlledLocalized value={answer.text} onChange={v => onPatch({ text: v })}
            placeholder="Answer text…" style={{ fontSize: 12.5 }} />
        </div>
        {(!answer.correct || hasNote) && (
          <button className="btn sm ghost" title="Note shown when a learner picks this answer"
            onClick={() => setShowNote(s => !s)}
            style={{ flex: '0 0 auto', color: hasNote ? 'var(--accent-text)' : undefined }}>
            <I.MessageSquare size={12} />
          </button>
        )}
        {onDelete && (
          <button className="btn sm ghost danger" title="Remove answer" onClick={onDelete}
            style={{ width: 26, height: 26, padding: 0, justifyContent: 'center', flex: '0 0 auto' }}>
            <I.X size={12} />
          </button>
        )}
      </div>
      {(showNote || hasNote) && !(answer.correct && !hasNote) && (
        <div style={{ marginTop: 8, paddingLeft: 41 }}>
          {answer.correct && hasNote && (
            <div style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 5 }}>
              This answer is now ticked correct, so this note is never shown. Clear it or untick
              the answer — it is left visible so it can’t sit in the course unseen.
            </div>
          )}
          <ControlledLocalized value={answer.wrongFeedback}
            onChange={v => onPatch({ wrongFeedback: v })} multiline
            placeholder="Why this particular answer is wrong…" style={{ fontSize: 12, minHeight: 44 }} />
        </div>
      )}
    </div>
  );
}

// ── Result screen — NOT authorable in this version, and honest about it.
// The wording lives in the per-language UI.xml files bundled with the Player
// (15 languages, ~14 separate label slots including three variants of the body
// depending on how the learner did in the pre-assessment). Making it editable
// is its own slice of work; a text box here would write to nothing.
function AssessmentResultScreenNote({ which, onNavigate }) {
  return (
    <div className="card" style={{ padding: 14 }}>
      <h3 style={{ margin: '0 0 10px', fontSize: 12.5, fontWeight: 600,
        textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)' }}>
        Result screen
      </h3>
      <p style={{ margin: '0 0 10px', fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.55 }}>
        The {which === 'pre' ? '“thank you”' : '“well done”'} screen learners see at the end uses the
        course player’s built-in wording, already translated into every language you can enable.
      </p>
      <p style={{ margin: 0, fontSize: 11.5, color: 'var(--text-faint)', lineHeight: 1.5 }}>
        Editing that wording per course isn’t available yet — it lives in a different part of the
        package from the questions. Your questions, answers and feedback <strong>are</strong> fully
        translatable on the Localisation screen.
      </p>
      {onNavigate && (
        <button className="btn sm" style={{ marginTop: 12 }} onClick={() => onNavigate('localisation')}>
          <I.Globe size={12} />Open Localisation
        </button>
      )}
    </div>
  );
}

Object.assign(window, { SurfaceAssessments });

// ── Copy the pre-assessment onto the post-assessment ─────────────────────────
//
// Omar, 2026-08-10 (verbatim in wiki/Pre_Post_Assessment_design.md §2-prime Q1'):
//
//   > "The copy button should enable the admin to either copy the whole
//   > pre-assessments questions onto the post, or only speicifc modules. If the
//   > there are existing post-assessment questions the system should ask whether
//   > to replace or to keep them. In case the admin keep the post-asses qeustions
//   > the new pre-asses questions will be added on top."
//
// Two axes, both his choice at press time: SCOPE and COLLISION BEHAVIOUR. The
// dialog therefore asks rather than assuming, and states the consequence in
// counts before the button is live — a copy that silently replaced authored
// questions would be the kind of quiet loss this project has already paid for.
//
// All the arithmetic lives in `assessment-ops-core.js` so CI can execute it; this
// component only renders it.
function AssessmentCopyToPostBar({ assessments, modules, onCopy }) {
  const [open, setOpen] = React.useState(false);
  const full = window.copyPreToPostPlan(assessments);
  const [picked, setPicked] = React.useState(null);   // null = "all", else array
  const [mode, setMode] = React.useState('replace');
  const [alsoEnable, setAlsoEnable] = React.useState(true);
  const [done, setDone] = React.useState(null);

  const nameOf = (id) => {
    const m = modules.find(x => x.id === id);
    return m ? `${id} · ${locText(m.title) || 'Untitled'}` : id;
  };
  const candidates = full.rows.map(r => r.moduleId);
  const selection = picked === null ? candidates : picked;
  const plan = window.copyPreToPostPlan(assessments, selection);

  if (!candidates.length) {
    return (
      <div className="card" style={{ padding: '12px 14px', fontSize: 12.5,
        color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 9 }}>
        <I.Copy size={13} style={{ flexShrink: 0 }} />
        <span>Write some pre-assessment questions and you'll be able to copy them
        straight onto the post-assessment from here.</span>
      </div>
    );
  }

  const toggle = (id) => setPicked(p => {
    const cur = p === null ? candidates.slice() : p.slice();
    const at = cur.indexOf(id);
    if (at === -1) cur.push(id); else cur.splice(at, 1);
    return cur;
  });

  return (
    <div className="card" style={{ padding: '12px 14px' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
        <I.Copy size={14} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 13, fontWeight: 600 }}>Reflect onto the post-assessment</div>
          <div style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>
            {full.totalCopying} question{full.totalCopying === 1 ? '' : 's'} across{' '}
            {candidates.length} module{candidates.length === 1 ? '' : 's'} can be copied.
          </div>
        </div>
        {done && (
          <span style={{ fontSize: 11.5, color: 'var(--success-text, #15803d)' }}>{done}</span>
        )}
        <button className="btn sm" onClick={() => { setDone(null); setOpen(true); }}>
          Copy to post…
        </button>
      </div>

      {open && ReactDOM.createPortal(
        <div style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex',
          alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,.42)' }}
          onClick={() => setOpen(false)}>
          <div onClick={e => e.stopPropagation()} className="card" style={{
            width: 560, maxWidth: 'calc(100vw - 32px)', maxHeight: 'calc(100vh - 64px)',
            overflowY: 'auto', padding: 18, boxShadow: 'var(--shadow-xl)' }}>
            <h3 style={{ margin: '0 0 4px', fontSize: 15, fontWeight: 600 }}>
              Copy the pre-assessment onto the post-assessment
            </h3>
            <p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-muted)' }}>
              Pick the modules to copy. Nothing changes until you press Copy.
            </p>

            {/* SCOPE — "the whole pre-assessments questions … or only specific modules" */}
            <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
              <strong style={{ fontSize: 12 }}>Modules</strong>
              <div style={{ flex: 1 }} />
              <button className="btn sm" style={{ height: 24 }}
                onClick={() => setPicked(null)}>All</button>
              <button className="btn sm" style={{ height: 24 }}
                onClick={() => setPicked([])}>None</button>
            </div>
            <div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)',
              overflow: 'hidden', marginBottom: 14 }}>
              {full.rows.map(r => (
                <label key={r.moduleId} style={{ display: 'flex', alignItems: 'center',
                  gap: 10, padding: '8px 11px', fontSize: 12.5, cursor: 'default',
                  borderBottom: '1px solid var(--border-faint)' }}>
                  <input type="checkbox" checked={selection.indexOf(r.moduleId) !== -1}
                    onChange={() => toggle(r.moduleId)}
                    style={{ width: 15, height: 15, accentColor: 'var(--accent)' }} />
                  <span style={{ flex: 1, minWidth: 0 }} className="truncate">{nameOf(r.moduleId)}</span>
                  <span style={{ color: 'var(--text-muted)', fontSize: 11.5 }}>
                    {r.copying} to copy
                  </span>
                  {r.collides && (
                    <span className="pill" style={{ fontSize: 10 }}>
                      post has {r.existingInPost}
                    </span>
                  )}
                </label>
              ))}
            </div>

            {/* Modules an author might EXPECT to be copied but which have nothing. */}
            {full.skippedEmpty.length > 0 && (
              <p style={{ margin: '0 0 14px', fontSize: 11.5, color: 'var(--text-muted)' }}>
                Not listed: {full.skippedEmpty.map(nameOf).join(', ')} — no pre-assessment
                questions yet, so there is nothing to copy from{full.skippedEmpty.length === 1 ? ' it' : ' them'}.
              </p>
            )}

            {/* COLLISION — asked only when it actually applies. */}
            {plan.collidingModules.length > 0 ? (
              <div style={{ marginBottom: 14 }}>
                <strong style={{ fontSize: 12 }}>
                  {plan.collidingModules.length === 1 ? 'One module' : `${plan.collidingModules.length} modules`}
                  {' '}already {plan.collidingModules.length === 1 ? 'has' : 'have'} post-assessment questions
                </strong>
                <label style={{ display: 'flex', gap: 9, marginTop: 7, fontSize: 12.5, cursor: 'default' }}>
                  <input type="radio" name="copymode" checked={mode === 'replace'}
                    onChange={() => setMode('replace')} style={{ marginTop: 2 }} />
                  <span>
                    <strong>Replace them.</strong> The post-assessment ends up matching the
                    pre-assessment exactly. {plan.totalExisting} existing question
                    {plan.totalExisting === 1 ? '' : 's'} will be deleted.
                  </span>
                </label>
                <label style={{ display: 'flex', gap: 9, marginTop: 7, fontSize: 12.5, cursor: 'default' }}>
                  <input type="radio" name="copymode" checked={mode === 'add'}
                    onChange={() => setMode('add')} style={{ marginTop: 2 }} />
                  <span>
                    <strong>Keep them and add these.</strong> Nothing is deleted; the copies land
                    after the questions already there.
                  </span>
                </label>
              </div>
            ) : (
              <p style={{ margin: '0 0 14px', fontSize: 12, color: 'var(--text-muted)' }}>
                None of the selected modules has post-assessment questions yet, so nothing will be
                overwritten.
              </p>
            )}

            {/* A copy into a switched-off assessment shows a learner nothing. */}
            {plan.postDisabled && (
              <label style={{ display: 'flex', gap: 9, marginBottom: 14, fontSize: 12.5,
                cursor: 'default', padding: '9px 11px', borderRadius: 'var(--radius)',
                background: 'var(--warning-bg, rgba(217,119,6,.10))' }}>
                <input type="checkbox" checked={alsoEnable}
                  onChange={e => setAlsoEnable(e.target.checked)} style={{ marginTop: 2 }} />
                <span>
                  <strong>Switch the post-assessment on.</strong> It is currently off, so
                  copying questions into it would not show a learner anything.
                </span>
              </label>
            )}

            <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
              <span style={{ flex: 1, fontSize: 12, color: 'var(--text-muted)' }}>
                {selection.length === 0
                  ? 'Pick at least one module.'
                  : `Copying ${plan.totalCopying} question${plan.totalCopying === 1 ? '' : 's'} into ` +
                    `${plan.rows.length} module${plan.rows.length === 1 ? '' : 's'}.`}
              </span>
              <button className="btn sm" onClick={() => setOpen(false)}>Cancel</button>
              <button className="btn sm primary" disabled={selection.length === 0}
                onClick={() => {
                  const n = plan.totalCopying;
                  const mods = plan.rows.length;
                  onCopy(mode, selection, plan.postDisabled && alsoEnable);
                  setOpen(false);
                  setDone(`Copied ${n} question${n === 1 ? '' : 's'} into ${mods} module${mods === 1 ? '' : 's'}.`);
                }}>
                Copy
              </button>
            </div>
          </div>
        </div>,
        document.body
      )}
    </div>
  );
}

// ── AI question generation ───────────────────────────────────────────────────
//
// Omar, 2026-08-10: "there has to be an Ai button that generate the questions,
// answer and feedback dynamically based on the text on the content. It needs to
// ask also how many quetions per module it is required to generate." And on what
// happens to existing questions: "either generate the questions for all modules,
// replacing what currently exist, or add specific question to specific modules".
//
// So the SCOPE decides the collision behaviour, and the dropdown says so in each
// option's own label rather than hiding it in a tooltip:
//   All modules      → REPLACES what is there
//   One named module → ADDS to what is there
//
// Panel layout follows the design locked on 2026-06-02 (§2 Q2): a Generate button,
// a scope dropdown, and a questions-per-module input defaulting to 3, min 1, max 10.
//
// Everything the model returns is filtered by the route against the four-answer
// shape before it gets here, and filtered AGAIN by `validateGeneratedQuestion`
// before it is merged — because `PUT /draft` is all-or-nothing, so one malformed
// question would 400 the author's entire course.
function AssessmentGeneratePanel({ which, course, modules, layoutDrafts, defaultLang, onApply }) {
  const [scope, setScope] = React.useState('__all');
  const [count, setCount] = React.useState(3);
  const [busy, setBusy] = React.useState(null);      // null | 'M1' | 'all'
  const [progress, setProgress] = React.useState('');
  const [result, setResult] = React.useState(null);  // {added, rejected[], notes[], skipped[]}
  const [error, setError] = React.useState(null);
  const lang = defaultLang || 'en';

  const targets = scope === '__all' ? modules : modules.filter(m => m.id === scope);
  const mode = scope === '__all' ? 'replace' : 'add';

  const run = async () => {
    setError(null); setResult(null); setBusy(scope === '__all' ? 'all' : scope);
    const base = (window.DYNAMO_ENV && window.DYNAMO_ENV.gatewayBase) || '';
    let token;
    try {
      token = await window.dynamoGetAccessToken();
    } catch (e) {
      setBusy(null);
      setError('Could not get an access token from the signed-in session. Try signing out and back in.');
      return;
    }
    const perModule = [];
    const rejected = [];
    const notes = [];
    const skipped = [];
    for (let i = 0; i < targets.length; i++) {
      const m = targets[i];
      setProgress(`${m.id} — ${i + 1} of ${targets.length}`);
      const text = window.moduleTextFor(m, layoutDrafts, lang);
      let res;
      try {
        res = await fetch(`${base}/v1/generate-questions`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
          body: JSON.stringify({
            language: lang, count: Number(count) || 3,
            moduleTitle: (m.title && m.title[lang]) || '',
            moduleText: text,
          }),
        });
      } catch (e) {
        setBusy(null); setProgress('');
        setError('Could not reach the server. Check your connection and try again.');
        return;
      }
      const body = await res.json().catch(() => ({}));
      if (!res.ok) {
        // A per-module failure must not abandon the modules already done, and the
        // reason has to survive to the summary — "generation failed" with no cause
        // is the message an author can do nothing with.
        skipped.push(`${m.id}: ${body.message || `HTTP ${res.status}`}`);
        continue;
      }
      // Second gate, on our side of the wire. The route already filtered; this
      // catches anything that would still fail the schema, because one bad
      // question 400s the whole draft.
      const good = (body.questions || []).filter(
        q => window.validateGeneratedQuestion(q, lang).length === 0);
      const lost = (body.questions || []).length - good.length;
      if (lost > 0) rejected.push(`${m.id}: ${lost} discarded before saving`);
      (body.rejected || []).forEach(r => rejected.push(`${m.id}: ${r}`));
      if (body.note) notes.push(`${m.id}: ${body.note}`);
      if (good.length) perModule.push({ moduleId: m.id, questions: good });
      else if (!body.message) skipped.push(`${m.id}: nothing usable came back`);
    }
    setBusy(null); setProgress('');
    if (perModule.length) onApply(perModule, mode);
    setResult({
      added: perModule.reduce((n, x) => n + x.questions.length, 0),
      modules: perModule.length, rejected, notes, skipped, mode,
    });
  };

  return (
    <div className="card" style={{ padding: '12px 14px' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
        <I.Sparkle size={14} style={{ flexShrink: 0, color: 'var(--accent)' }} />
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: 13, fontWeight: 600 }}>Generate questions with AI</div>
          <div style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>
            Written from each module's own content, in {lang.toUpperCase()} — translate
            afterwards from Localisation.
          </div>
        </div>
        <div style={{ flex: 1 }} />

        <label style={{ fontSize: 11.5, color: 'var(--text-muted)', display: 'flex',
          alignItems: 'center', gap: 6 }}>
          How many per module
          <input type="number" min={1} max={10} value={count}
            onChange={e => setCount(Math.max(1, Math.min(10, Number(e.target.value) || 1)))}
            style={{ width: 52, padding: '3px 6px', fontSize: 12,
              border: '1px solid var(--border)', borderRadius: 'var(--radius)',
              background: 'var(--surface)', color: 'var(--text)' }} />
        </label>

        <select value={scope} onChange={e => setScope(e.target.value)}
          style={{ padding: '4px 7px', fontSize: 12, maxWidth: 260,
            border: '1px solid var(--border)', borderRadius: 'var(--radius)',
            background: 'var(--surface)', color: 'var(--text)' }}>
          {/* The consequence is IN the label. His rule is that scope decides
              whether existing questions survive, so the author should not have to
              remember which is which. */}
          <option value="__all">All modules — replaces existing</option>
          {modules.map(m => (
            <option key={m.id} value={m.id}>
              {m.id} — adds to existing
            </option>
          ))}
        </select>

        <button className="btn sm primary" disabled={!!busy || targets.length === 0}
          onClick={run}>
          {busy ? (progress || 'Generating…') : 'Generate'}
        </button>
      </div>

      {error && (
        <div style={{ marginTop: 10, padding: '8px 10px', fontSize: 12,
          background: 'rgba(220,38,38,.12)', borderRadius: 'var(--radius)' }}>{error}</div>
      )}

      {result && (
        <div style={{ marginTop: 10, padding: '9px 11px', fontSize: 12,
          background: 'var(--surface-inset)', borderRadius: 'var(--radius)',
          display: 'flex', flexDirection: 'column', gap: 5 }}>
          <div>
            <strong>
              {result.added === 0
                ? 'No questions were added.'
                : `${result.added} question${result.added === 1 ? '' : 's'} ` +
                  `${result.mode === 'replace' ? 'replaced what was in' : 'added to'} ` +
                  `${result.modules} module${result.modules === 1 ? '' : 's'}.`}
            </strong>
            {result.added > 0 && ' Read them before you build — they are a first draft.'}
          </div>
          {/* Every shortfall is NAMED. A short batch reported as plain success is
              how an author comes to believe the module only had two ideas in it. */}
          {result.skipped.map((s, i) => (
            <div key={`s${i}`} style={{ color: 'var(--warning-text)' }}>⚠ {s}</div>
          ))}
          {result.notes.map((n, i) => (
            <div key={`n${i}`} style={{ color: 'var(--text-muted)' }}>{n}</div>
          ))}
          {result.rejected.length > 0 && (
            <div style={{ color: 'var(--text-muted)' }}>
              {result.rejected.length} returned question
              {result.rejected.length === 1 ? '' : 's'} did not fit the required
              four-answer shape and {result.rejected.length === 1 ? 'was' : 'were'} discarded.
            </div>
          )}
          <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
            Needs translating into your other languages — Localisation ▸ Assessment.
          </div>
        </div>
      )}
    </div>
  );
}
