// Surface 8 — Export
//
// End-to-end functional build flow:
//   pre-build options modal → client-side validation → gateway call (with a
//   simulated fallback for the prototype, which has no live backend) →
//   real-time stepper → post-build success modal → history refresh.
//
// Data threaded in from app.jsx: `course` (live course shell), `settings`
// (courseSettings slice), `layoutDrafts` (edited layout data), plus
// `onOpenPreview` and `onJump` for navigation.

// ─── Helpers ─────────────────────────────────────────────────────────────────
const EXPORT_PHASES = ['Queued', 'Emitting XML', 'Bundling runtime',
  'Building ZIP', 'Validating ZIP', 'Ready'];

const LS_SCORM = 'export.scormVersion';
const LS_OPTS = 'export.buildOpts';
const LS_HISTORY = 'export.history';

// Real Railway gateway. The build call posts to
// `${GATEWAY_BASE}/v1/courses/:courseId/export` with a Bearer token obtained
// from the signed-in Auth0 session (src/auth.jsx · dynamoGetAccessToken).
// Environment-aware: resolved per-host by src/env-config.js. This single
// top-level const is also reused by src/surface-localisation.jsx.
const GATEWAY_BASE = window.DYNAMO_ENV.gatewayBase;

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

function hasText(v) {
  if (v == null) return false;
  if (typeof v === 'string') return v.trim().length > 0;
  if (typeof v === 'object') return Object.values(v).some(x => hasText(x));
  return !!v;
}

function readLS(key, fallback) {
  try { const v = localStorage.getItem(key); return v == null ? fallback : JSON.parse(v); }
  catch { return fallback; }
}
function writeLS(key, value) {
  try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore */ }
}

// Save a blob/object URL to disk via a programmatic anchor. More reliable
// than window.open inside the preview iframe (popup blockers don't apply,
// and the `download` attr names the file).
function triggerDownload(url, filename) {
  if (!url) return;
  const a = document.createElement('a');
  a.href = url;
  if (filename) a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
}

// Parse filename="…" (or filename*=) out of a Content-Disposition header.
function filenameFromDisposition(cd, fallback) {
  if (!cd) return fallback;
  const star = /filename\*=(?:UTF-8'')?"?([^";]+)"?/i.exec(cd);
  if (star) { try { return decodeURIComponent(star[1]); } catch { return star[1]; } }
  const plain = /filename="?([^";]+)"?/i.exec(cd);
  return plain ? plain[1] : fallback;
}

// Per-type shape fixes so a layout passes the gateway's DraftContentSchema.
// These reconcile the prototype's editor-state shapes with the schema's
// stricter per-type expectations (discriminators, no-empty-string optionals,
// required a11y fields). fullscreen_video is intentionally NOT handled here —
// it's parked until the media-upload pipeline lands.
function normalizeLayoutForSchema(layout, opts) {
  const blankMode = !!(opts && opts.blankMode);
  // Carry a legacy multi-correct quiz `feedbacks.correct` onto the question's
  // `feedbackText` FIRST — the gateway's sanitizer deletes unrecognized keys
  // silently, so without this the author's shared feedback never reached a ZIP.
  const l = { ...window.migrateQuizFeedback(layout) };
  const pickEn = (v) =>
    (v && typeof v === 'object' && !Array.isArray(v)) ? (v.en ?? Object.values(v)[0] ?? '') : v;

  // (a) blockingSection.state: '' is not a valid state — drop it (= "no gate").
  if (l.blockingSection && l.blockingSection.state === '') {
    const { state, ...rest } = l.blockingSection;
    l.blockingSection = rest;
  }

  // (b) Optional URL fields must be ABSENT, not '' (the schema rejects empty strings).
  for (const k of ['backgroundImageUrl', 'subtitlesUrl', 'transcriptUrl']) {
    if (l[k] === '') delete l[k];
  }

  // (b2) Same rule for the optional background COLOUR, for the same reason:
  // ColorSchema rejects '' , so an empty value has to be absent rather than
  // blank. Nothing in the UI produces '' today — the picker always emits a real
  // swatch — but a hand-written draft or a future clear-button would otherwise
  // 400 the whole save (`feedback_all_or_nothing_draft_validation`).
  if (l.contentBackgroundColor === '') delete l.contentBackgroundColor;

  // (c) quiz_images / quiz_gaming — question.content uses a `kind` discriminator,
  //     not a nested { image|video|html: {...} } key. Flatten the inner object up,
  //     keep any siblings (e.g. tooltipValues), and make htmlAlt a plain string.
  if (l.type === 'quiz_images' || l.type === 'quiz_gaming') {
    l.questions = (l.questions || []).map((q) => {
      const c = q.content;
      if (!c || typeof c !== 'object' || c.kind) return q;
      const key = ['image', 'video', 'html'].find((k) => c[k] && typeof c[k] === 'object');
      if (!key) return q;
      const { [key]: inner, ...siblings } = c;
      const content = { kind: key, ...inner, ...siblings };
      if (content.htmlAlt && typeof content.htmlAlt === 'object') content.htmlAlt = pickEn(content.htmlAlt);
      return { ...q, content };
    });
  }

  // (d) sequence tabs — mobileText is a plain string; the text-tab body field is
  //     `text` (authored as `tabText`); question + feedback tabs need a background image.
  if (l.type === 'sequence') {
    l.tabs = (l.tabs || []).map((t) => {
      const tab = { ...t };
      if (tab.mobileText !== undefined) tab.mobileText = pickEn(tab.mobileText);
      if (tab.kind === 'text' && tab.text === undefined && tab.tabText !== undefined) tab.text = tab.tabText;
      // Demo-course crutch only: in blank mode a missing tab image must stay
      // missing so the schema names it (injecting a placeholder here would
      // surface later as an unlocatable media-gate error instead).
      if (!blankMode && (tab.kind === 'question' || tab.kind === 'feedback') && !tab.imageUrl) tab.imageUrl = 'placeholder:scene-office';
      return tab;
    });
  }

  // (e) horizontal_tabs — each tab needs a `kind` discriminator; derive from media.
  if (l.type === 'horizontal_tabs') {
    l.tabs = (l.tabs || []).map((t) => (t.kind ? t : { ...t, kind: t.videoUrl ? 'video' : 'image' }));
  }

  // (f) object_viewer — objectAltText (accessibility) is required; fall back to the title.
  if (l.type === 'object_viewer' && l.objectAltText === undefined) {
    l.objectAltText = l.titleMain || { en: 'Interactive 3D model' };
  }

  // (h) Comprehensive LocalizedString coercion + optional-poster drop.
  //     GENERALISES the old object_viewer-only case (g). The editors author
  //     user-facing text as plain strings (e.g. addInteraction → interactionText:
  //     'Reveal'; the question/tab/hotspot templates in data.jsx likewise), but the
  //     gateway schema models every such field as LocalizedString ({ en: '…' }).
  //     Deep-walk the whole layout and wrap any string-valued field whose NAME is a
  //     schema LocalizedString field. Also drop an empty `videoThumbUrl` — the poster
  //     is now OPTIONAL in the schema, so a video slide with no separate poster is valid.
  //
  //     Field set = the `: LocalizedStringSchema` field names in @dynamo/schema
  //     MINUS `name` (TooltipParam.name is a plain string; ModuleGroup.name is already
  //     { en } and lives outside the layout). `mobileText` / `htmlAlt` are plain
  //     z.string() fields — deliberately NOT in the set.
  //
  //     Kept BYTE-IDENTICAL to LOC_STRING_FIELDS (loc-translate-core.js), which
  //     carries the re-derivation command and the 2026-07-28 changelog: every
  //     field the export coerces must be a field Translate can fill, and vice
  //     versa. A harness check asserts the two sets are equal.
  const LOCALIZED_FIELDS = new Set([
    'body','caption','correct','correctFeedback','description','descriptionText',
    'descriptionTitle','dftiInfoLeftText','dftiInfoRightText','dftiInfoTitle',
    'feedback','feedbackText','image360CoverText','info','initialCoverText',
    'interactionText','itemText','itemTitle','label','leftColumnContent',
    'objectAltText','profileSetupDescription','profileSetupEmail',
    'profileSetupFirstName','profileSetupLastName','prompt','rightColumnContent',
    'tabText','tabTitle','text','title','titleMain','titleSub','wrong',
    'wrongFeedback',
  ]);
  // Per-language MEDIA fields (OQ-081). DELIBERATELY a separate set from
  // LOCALIZED_FIELDS above: that one is the TRANSLATE vocabulary, is asserted
  // byte-equal to LOC_STRING_FIELDS by a harness, and enrolling a URL in it would
  // send `asset://<uuid>` to Google Translate. These fields are localized in the
  // schema but must never be machine-translated.
  const URL_LOCALIZED_FIELDS = new Set(['subtitlesUrl', 'transcriptUrl']);
  const coerceLocalized = (node) => {
    if (Array.isArray(node)) { node.forEach(coerceLocalized); return; }
    if (node && typeof node === 'object') {
      if (node.videoThumbUrl === '') delete node.videoThumbUrl;   // optional poster
      for (const k of Object.keys(node)) {
        const v = node[k];
        if (typeof v === 'string' && LOCALIZED_FIELDS.has(k)) node[k] = { en: v };
        // A legacy plain-string track (stored before per-language subtitles, and
        // still present in a production draft) becomes the English entry. Without
        // this the whole save 400s on a value the author never set — and the save
        // is all-or-nothing, so one stale field would block the entire course.
        // An EMPTY string is dropped instead of becoming `{en:''}`: the schema
        // rejects an empty entry, because absence is the only way to say
        // "no track for this language".
        else if (typeof v === 'string' && URL_LOCALIZED_FIELDS.has(k)) {
          if (v === '') delete node[k];
          else node[k] = { en: v };
        }
        else if (v && typeof v === 'object') coerceLocalized(v);
      }
    }
  };
  coerceLocalized(l);

  // (i) Drop non-renderable in-video interactions. The Player renders interaction
  //     text/description fields raw (no empty-guard), so a blank one shows
  //     "undefined" or blocks export on a required-but-empty LocalizedString.
  //     Keep only interactions that render; for `question` (whose prompt `text` is
  //     optional) also drop a blank prompt so it isn't sent as { en: '' }.
  const locVal = (v) =>
    v && typeof v === 'object' && !Array.isArray(v) ? (v.en ?? Object.values(v)[0] ?? '') : (v ?? '');
  const nonBlank = (v) => String(locVal(v)).trim() !== '';
  const interactionRenders = (iv) => {
    if (iv.type === 'discover') return nonBlank(iv.descriptionText);
    // question / mandatoryQuestion need >= 2 options with real text;
    // mandatoryQuestion also needs a prompt.
    const goodOptions = (iv.options || []).filter((o) => nonBlank(o.text));
    if (goodOptions.length < 2) return false;
    if (iv.type === 'mandatoryQuestion') return nonBlank(iv.text);
    return true;
  };
  const pruneInteractions = (node) => {
    if (Array.isArray(node)) { node.forEach(pruneInteractions); return; }
    if (node && typeof node === 'object') {
      if (Array.isArray(node.interactions)) {
        node.interactions = node.interactions
          .filter(interactionRenders)
          .map((iv) => {
            if (iv.type === 'question' && !nonBlank(iv.text)) {
              const { text, ...rest } = iv; // prompt is optional — drop if blank
              return rest;
            }
            return iv;
          });
      }
      for (const k of Object.keys(node)) {
        if (node[k] && typeof node[k] === 'object') pruneInteractions(node[k]);
      }
    }
  };
  pruneInteractions(l);

  return l;
}

// Reshape the app's reconstructed liveCourse into the gateway's DraftContent
// schema so the export reflects what's ON SCREEN (the gateway builds the ZIP
// from the saved draft — without this it ships stale DB content). Uses the
// SAME group→module→layout nesting the Course-architecture surface renders;
// localized fields ({en, it, …}) pass through as objects, never flattened to
// plain strings; moduleGroups always has ≥ 1 group.
// ── Stand-in media (whole-course bilingual-export proof, 2026-06-19) ──────────
// The demo course is a visual mockup: ~35 media slots hold placeholder sketches
// (`placeholder:…`) or sample paths that are not real uploads, so the gateway's
// media guard (UnresolvedMediaError) would reject every one. For the whole-course
// bilingual EXPORT, swap each placeholder/sample media value for ONE real uploaded
// stand-in asset (Omar uploaded 1 image + 1 video). Already-real `asset://` refs
// and empty values are left untouched; optional subtitle/transcript tracks are
// dropped (no stand-in for a text track). Runs ONLY in the export payload — the
// editors still show the original sketches. Remove once the course has real media.
//
// The two ids come from `env-config.js`, which pairs them with the gateway URL
// in ONE per-environment record. They used to be hard-coded right here, and
// because `asset://<uuid>` names a row in ONE environment's database, every
// branch ended up carrying a different pair: promotions to staging hit a
// permanent merge conflict, and `main` shipped QA's ids to production where
// those rows do not exist (2026-07-29,
// wiki/findings/2026-07-29-standin-asset-ids-are-env-specific).
//
// `null` when the running environment has no stand-ins configured — in that case
// media is left EXACTLY as authored, so the schema or the unresolved-media gate
// names the field. Never a fallback to another environment's id: that fallback IS
// the bug this replaced.
//
// Two honest caveats. (1) All three shipped records DO have stand-ins, so the
// `null` branch is exercised only by `standin-per-env.harness.mts` check 8 — it is
// the safety net for a future environment, not live behaviour. (2) "the gate names
// the field" is not uniformly friendly: a surviving `placeholder:` in
// `videoThumbUrl` 422s on an optional poster the author cannot see or clear (see
// the `placeholder-token-in-a-raw-field` lesson in agent memory).
// `normalizeLayoutForSchema` deletes an EMPTY videoThumbUrl before this runs, so
// the reachable case is an authored sentinel rather than a blank slot.
const _STANDINS = (window.DYNAMO_ENV && window.DYNAMO_ENV.standIns) || null;
const STANDIN_IMAGE = (_STANDINS && _STANDINS.image) || null;
const STANDIN_VIDEO = (_STANDINS && _STANDINS.video) || null;
const _IMG_MEDIA_FIELDS = new Set(['imageUrl', 'backgroundImageUrl', 'image360Url',
  'itemImageUrl', 'objectPosterImgUrl', 'objectEnvImgUrl', 'videoThumbUrl', 'itemVideoThumbUrl']);
const _VID_MEDIA_FIELDS = new Set(['videoUrl', 'itemVideoUrl']);
const _DROP_MEDIA_FIELDS = new Set(['subtitlesUrl', 'transcriptUrl']);
function substituteStandInMedia(node) {
  if (Array.isArray(node)) return node.map(substituteStandInMedia);
  if (!node || typeof node !== 'object') return node;
  const out = {};
  for (const [k, v] of Object.entries(node)) {
    // Per-language track with no real upload in it → drop the whole field, as a
    // plain string one already was. Checked BEFORE the string branch below,
    // because a record never reaches it: after the export coercion the demo
    // course's seeded `content/en/…/priya.txt` is an OBJECT, and letting it
    // through would 422 the export of the very course used to demo the product.
    if (_DROP_MEDIA_FIELDS.has(k) && v && typeof v === 'object' && !Array.isArray(v)) {
      const hasUpload = Object.values(v).some(
        (x) => typeof x === 'string' && x.startsWith('asset://'));
      if (!hasUpload) continue;
    }
    if (typeof v === 'string' && !v.startsWith('asset://')) {
      // Fill known media fields with the stand-in — INCLUDING empty slots, so a
      // video/image layout that was never authored still bundles media. With no
      // stand-in configured for this environment, fall THROUGH and keep the
      // authored value: an honest 422 naming the slot beats a reference to a row
      // that only exists somewhere else.
      if (STANDIN_IMAGE && _IMG_MEDIA_FIELDS.has(k)) { out[k] = STANDIN_IMAGE; continue; }
      if (STANDIN_VIDEO && _VID_MEDIA_FIELDS.has(k)) { out[k] = STANDIN_VIDEO; continue; }
      if (_DROP_MEDIA_FIELDS.has(k)) { continue; }
    }
    out[k] = (v && typeof v === 'object') ? substituteStandInMedia(v) : v;
  }
  return out;
}

// ── Blank-mode media prune (from-zero courses) ────────────────────────────────
// Courses with `contentMode === 'blank'` never get stand-in media — the honest
// gates do the work instead. This prune runs AFTER normalizeLayoutForSchema (so
// the layout is already schema-shaped) and deep-deletes every EMPTY media key:
// - a REQUIRED media field then fails schema validation with a "Required" error
//   naming the exact path (surfaced in the Validation panel), and
// - an OPTIONAL media field is correctly omitted from the draft.
// Non-empty non-asset values (editor sentinels like `placeholder:q`,
// `content/q.mp4`) survive on purpose: the gateway's UnresolvedMediaError gate
// rejects them per-field, telling the author exactly which slot needs real media.
// Field set is a SUBSET of the packager's recognised media fields
// (packages/scorm-packager/src/asset-inline.ts), plus `htmlUrl` (the quiz HTML
// media path) — deliberately, and it is not a mirror to keep in lock-step:
// - the quiz_gaming fields (`imgUrlCorrect`, `imgUrlWrong`, `bgImgUrl`) are
//   OPTIONAL in the schema and pruned by the layout normaliser already;
// - `dftiLeftImg` / `dftiRightImg` are REQUIRED `z.string()` on DftiFlowSchema,
//   so deleting an empty one would fail validation with "Required" on a field
//   whose empty value is legitimately "no image" — `''` is the unset here.
// Adding a field to the packager therefore does NOT imply adding it here; ask
// what an empty value means for that field first.
const _BLANK_PRUNE_MEDIA_FIELDS = new Set([
  'imageUrl', 'backgroundImageUrl', 'image360Url',
  'videoUrl', 'videoThumbUrl', 'subtitlesUrl', 'transcriptUrl',
  'objectUrl', 'objectPosterImgUrl', 'objectEnvImgUrl',
  'itemImageUrl', 'itemVideoUrl', 'itemVideoThumbUrl', 'htmlUrl',
]);
// LocalizedString fields that are OPTIONAL in the schema: an all-empty value
// means "author left it empty" — drop it rather than coverage-block the export.
// REQUIRED LocalizedString fields keep their { en: '' } so the coverage gate
// names them ("titleMain lacks a value for: en") — the friendlier message.
const _BLANK_PRUNE_OPTIONAL_LOC_FIELDS = new Set([
  // Per-language media tracks: an all-empty record means the author set nothing.
  'subtitlesUrl', 'transcriptUrl','titleSub', 'feedback', 'info', 'feedbackText']);
function pruneBlankMedia(node) {
  if (Array.isArray(node)) return node.map(pruneBlankMedia);
  if (!node || typeof node !== 'object') return node;
  const isAllEmptyLoc = (v) => window.isLocObject(v) &&
    Object.values(v).every(s => !String(s).trim());
  const out = {};
  for (const [k, v] of Object.entries(node)) {
    if (v === '' && _BLANK_PRUNE_MEDIA_FIELDS.has(k)) continue;
    if (_BLANK_PRUNE_OPTIONAL_LOC_FIELDS.has(k) && isAllEmptyLoc(v)) continue;
    out[k] = (v && typeof v === 'object') ? pruneBlankMedia(v) : v;
  }
  return out;
}

function toDraftContent(course, settings, layoutDrafts) {
  const drafts = layoutDrafts || {};
  // A module's one-line summary exports as ModuleSchema.info (emitted as
  // <info> in content.xml). Include it only when it has real text — empty
  // LocalizedString entries are stripped so an untouched summary neither
  // ships as blank nor trips the server's translation-coverage gate.
  const buildModuleInfo = (summary) => {
    if (!summary || typeof summary !== 'object' || Array.isArray(summary)) return {};
    const clean = Object.fromEntries(
      Object.entries(summary).filter(([, v]) => v && String(v).trim()));
    return Object.keys(clean).length ? { info: clean } : {};
  };
  // 'blank' courses (anything but the seeded demo course) never get stand-in
  // media — empty media keys are pruned so the schema + UnresolvedMedia gates
  // name exactly what the author still has to provide.
  const blankMode = course.contentMode === 'blank';
  const finishLayout = blankMode ? pruneBlankMedia : substituteStandInMedia;
  // Defensive LocalizedString coercion for module/group titles: creation paths
  // wrap plain strings at the source (handleAddModule), but any legacy or
  // future plain-string title must never 400 the whole save.
  const locTitle = (v) => (v && typeof v === 'object' && !Array.isArray(v)) ? v : { en: v || '' };
  const buildModule = (m) => ({
    // One shared rule, see `schemaModuleId`.
    id: schemaModuleId(m),
    title: locTitle(m.title),                // LocalizedString — coerced
    ...buildModuleInfo(m.summary),
    // The module image, emitted as `<thumbnailURL>`. Included only when the
    // author actually set one — `ModuleSchema.thumbnailUrl` is optional and the
    // emitter skips an absent tag, so an empty string here would ship
    // `<thumbnailURL></thumbnailURL>` and invite a 404 in the Player's image
    // loader. Until Stage 2 this key was dropped entirely, so a real upload never
    // reached the package (campaign backlog item 3).
    ...(typeof m.thumbnailUrl === 'string' && m.thumbnailUrl.trim()
      ? { thumbnailUrl: m.thumbnailUrl } : {}),
    // A liveCourse layout `l` is a UI stub (id/n/status/summary) with NO
    // content fields — the real content lives in the per-mode content base
    // (samples for the demo course, blank skeletons for from-zero courses),
    // with the saved draft as edits on top. Build content-only objects:
    // type defaults ← draft overlay, then drop the UI stub entirely.
    layouts: (m.layouts || []).map(l => {
      const draft = drafts[l.id] || {};
      const type = draft.type || l.type;
      const base = window.layoutContentBase(type, course.contentMode);
      return { ...base, ...draft, type };   // content only — NOT ...l
    }).map(l => normalizeLayoutForSchema(l, { blankMode })).map(finishLayout),
  });

  const groups = course.moduleGroups || [];
  const known = new Set(groups.map(g => g.id));
  const moduleGroups = groups.map(g => ({
    name: locTitle(g.title),                 // LocalizedString — coerced
    color: g.color || '#64748b',
    modules: course.modules.filter(m => m.group === g.id).map(buildModule),
  }));

  // A synthetic group name is OURS, not authored content — but the export's
  // coverage gate (packages/schema findLocalizedCoverageGaps, called from
  // services/gateway/src/routes/export.ts) requires every LocalizedString to
  // carry every enabled language, and the Translate run walks only the REAL
  // `course.moduleGroups`. So `{ en: 'Ungrouped' }` made a course with one
  // ungrouped module 422 on export with NO author-side fix: no screen shows the
  // synthetic group, so nothing can translate it (found 2026-07-28; English-only
  // export succeeded, which is why it hid until a second language was enabled).
  // Fill every enabled language so the export can never become unfixable, and
  // let `validateExport` say the label is ours — an English chapter name in an
  // Italian package must never be a silent surprise.
  const synthGroupName = (label) => Object.fromEntries(
    (course.languages && course.languages.length ? course.languages : ['en'])
      .map(l => [l, label]));

  // Modules with no group (or a group that was deleted) still need a home —
  // the schema requires ≥ 1 group, and we must not silently drop content.
  const orphans = course.modules.filter(m => !m.group || !known.has(m.group));
  if (orphans.length) {
    moduleGroups.push({ name: synthGroupName('Ungrouped'), color: '#64748b',
      modules: orphans.map(buildModule) });
  }
  // Drop groups with no modules — a chapter only reaches the exported package
  // through its modules, and the author's chapters themselves live in
  // `authoringState`, so nothing is lost by leaving them out of this projection.
  //
  // When NOTHING is left, send an empty list and say so honestly. This used to
  // fabricate one group with `modules: []` "to guarantee at least one group
  // exists" — which satisfied `DraftContentSchema.moduleGroups.min(1)` while
  // violating `ModuleGroupSchema.modules.min(1)`, so it built a document that
  // could never validate and 400'd the whole save. Every course created from
  // zero starts in exactly that state, which made the FIRST Save of a new
  // course fail (found 2026-07-29 —
  // wiki/findings/2026-07-29-empty-course-cannot-be-saved). The gateway now
  // accepts an empty skeleton as the legal draft state it is, and refuses the
  // EXPORT by name instead.
  const finalGroups = moduleGroups.filter(g => g.modules.length > 0);

  // Course settings: dftiFlow, assessments and roles are the schema-modelled
  // slices (CourseSettingsSchema is .strict()) — map exactly those; the rest of
  // the prototype settings (metadata, organisations, cover…) stays out until each
  // gets its own schema slice + emitter wiring (FE→ZIP rule, §15).
  //
  // The assessment mapper needs the module list to turn the FE's string module
  // id into the numeric one the schema and the Player use.
  return {
    moduleGroups: finalGroups,
    // EIGHT fragments now (`courseTitle`, `news` and `features` joined
    // 2026-08-14). `mergeCourseSettings` is a SHALLOW spread where the later
    // fragment wins per top-level key, so this is only safe because each
    // fragment owns a DIFFERENT key — `dftiFlow`, `assessments`, `roles`,
    // `brands`, `cover`, `courseTitle`, `news`, `features`. A fragment emitting a
    // key an earlier one already wrote would silently replace it rather than
    // merge into it (`feedback_enumerate_every_caller`);
    // `export-brands-projection.test.ts` asserts they all survive together,
    // which is the check that would catch it.
    ...[
      buildAssessments(settings, course),
      buildRoles(settings, finalGroups),
      buildBrands(settings),
      buildCover(settings),
      buildCourseTitle(settings, course),
      buildNews(settings),
      buildFeatures(settings),
      buildNewToCompanyIntro(settings),
    ].reduce(mergeCourseSettings, buildCourseSettings(settings)),
  };
}

// Map `courseSettings.roles` (FE shape) onto `RolesSchema` (Stage 5 Phase 2b).
//
// Returns `{}` when there are no roles — the same precedent `buildCourseSettings`
// sets for a disabled gaming flow. It matters for two reasons: an empty `roles: []`
// would make every course carry the key for no reason, and any localised field
// inside a slice becomes coverage-gated for every enabled language, so emitting
// role labels for a course with no roles would block bilingual exports over a
// feature nobody switched on.
// >>> CANONICAL-ROLE-PROJECTION — sliced and executed by
// services/gateway/src/routes/export-role-projection.test.ts. The block below is
// plain ES2015 with no React and no imports, so node runs it as-is. Keep it that
// way, or point that test at the new home rather than deleting it.
function buildRoles(settings, finalGroups) {
  const roles = (settings && settings.roles) || [];
  if (!roles.length) return {};

  // ★ The grid speaks 'M3'; the schema speaks 3. `ModuleSchema.id` is the
  // author-visible module NUMBER (`buildModule` sets `id: m.n`), and the FE id is
  // 'M' + that number — so parse it, never derive it from array position. The
  // displayed 'M4' can sit third in the list after a delete or a reorder, so
  // position and id genuinely disagree.
  const knownIds = new Set(
    finalGroups.flatMap(g => (g.modules || []).map(m => m.id)));
  const toNumericId = (feId) => {
    const n = Number(String(feId).replace(/^M/i, ''));
    return Number.isInteger(n) && n > 0 ? n : null;
  };

  return { courseSettings: { roles: roles.map(r => {
    // Keep only languages that actually have text. A key with an empty value is
    // not a translation, and the export gate reports the genuine gaps per
    // enabled language (`feedback_present_vs_has_text`).
    const label = Object.fromEntries(
      Object.entries((r.label && typeof r.label === 'object') ? r.label : {})
        .filter(([, v]) => v && String(v).trim()));
    return {
      code: r.code,
      label,
      // Drop bindings whose module no longer exists — the ordinary residue of
      // deleting a module. Rejecting them would 400 the author's whole course,
      // since PUT /draft is all-or-nothing.
      moduleIds: (r.modules || [])
        .map(toNumericId)
        .filter(id => id !== null && knownIds.has(id)),
      // ★★ THE ROLE→ORGANISATION ASSIGNMENT. Phase 4b.
      //
      // This one line is the data loss Omar's 2026-08-11 report was only half of.
      // The Save button added in PR #129 fixed the DOOR; this function is what
      // walked through it, and it returned `{code, label, moduleIds}` — so an
      // author could assign HR to "Retail", get an honest "Saved to the server",
      // and lose the assignment anyway. PR #129's guard could not catch it: that
      // guard asserts a screen can REACH the server, which is a door-level
      // invariant, not a payload-level one.
      //
      // Spread conditionally rather than always: `brand: undefined` would be a
      // key present with no value, and `RoleSchema` is `.strict()` — absence and
      // emptiness are not the same thing to it, nor to the emitter, which omits
      // the attribute entirely for a course with no organisations
      // (`feedback_absence_and_emptiness_read_the_same`).
      ...(r.brand ? { brand: r.brand } : {}),
    };
  }) } };
}
// <<< CANONICAL-ROLE-PROJECTION

// ── Organisations → the `brands` slice ──────────────────────────────────────
//
// Returns `{}` when there are none, the same precedent `buildRoles` and
// `buildCourseSettings` set: an empty `brands: []` would make every course carry
// the key for no reason, and — because the export's translation gate is derived
// from the SCHEMA rather than a hand-kept list — an organisation label present for
// a course with no organisations would become a coverage-gated field, blocking
// bilingual exports over a feature nobody switched on.
// >>> CANONICAL-BRANDS-PROJECTION — sliced and executed by
// services/gateway/src/routes/export-brands-projection.test.ts. Plain ES2015, no
// React and no imports, so node runs it as-is. Keep it that way, or point that
// test at the new home rather than deleting it.
function buildBrands(settings) {
  const brands = (settings && settings.brands) || [];
  if (!brands.length) return {};
  return { courseSettings: { brands: brands.map(b => {
    // Same has-text filter the role labels use: a key with an empty value is not
    // a translation, and the export gate reports the genuine gaps per enabled
    // language (`feedback_present_vs_has_text`).
    const label = Object.fromEntries(
      Object.entries((b.label && typeof b.label === 'object') ? b.label : {})
        .filter(([, v]) => v && String(v).trim()));
    return {
      code: b.code,
      label,
      // Authoring-only, and deliberately still persisted: the author picked these
      // and they must survive a reload. The emitter never writes them — a hex
      // cannot reach the learner, because theming resolves a CSS folder name and
      // the pinned runtime ships one. Only defined keys are sent, since
      // `BrandSchema` is `.strict()` and `color: undefined` is a present key.
      ...(b.color ? { color: b.color } : {}),
      ...(b.textColor ? { textColor: b.textColor } : {}),
      // ★ Phase 4d — the per-organisation logo, and the ONE line in this file that
      // makes it reach the learner. Same `...(x ? {x} : {})` shape as the two
      // above, for the same `.strict()` reason — but note what is DIFFERENT about
      // it: colour is authoring-only and the emitter drops it, while this really
      // does travel, as CSS keyed on `data-brand` plus the image file itself.
      //
      // This exact line is the one whose omission cost the role→organisation
      // assignment a whole day on 2026-08-12: a projection that silently drops a
      // key persists a 200 and loses the data
      // (`feedback_silent_key_strip_hides_data_loss`). It is asserted by
      // `export-brands-projection.test.ts`, which executes this block for real.
      ...(b.logo ? { logo: b.logo } : {}),
    };
  }) } };
}
// <<< CANONICAL-BRANDS-PROJECTION

// >>> CANONICAL-MERGE-COURSE-SETTINGS — sliced by the same test.
/** Merge two `{ courseSettings: {...} }` fragments; either may be `{}`. */
function mergeCourseSettings(a, b) {
  const merged = { ...(a.courseSettings || {}), ...(b.courseSettings || {}) };
  return Object.keys(merged).length ? { courseSettings: merged } : {};
}
// <<< CANONICAL-MERGE-COURSE-SETTINGS

// Map `courseSettings.assessments` (FE shape) onto `AssessmentsSchema`.
//
// Three things this function exists to get right:
//
//  1. **Module id translation.** The FE binds a group to the STRING id ('M1');
//     `ModuleSchema.id` is the numeric display number, and `buildModule` above
//     exports `m.n`. `<questionGroup module="…">` has to match
//     `<oneModule id="…">` exactly or the group renders zero questions with no
//     error anywhere. A group whose module has been deleted cannot be
//     resolved, so it is dropped here — the surface shows a red banner naming
//     those groups so the author sees it rather than losing work silently.
//  2. **A disabled phase is still SAVED, just marked off.** It would be
//     tempting to omit it — every LocalizedString reaching the server is
//     translation-gated, and a half-drafted pre-assessment left switched off
//     must not 422 a bilingual export over text no learner sees. But omitting
//     it means toggling a phase off DELETES the author's questions from
//     `draft_version.content` on the next save, and nothing ever re-hydrates
//     them from the server. So the phase is persisted with `enabled: false`
//     and the EXPORT ROUTE drops disabled phases before the coverage gate and
//     the packager run (`stripDisabledAssessments` in @dynamo/schema).
//  3. **Empty strings are pruned, not sent.** An untouched feedback box would
//     otherwise count as a missing translation for every language.
// >>> CANONICAL-ASSESSMENT-PROJECTION — sliced and executed by
// services/gateway/src/routes/export-assessment-projection.test.ts (TRACKED, so
// it runs in CI) and by assessment-copy-e2e / generate-questions-e2e (untracked
// harnesses). Self-contained: plain ES2015, no React, no imports, so node runs
// the block as-is. Keep it that way, or point those at the new home rather than
// deleting them.
//
// ⚠️ This header used to name `assessment-copy-core.test.ts` as the CI consumer.
// It never sliced this block — grep for the marker returns only the two
// harnesses — so the comment asserted a control that did not exist, which is
// worse than no comment (`feedback_a_skipped_test_is_a_control_that_isnt_running`).
// Corrected 2026-08-10 when Phase 3a added a field here and found nothing in CI
// would have caught a mistake in it.
/**
 * The SCHEMA's numeric module id for a live-course module.
 *
 * The frontend's course model gives every module a string id (`'M1'`) for React keys and a
 * 1-based display number `n`; the schema, the emitter and the Player all use the NUMBER
 * (`ModuleSchema.id`). This one line is the bridge.
 *
 * ★ Extracted 2026-08-13, when "Module preview" needed the same conversion and would have
 * been its THIRD copy — the two existing ones already carried a comment pointing at each
 * other, which is the tell that a rule wants a name (`feedback_one_rule_one_place`). A third
 * copy in another file is how the three drift, and a preview scoped to the wrong module is a
 * bug the author would read as "the preview is broken", not as "the id was wrong".
 */
function schemaModuleId(m) {
  return m && typeof m.n === 'number' ? m.n : m && m.id;
}

function buildAssessments(settings, course) {
  const a = settings && settings.assessments;
  if (!a) return {};

  // FE module id ('M1') → schema module id, through the shared `schemaModuleId`.
  const numericId = new Map(
    (course.modules || []).map(m => [m.id, schemaModuleId(m)]));

  const loc = (v) => {
    if (!v || typeof v !== 'object' || Array.isArray(v)) {
      return (typeof v === 'string' && v.trim()) ? { en: v } : null;
    }
    const clean = Object.fromEntries(
      Object.entries(v).filter(([, x]) => x && String(x).trim()));
    return Object.keys(clean).length ? clean : null;
  };
  // Required LocalizedStrings always ship — blank ones must reach the coverage
  // gate so it names the field, rather than vanishing and looking complete.
  const required = (v) => loc(v) || { en: '' };

  const phase = (p) => {
    // Nothing to preserve in a phase that is off AND empty — omit it so an
    // untouched course carries no assessment noise. A phase that is off but
    // HAS questions is kept (see note 2 above).
    if (!p || (p.enabled !== true && (p.groups || []).length === 0)) return undefined;
    const groups = (p.groups || [])
      .filter(g => numericId.has(g.moduleId))
      .map(g => {
        const questions = (g.questions || []).map(q => {
          const answers = (q.answers || []).map(ans => {
            const note = loc(ans.wrongFeedback);
            return {
              text: required(ans.text),
              ...(ans.correct === true ? { correct: true } : {}),
              ...(note ? { wrongFeedback: note } : {}),
            };
          });
          const correctFb = loc(q.correctFeedback);
          const wrongFb = loc(q.wrongFeedback);
          // Per-question Role tags (Stage 5 Phase 3a). Forwarded FAITHFULLY,
          // because the absent-vs-empty distinction carries the whole meaning:
          //   absent → every Role (the back-compatible default, emitted "all")
          //   []     → nobody (emitted "", inert at runtime, warned about)
          // An empty array is TRUTHY in JS, so `[]` survives the spread below and
          // stays distinguishable from `null`. That is load-bearing, not incidental
          // (`feedback_absence_and_emptiness_read_the_same`).
          //
          // Dangling codes, duplicates, case and the `all` decision are all left
          // to `questionRoles` in the emitter — ONE owner for the emission rules,
          // so this projection cannot disagree with the export gate
          // (`feedback_one_rule_one_place`).
          const roleTags = Array.isArray(q.roles)
            ? q.roles.filter(r => typeof r === 'string' && r.trim()).map(r => r.trim())
            : null;
          return {
            prompt: required(q.prompt),
            answers,
            ...(correctFb ? { correctFeedback: correctFb } : {}),
            ...(wrongFb ? { wrongFeedback: wrongFb } : {}),
            ...(roleTags ? { roles: roleTags } : {}),
          };
        });
        return {
          moduleId: numericId.get(g.moduleId),
          questionsShown: Math.max(1, Math.min(
            Number(g.questionsShown) || 1, Math.max(1, questions.length))),
          questions,
        };
      });
    return { enabled: p.enabled === true, groups };
  };

  const pre = phase(a.pre);
  const post = phase(a.post);
  // Nothing authored at all → omit the slice entirely, so a course that never
  // touched Assessments carries no assessment key.
  if (!pre && !post) return {};
  return { courseSettings: { assessments: {
    randomiseGroupOrder: a.randomiseGroupOrder === true,
    ...(pre ? { pre } : {}),
    ...(post ? { post } : {}),
  } } };
}
// <<< CANONICAL-ASSESSMENT-PROJECTION

// Map the FE `courseSettings.dftiFlow` slice (data.jsx shape — field names
// mirror DftiFlowSchema exactly) into DraftContent.courseSettings, so the
// emitter flips course_template 1→5 and emits the Profile-form / intro
// labels. Only included when the gaming flow is ON: when off, the labels
// would still be coverage-gated for every enabled language, blocking
// bilingual exports over a disabled feature.
function buildCourseSettings(settings) {
  const f = settings && settings.dftiFlow;
  if (!f || f.enabled !== true) return {};
  // Keep only non-empty per-language values; the export coverage gate
  // reports genuinely missing translations per enabled language.
  const loc = (v) => {
    if (!v || typeof v !== 'object' || Array.isArray(v)) return { en: '' };
    const clean = Object.fromEntries(
      Object.entries(v).filter(([, x]) => x && String(x).trim()));
    return Object.keys(clean).length ? clean : { en: '' };
  };
  // DftiFlowSchema wants BARE image paths — the Player auto-prefixes
  // `content/` at runtime, so a stored `content/…` would 404 as
  // `content/content/…` (per feedback_player_path_prefix_per_field).
  const img = (v) => typeof v === 'string' ? v.replace(/^content\//, '') : '';
  return { courseSettings: { dftiFlow: {
    enabled: true,
    profileSetupDescription: loc(f.profileSetupDescription),
    profileSetupFirstName: loc(f.profileSetupFirstName),
    profileSetupLastName: loc(f.profileSetupLastName),
    profileSetupEmail: loc(f.profileSetupEmail),
    dftiInfoTitle: loc(f.dftiInfoTitle),
    dftiInfoLeftText: loc(f.dftiInfoLeftText),
    dftiInfoRightText: loc(f.dftiInfoRightText),
    dftiLeftImg: img(f.dftiLeftImg),
    dftiRightImg: img(f.dftiRightImg),
  } } };
}

// >>> CANONICAL-COVER-PROJECTION — sliced and executed by
// `routes/export-cover-projection.test.ts`. This file is parsed by Babel in the
// browser and has no build step, so that test is the only thing that ever runs
// this function outside a real session.
// Map `courseSettings.cover` (FE shape) onto `CoverSchema`.
//
// Unlike `buildCourseSettings`, this does NOT bail out when the feature is
// switched off. A disabled cover still travels — carrying the image the author
// uploaded and the sentence they wrote — because omitting a slice DELETES it from
// the draft, and the author would lose that work the moment they flipped the
// toggle off and saved (`feedback_park_a_disabled_slice_dont_omit_it`). The
// gateway strips a disabled cover on the EXPORT path instead
// (`stripDisabledCover`), so parking it here costs the package nothing.
//
// The one case that still returns `{}` is a cover nobody has ever touched: off,
// no images, no sentence. That keeps a course that never opened this panel
// emitting byte-identically to before the feature existed.
function buildCover(settings) {
  const c = settings && settings.cover;
  if (!c || typeof c !== 'object') return {};
  // CoverSchema wants BARE image paths for the same reason DftiFlowSchema does —
  // the Player concatenates the literal `content/` itself, so a stored
  // `content/…` would 404 as `content/content/…`
  // (`feedback_player_path_prefix_per_field`).
  const img = (v) => (typeof v === 'string' ? v.replace(/^content\//, '') : '');
  // Drop empty per-language slots so an untouched sentence is ABSENT rather than
  // a `{en: ''}` husk. The two read the same to an author but not to the export
  // gate, and `sentence` is `.optional()` precisely so absence is legal
  // (`feedback_present_vs_has_text`).
  const sentence = (v) => {
    if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
    const clean = Object.fromEntries(
      Object.entries(v).filter(([, x]) => x && String(x).trim()));
    return Object.keys(clean).length ? clean : null;
  };
  const enabled = c.enabled === true;
  const coverBgImgUrl = img(c.coverBgImgUrl);
  const coverLogoImgUrl = img(c.coverLogoImgUrl);
  const sent = sentence(c.sentence);
  if (!enabled && !coverBgImgUrl && !coverLogoImgUrl && !sent) return {};
  return { courseSettings: { cover: {
    enabled,
    ...(coverBgImgUrl ? { coverBgImgUrl } : {}),
    ...(coverLogoImgUrl ? { coverLogoImgUrl } : {}),
    ...(sent ? { sentence: sent } : {}),
  } } };
}
// <<< CANONICAL-COVER-PROJECTION

// >>> CANONICAL-WELCOME-PROJECTION — sliced and executed by
// `routes/export-welcome-projection.test.ts`, the same way the cover block above
// is. Plain ES2015, no React, no imports.
//
// The three slices Omar's 2026-08-14 "finalise Course settings" pass added.

/**
 * Map the localised course title onto `courseSettings.courseTitle`.
 *
 * ★ The `course` ROW's title stays the fallback and is NOT replaced by this: it
 * still names the ZIP, the imsmanifest and the courses list, and `emit-ui-xml`
 * uses it for any language this map has no entry for. What this adds is the
 * per-language `<label_course_title>` — the string the Player shows in the
 * header on every screen after the language picker, in the browser tab, and as
 * the cover headline. Before it, a course translated into Italian still greeted
 * the learner with an English title (Omar, 2026-08-14).
 *
 * Empty slots are dropped so an untouched language is ABSENT rather than a
 * `{it: ''}` husk — absence falls back to the row title, an empty string would
 * ship a header with no course name (`feedback_present_vs_has_text`).
 *
 * Returns `{}` when there is nothing to say, so a course nobody has renamed
 * emits exactly what it emitted before this slice existed.
 */
function buildCourseTitle(settings, course) {
  const map = settings && settings.courseTitle;
  const primary = (course && course.defaultLanguage) || 'en';
  const clean = {};
  if (map && typeof map === 'object' && !Array.isArray(map)) {
    for (const [lang, value] of Object.entries(map)) {
      if (value && String(value).trim()) clean[lang] = value;
    }
  }
  // The default-language entry is the SOURCE the Localisation surface translates
  // from, so it must be present whenever the author has titled the course at
  // all. Course settings writes it on every keystroke; this backfills it from
  // the same place the `course` row gets its title, for a course whose title
  // predates this slice.
  if (!clean[primary]) {
    const rowTitle = (settings && settings.metadata && settings.metadata.title
      && settings.metadata.title.en) || (course && course.title) || '';
    if (String(rowTitle).trim()) clean[primary] = rowTitle;
  }
  return Object.keys(clean).length ? { courseSettings: { courseTitle: clean } } : {};
}

/**
 * Map `courseSettings.news` (FE shape) onto `NewsSchema`.
 *
 * Parked-not-omitted exactly like the cover: a switched-off News screen still
 * travels with its headline and message, so toggling it off and saving does not
 * delete the author's text (`feedback_park_a_disabled_slice_dont_omit_it`). The
 * gateway's `stripDisabledNews` removes it on the EXPORT path, which is what
 * keeps a parked English-only message from being reported as a missing Italian
 * translation.
 *
 * `{}` only for a News screen nobody has ever touched — off, no title, no
 * message — so a course that never opened the panel exports byte-identically.
 */
function buildNews(settings) {
  const n = settings && settings.news;
  if (!n || typeof n !== 'object') return {};
  const loc = (v) => {
    if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
    const clean = Object.fromEntries(
      Object.entries(v).filter(([, x]) => x && String(x).trim()));
    return Object.keys(clean).length ? clean : null;
  };
  const enabled = n.enabled === true;
  const title = loc(n.title);
  const message = loc(n.message);
  if (!enabled && !title && !message) return {};
  return { courseSettings: { news: {
    enabled,
    ...(title ? { title } : {}),
    ...(message ? { message } : {}),
  } } };
}

/**
 * Map the three WIRED screen toggles onto `courseSettings.features`.
 *
 * ★ Only the three. `settings.featureFlags` carries a dozen keys, most of which
 * reach nothing — `CourseFeaturesSchema` is `.strict()`, so sending the whole
 * object would 400 every save. Listing them here rather than filtering by the
 * meta table keeps the projection readable by the test that slices this file,
 * which has no `window.FEATURE_FLAG_META` to consult.
 *
 * Every field is omitted when false, so a course with nothing switched on emits
 * no `features` key at all — and absent means off everywhere downstream.
 */
function buildFeatures(settings) {
  const f = (settings && settings.featureFlags) || {};
  const out = {};
  if (f.sequentialModules === true) out.sequentialModules = true;
  if (f.blockingByDefault === true) out.blockingByDefault = true;
  if (f.haveNewToCompany === true) out.newToCompany = true;
  return Object.keys(out).length ? { courseSettings: { features: out } } : {};
}

/**
 * Map the author's "new to company" intro OVERRIDES onto
 * `NewToCompanyIntroSchema`.
 *
 * ★ AN EMPTY RESULT IS THE HEALTHY ONE, which inverts the usual reading of this
 * file. The 15 bundled UI.xml templates carry professional translations of all
 * four blocks, so a course that never opens the panel must send NOTHING and get
 * that text. Sending `{title: {en: ''}}` would be worse than useless: an empty
 * per-language husk is a value the emitter would have to decide about, and a
 * blank one there means a blank heading on the learner's screen
 * (`feedback_present_vs_has_text`).
 *
 * So blank slots are dropped, then a field with no slots left is dropped, then
 * an intro with no fields left disappears entirely. `NewToCompanyIntroSchema` is
 * `.strict()`, and `PUT /draft` is all-or-nothing, so a stray key here would 400
 * the author's whole course (`feedback_all_or_nothing_draft_validation`).
 *
 * NOT gated on the toggle, deliberately — the opposite of `buildFeatures`. An
 * author can rewrite the intro, switch the screen off while they think, and
 * switch it back on; omitting the slice here would delete their words in between
 * (`feedback_park_a_disabled_slice_dont_omit_it`). The gateway's
 * `stripDisabledNewToCompanyIntro` hides it from the export gate for exactly as
 * long as the screen is off, which is the half that has to be conditional.
 */
function buildNewToCompanyIntro(settings) {
  const i = settings && settings.newToCompanyIntro;
  if (!i || typeof i !== 'object') return {};
  const out = {};
  // TWO fields since 2026-08-14. `intro` and `footnote` were retired when the
  // screen's authoring collapsed to Headline + Message; not projecting them is what
  // makes a draft written during the day they existed self-heal on the next save,
  // while `dropRetiredNewToCompanyBlocks` covers the export until then.
  ['title', 'body'].forEach(function (key) {
    const v = i[key];
    if (!v || typeof v !== 'object' || Array.isArray(v)) return;
    const clean = Object.fromEntries(
      Object.entries(v).filter(([, x]) => x && String(x).trim()));
    if (Object.keys(clean).length) out[key] = clean;
  });
  return Object.keys(out).length
    ? { courseSettings: { newToCompanyIntro: out } }
    : {};
}
// <<< CANONICAL-WELCOME-PROJECTION

function relTime(ts) {
  const diff = Date.now() - ts;
  const min = Math.floor(diff / 60000);
  if (min < 1) return 'just now';
  if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
  const hr = Math.floor(min / 60);
  if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
  const d = new Date(ts);
  return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}

// A pseudo runtime hash so the prototype has a stable-looking debug signal.
const CURRENT_RUNTIME_HASH = '8ba242ea9c1d4f7b';
function shortHash(h) { return (h || 'unknown').slice(0, 8); }

// Client-side validation pass (Fix 2). In production this would run each
// layout draft through `LayoutContentSchema.safeParse`; that schema isn't
// available in the prototype, so we fall back to manual required-field checks
// over the drafts that exist + structural course-level invariants.
//
// It does NOT fold in cross-surface invariants for features the author has
// not switched on, so a fresh course validates clean and the happy path can
// build. An ENABLED assessment is different: it will 422 at the server, and
// the round trip is slow and its message is a raw list of paths. Mirror the
// server's `findAssessmentIssues` here so the author is told before building.
/** True when a LocalizedString (or a legacy plain string) carries real text. */
function assessmentHasText(v) {
  if (typeof v === 'string') return v.trim() !== '';
  if (!v || typeof v !== 'object') return false;
  return Object.values(v).some(x => typeof x === 'string' && x.trim() !== '');
}

function validateAssessments(course, settings) {
  const out = [];
  const a = settings && settings.assessments;
  if (!a) return out;
  const byId = new Map((course.modules || []).map(m => [m.id, m]));

  for (const which of ['pre', 'post']) {
    const phase = a[which];
    if (!phase || phase.enabled !== true) continue;
    const label = which === 'pre' ? 'Pre-assessment' : 'Post-assessment';
    const groups = (phase.groups || []).filter(g => byId.has(g.moduleId));
    const dropped = (phase.groups || []).length - groups.length;
    if (dropped > 0) {
      out.push({ id: `assess-${which}-orphan`, level: 'error', surface: 'assessments',
        message: `${label}: ${dropped} question group${dropped === 1 ? '' : 's'} point at a module that no longer exists. Remove them on the Assessments screen.` });
    }
    if (!groups.some(g => (g.questions || []).length > 0)) {
      out.push({ id: `assess-${which}-empty`, level: 'error', surface: 'assessments',
        message: `${label} is switched on but has no questions. Add questions, or switch it off.` });
      continue;
    }
    const seen = new Set();
    groups.forEach(g => {
      const name = locText((byId.get(g.moduleId) || {}).title) || g.moduleId;
      if (seen.has(g.moduleId)) {
        out.push({ id: `assess-${which}-dup-${g.moduleId}`, level: 'error', surface: 'assessments',
          message: `${label}: “${name}” has more than one question group. Merge them into one.` });
      }
      seen.add(g.moduleId);
      const qs = g.questions || [];
      if (!qs.length) return;
      if ((g.questionsShown || 1) > qs.length) {
        out.push({ id: `assess-${which}-count-${g.moduleId}`, level: 'error', surface: 'assessments',
          message: `${label}: “${name}” is set to ask ${g.questionsShown} questions but only has ${qs.length}.` });
      }
      qs.forEach((q, qi) => {
        const answers = q.answers || [];
        // Blank text has to be caught HERE, not left to the server. Without it
        // an untouched "Add question" row (empty prompt, two empty answers)
        // cleared every check, showed the author "No blockers", and then came
        // back from the build as a "missing translations" 422 listing Zod
        // paths — on an English-only course.
        if (!assessmentHasText(q.prompt)) {
          out.push({ id: `assess-${which}-${g.moduleId}-q${qi}-prompt`, level: 'error', surface: 'assessments',
            message: `${label}: “${name}” question ${qi + 1} has no question text yet.` });
        }
        if (answers.length < 2) {
          out.push({ id: `assess-${which}-${g.moduleId}-q${qi}-answers`, level: 'error', surface: 'assessments',
            message: `${label}: “${name}” question ${qi + 1} needs at least two answer options.` });
        }
        answers.forEach((a, ai) => {
          if (!assessmentHasText(a.text)) {
            out.push({ id: `assess-${which}-${g.moduleId}-q${qi}-a${ai}`, level: 'error', surface: 'assessments',
              message: `${label}: “${name}” question ${qi + 1}: answer ${String.fromCharCode(65 + ai)} is empty.` });
          }
        });
        if (!answers.some(x => x.correct === true)) {
          out.push({ id: `assess-${which}-${g.moduleId}-q${qi}-correct`, level: 'error', surface: 'assessments',
            message: `${label}: “${name}” question ${qi + 1} has no correct answer ticked.` });
        }
      });
    });
  }
  return out;
}

// ── "Nothing to build yet" — the canonical wordings ──────────────────────────
// ONE RULE, THREE SURFACES. These sentences are said by `validateExport` below
// (before any request — its errors disable the Build button, so this is the copy
// an author normally reads), by `humanizeSchemaFailure` when a schema rejection
// has to be translated, and by the gateway's own emptiness gate for every
// non-browser client. The gateway cannot import this file, so the two copies are
// held equal by a test instead of a comment:
//   services/gateway/src/routes/export.ts        (EMPTY_COURSE_MESSAGE et al.)
//   services/gateway/src/routes/export-emptiness-copy.test.ts
// Three different wordings with three different remedies had already drifted
// apart when the adversarial review found them (2026-07-29). Change one, change
// all three, and the test will tell you if you forgot.
// >>> CANONICAL-EMPTINESS-COPY — do not move or rename these markers.
// `export-emptiness-copy.test.ts` slices the block between them, evaluates it, and
// compares the RESULTING STRINGS with the gateway's. Value-level, not substring:
// how the literals are wrapped across lines is free, what they say is not.
const EMPTY_COURSE_TEXT =
  'This course has no modules yet — add at least one module, with at least one ' +
  'screen in it, before building.';
const emptyChapterText = (n) =>
  `Chapter ${n} has no modules yet — add a module, or delete the chapter, before building.`;
const emptyModuleText = (n, title) =>
  `Module ${n}${title ? ` (${title})` : ''} has no screens yet — ` +
  `add a screen, or delete the module, before building.`;
// <<< CANONICAL-EMPTINESS-COPY

// ── Turning a schema rejection into a sentence ────────────────────────────────
// When the gateway refuses a save or an export on schema grounds it reports its
// Zod message: a JSON array of issue objects, e.g.
//   [ { "code": "too_small", "type": "array", "minimum": 1,
//       "path": [ "moduleGroups", 0, "modules" ] } ]
// Accurate, and unreadable — that dump is literally what the app showed under
// "Not saved:" (2026-07-29). Rewrite it as a location plus a plain problem.
//
// Anything that is not a Zod dump is returned untouched, so a network error, a
// 413, a 409 or a role refusal keeps the message its own code chose. The raw
// text goes to the console once, so nothing is hidden from a developer.
// The fields a half-filled layout actually reports. Keep this list ahead of what
// authors hit: an unmapped key falls through as the raw schema name, which is the
// developer output this function exists to remove — the first version had no entry
// for `blockingSection`, the single most common "is missing" in a fresh layout
// (adversarial review, 2026-07-29).
const _FRIENDLY_FIELD = {
  moduleGroups: 'chapters', modules: 'modules', layouts: 'screens',
  titleMain: 'title', titleSub: 'subtitle', text: 'body text', info: 'summary',
  backgroundImageUrl: 'background image', imageUrl: 'image', videoUrl: 'video',
  image360Url: '360° image', videoThumbUrl: 'video poster',
  subtitlesUrl: 'subtitles', transcriptUrl: 'transcript',
  objectUrl: '3D model', objectPosterImgUrl: '3D model poster',
  objectEnvImgUrl: '3D model backdrop', htmlUrl: 'HTML template',
  itemImageUrl: 'item image', itemVideoUrl: 'item video',
  itemVideoThumbUrl: 'item video poster', thumbnailUrl: 'module image',
  questions: 'questions', answers: 'answers', tabs: 'steps', rows: 'rows',
  icons: 'icons', items: 'items', hotspots: 'hotspots',
  blockingSection: 'the continue-gate settings',
  titleTextColor: 'title colour', textColor: 'text colour', color: 'colour',
  backgroundColor: 'background colour', gamingHeader: 'the score header',
  gamingScreens: 'the game screens', feedback: 'feedback text',
  feedbackText: 'feedback text', correctAnswersNeeded: 'pass mark',
  imgUrlCorrect: 'correct-answer icon', imgUrlWrong: 'wrong-answer icon',
  bgImgUrl: 'background image', tabTitle: 'step title', type: 'layout type',
};
/**
 * Own-property lookup. A plain `map[key]` reads up the prototype chain, so a path
 * segment called `constructor` or `toString` printed a function's source into the
 * author's error message (adversarial review, 2026-07-29).
 */
function _lookup(map, key) {
  return Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined;
}
function humanizeSchemaFailure(message) {
  const text = String(message == null ? '' : message);
  const open = text.indexOf('[');
  if (open < 0) return text;
  let issues = null;
  try { issues = JSON.parse(text.slice(open)); } catch (e) { return text; }
  if (!Array.isArray(issues) || !issues.length) return text;
  // Every entry must be a plain object, or this is not a Zod issue list and we
  // have no business rewriting it — hand back the original text rather than
  // throwing. A throw here is worse than an ugly message: at the export call site
  // it aborts the same try block that surfaces `details.gaps` /
  // `details.assessmentIssues`, so the author would lose the itemized list this
  // feature exists to show (found by the independent test agent, 2026-07-29).
  if (!issues.every(i => i && typeof i === 'object' && !Array.isArray(i))) return text;
  // An entry with no `code` is not something this function can describe; its
  // generic branch produced the literal word "undefined". Hand back the raw text.
  if (!issues.every(i => typeof i.code === 'string' && i.code)) return text;
  if (typeof console !== 'undefined' && console.warn) {
    console.warn('[dynamo] schema rejection, raw:', text);
  }

  const STRUCT = { moduleGroups: 'Chapter', modules: 'Module', layouts: 'Screen' };
  const describe = (iss) => {
    const path = Array.isArray(iss.path) ? iss.path : [];
    // The two skeleton shapes get a real sentence — "moduleGroups needs at least
    // 1 item" is not English. GATED ON THE CODE, not on the path alone: keyed on
    // the path only, these fired for a wrong-typed or absent `moduleGroups` too,
    // so they described a malformed document as an empty course and offered
    // "delete the chapter" as the remedy for a chapter that might be full
    // (adversarial review, 2026-07-29). Same wording as the gateway's gate and
    // `validateExport` below — see EMPTY_COURSE_MESSAGE in
    // services/gateway/src/routes/export.ts and the copy test beside it.
    const emptyArray = iss.code === 'too_small' && iss.minimum === 1;
    if (emptyArray && path.length === 1 && path[0] === 'moduleGroups') {
      return EMPTY_COURSE_TEXT;
    }
    if (emptyArray && path.length === 3 && path[0] === 'moduleGroups' && path[2] === 'modules') {
      return emptyChapterText(Number(path[1]) + 1);
    }
    const loc = [], field = [];
    for (let i = 0; i < path.length; i++) {
      const seg = path[i], next = path[i + 1];
      const struct = _lookup(STRUCT, seg);
      if (struct && typeof next === 'number') { loc.push(`${struct} ${next + 1}`); i++; }
      else if (typeof seg === 'number') field.push(`#${seg + 1}`);
      else field.push(_lookup(_FRIENDLY_FIELD, seg) || String(seg));
    }
    const where = loc.length ? loc.join(' → ') : 'This course';
    const what = field.join(' ');
    let problem;
    if (iss.code === 'invalid_type' && iss.received === 'undefined') {
      problem = `${what || 'a required field'} is missing`;
    } else if (iss.code === 'invalid_type') {
      const kind = { string: 'text', number: 'a number', boolean: 'a yes/no value',
        array: 'a list', object: 'a group of fields' };
      problem = `${what || 'a field'} should be ` +
        `${_lookup(kind, iss.expected) || iss.expected}, not ` +
        `${_lookup(kind, iss.received) || iss.received}`;
    } else if (iss.code === 'too_small' && iss.type === 'array') {
      problem = typeof iss.minimum === 'number'
        ? `${what || 'a list'} needs at least ${iss.minimum} ` +
          `item${iss.minimum === 1 ? '' : 's'}`
        : `${what || 'a list'} does not have enough items yet`;
    } else if (iss.code === 'unrecognized_keys') {
      const keys = Array.isArray(iss.keys) ? iss.keys : [];
      problem = `unexpected field${keys.length === 1 ? '' : 's'}: ${keys.join(', ')}`;
    } else {
      problem = (what ? `${what}: ` : '') + String(iss.message || iss.code);
    }
    return `${where} — ${problem}`;
  };

  const shown = issues.slice(0, 3).map(describe);
  const more = issues.length > shown.length
    ? ` (and ${issues.length - shown.length} more)` : '';
  return shown.join('; ') + more;
}
// Reachable from app.jsx's save path (this file loads first) and from harnesses.
window.dynamoHumanizeSchemaFailure = humanizeSchemaFailure;

function validateExport(course, settings, drafts) {
  const errors = [], warnings = [];
  const langs = course.languages || ['en'];
  const defLang = settings?.metadata?.defaultLanguage || course.defaultLanguage || 'en';

  const idx = {};
  course.modules.forEach(m => m.layouts.forEach(l => { idx[l.id] = { m, l }; }));
  const where = lid => { const o = idx[lid]; return o ? `Module ${o.m.n} / Layout ${o.l.n}` : lid; };

  // ── Only layouts the course STILL HAS may be validated ────────────────────
  // Deleting a layout does not remove its authored content: `handleDeleteLayout`
  // (app.jsx) records the id in `deletedLayoutIds` and `liveCourse` filters it
  // out of `course.modules`, while `layoutDrafts` keeps the entry — deliberately,
  // it is the only copy of that content and nothing should silently destroy it.
  //
  // But the two walks below read the DRAFTS MAP, and `toDraftContent` builds the
  // package from `course.modules`. So the validator judged layouts the exporter
  // will never look at: Omar deleted a quiz_gaming layout on 2026-07-30 and the
  // Export screen still warned about its pass threshold, labelled with a raw
  // internal id (`M1-Lxms6dv6bu`) because `where()` could not find it — and
  // "Jump to" then landed on an unrelated layout. Worse than the noise he saw:
  // this pass also emits BLOCKING errors (missing title, missing video), so a
  // deleted layout could block every export permanently, with no layout left to
  // open and fix, surviving reloads because both the drafts and the tombstones
  // are persisted.
  //
  // Guarded by `export-validate-orphans.test.ts`, which asserts on the INVARIANT
  // — no finding may name a layout absent from the course — rather than on this
  // particular pair of call sites, because a third walk added later would
  // otherwise reintroduce the bug silently.
  const liveDrafts = {};
  Object.keys(drafts || {}).forEach(lid => { if (idx[lid]) liveDrafts[lid] = drafts[lid]; });

  // Same sentences the gateway's gate uses — see the canonical wordings above.
  if (!course.modules.length) {
    errors.push({ id: 'no-modules', level: 'error', surface: 'draft',
      message: EMPTY_COURSE_TEXT });
  }
  course.modules.forEach(m => {
    if (!m.layouts.length) {
      errors.push({ id: 'empty-' + m.id, level: 'error', surface: 'draft', moduleId: m.id,
        message: emptyModuleText(m.n, locText(m.title)) });
    }
  });
  // A chapter with no modules — a WARNING here, deliberately not an error, and
  // deliberately NOT the gateway's sentence. `toDraftContent` drops empty chapters
  // from the export projection, so the build genuinely succeeds and telling the
  // author to fix something "before building" would be false. What is true is that
  // the chapter they can see will not be in the package. The gateway says the
  // stronger thing because content that still CONTAINS an empty chapter (from any
  // other client) cannot satisfy `ModuleGroupSchema.modules.min(1)`.
  //
  // Two different facts, so two different sentences. `export-emptiness-copy.test.ts`
  // asserts these do NOT converge, so a future tidy-up cannot quietly make this one
  // lie. Skipped entirely when the course has no modules at all: there is no package
  // for the chapter to be missing from, and the blocking error above already says
  // the only thing worth saying.
  if (course.modules.length) {
    (course.moduleGroups || []).forEach((g, gi) => {
      if (course.modules.some(m => m.group === g.id)) return;
      warnings.push({ id: 'empty-chapter-' + g.id, level: 'warning', surface: 'draft',
        message: `Chapter ${gi + 1} (${locText(g.title) || 'untitled'}) has no modules, ` +
          `so it will not appear in the package. Add a module, or delete the chapter.` });
    });
  }
  if (!langs.includes(defLang)) {
    errors.push({ id: 'def-lang', level: 'error', surface: 'localisation',
      message: `Default language “${defLang}” is not in the enabled languages.` });
  }

  // Ungrouped modules ship in a chapter WE name, in English, in every language —
  // see the `synthGroupName` note in toDraftContent. A warning, not an error:
  // the export succeeds, but the author must be told whose words those are.
  const knownGroupIds = new Set((course.moduleGroups || []).map(g => g.id));
  const ungrouped = course.modules.filter(m => !m.group || !knownGroupIds.has(m.group));
  if (ungrouped.length) {
    warnings.push({ id: 'ungrouped-modules', level: 'warning', surface: 'draft',
      message: `${ungrouped.length} module${ungrouped.length === 1 ? '' : 's'} ` +
        `(${ungrouped.map(m => `Module ${m.n}`).join(', ')}) ` +
        `${ungrouped.length === 1 ? 'is' : 'are'} not in a chapter, so ` +
        `${ungrouped.length === 1 ? 'it' : 'they'} will ship in a chapter named ` +
        `“Ungrouped” — in English, in every language. Drag ` +
        `${ungrouped.length === 1 ? 'it' : 'them'} into a chapter to name it yourself.` });
  }

  // Rich text carrying markup the previews will not render. The editor now
  // sanitises everything it stores (rich-text-sanitize.js), but content authored
  // before that — or arriving by another route, e.g. machine translation — can
  // still hold tags, and the package ships them verbatim to the learner's
  // browser. Report it and name the field; do NOT quietly rewrite the author's
  // words on their behalf.
  if (window.richTextFindings) {
    const dirty = [];
    const scan = (v, at) => {
      if (typeof v === 'string') {
        const f = window.richTextFindings(v);
        if (f.length) dirty.push({ at, kinds: f.map(x => `${x.kind}=${x.detail}`) });
        return;
      }
      if (Array.isArray(v)) return v.forEach(x => scan(x, at));
      if (v && typeof v === 'object') return Object.keys(v).forEach(k => scan(v[k], at));
    };
    Object.keys(liveDrafts).forEach(lid => scan(liveDrafts[lid], where(lid)));
    if (dirty.length) {
      const kinds = [...new Set(dirty.flatMap(d => d.kinds))].slice(0, 6);
      warnings.push({
        id: 'unsafe-rich-text', level: 'warning', surface: 'draft',
        message: `${dirty.length} text field${dirty.length === 1 ? '' : 's'} contain HTML ` +
          `the previews will not render (${kinds.join(', ')}), starting at ${dirty[0].at}. ` +
          `The package still ships the original markup. Open the field and retype the ` +
          `formatting to clear it.`,
      });
    }
  }

  const titleTypes = ['fullscreen_text_and_image', 'title', 'two_columns_text'];
  const videoTypes = ['fullscreen_video', 'small_video'];
  const blankMode = course.contentMode === 'blank';

  // Blank-mode media checks walk EVERY layout (merged base + draft), because a
  // never-opened layout has no draft entry yet still ships its skeleton — and
  // in blank mode there is no stand-in media to fall back on.
  if (blankMode) {
    course.modules.forEach(m => (m.layouts || []).forEach(l => {
      const d = (drafts || {})[l.id] || {};
      const type = d.type || l.type;
      const c = { ...window.layoutContentBase(type, 'blank'), ...d };
      const missing = (v) => !v || (typeof v === 'string' && (!v.trim() || v.startsWith('placeholder:')));
      if (videoTypes.includes(type) && missing(c.videoUrl)) {
        errors.push({ id: 'video-' + l.id, level: 'error', surface: 'layout', layoutId: l.id,
          field: 'videoUrl', message: `${where(l.id)}: upload a video before building.` });
      }
      if (type === 'hidden_items' && missing(c.imageUrl) && missing(c.image360Url)) {
        errors.push({ id: 'scene-' + l.id, level: 'error', surface: 'layout', layoutId: l.id,
          field: 'imageUrl', message: `${where(l.id)}: upload the scene image before building.` });
      }
      if (type === 'object_viewer' && missing(c.objectUrl)) {
        errors.push({ id: 'object-' + l.id, level: 'error', surface: 'layout', layoutId: l.id,
          field: 'objectUrl', message: `${where(l.id)}: upload the 3D model before building.` });
      }
      // A WARNING, deliberately not an error (Omar, 2026-08-05). The schema used
      // to require this and the export 400'd on it — on both surfaces, since the
      // gateway validates the same schema. But the shipped Player omits the
      // background-image style entirely when the value is absent, so the screen
      // renders as title + text on a plain background. That is a design the
      // author may well have chosen, and a build must not be blocked for it.
      //
      // Narrowed 2026-08-05, same day: a background COLOUR is now authorable and
      // renders, so "no image" is no longer evidence that anything is missing.
      // Warning on it regardless would nag every author who deliberately picked
      // a colour — and a warning that fires on the intended case teaches people
      // to ignore warnings (`feedback_filter_at_the_source_not_in_the_presenter`).
      // It now fires only when NEITHER is set, which is the one case where the
      // screen falls through to whatever the course shell happens to be.
      if (type === 'fullscreen_text_and_image'
          && missing(c.backgroundImageUrl) && missing(c.contentBackgroundColor)) {
        warnings.push({ id: 'bg-' + l.id, level: 'warning', surface: 'layout', layoutId: l.id,
          field: 'backgroundImageUrl',
          message: `${where(l.id)}: no background image and no background colour — this screen will build on the course's default background.` });
      }
      if (type === 'text_and_image') {
        (c.rows || []).forEach((r, i) => {
          if (missing(r.kind === 'video' ? r.videoUrl : r.imageUrl)) {
            errors.push({ id: `row-${l.id}-${i}`, level: 'error', surface: 'layout', layoutId: l.id,
              message: `${where(l.id)}: row ${i + 1} needs ${r.kind === 'video' ? 'a video' : 'an image'} before building.` });
          }
        });
      }
      if (type === 'sequence') {
        (c.tabs || []).forEach((t, i) => {
          const need = t.kind === 'video' ? t.videoUrl : t.imageUrl; // text/question/feedback tabs require an image
          if (missing(need)) {
            errors.push({ id: `tab-${l.id}-${i}`, level: 'error', surface: 'layout', layoutId: l.id,
              message: `${where(l.id)}: step ${i + 1} needs ${t.kind === 'video' ? 'a video' : 'a background image'} before building.` });
          }
        });
      }
      if (type === 'horizontal_tabs') {
        (c.tabs || []).forEach((t, i) => {
          if (missing(t.videoUrl ? t.videoUrl : t.imageUrl)) {
            errors.push({ id: `htab-${l.id}-${i}`, level: 'error', surface: 'layout', layoutId: l.id,
              message: `${where(l.id)}: tab ${i + 1} needs an image or video before building.` });
          }
        });
      }
      if (type === 'icons_discover') {
        (c.icons || []).forEach((ic, i) => {
          if (missing(ic.imageUrl)) {
            errors.push({ id: `icon-${l.id}-${i}`, level: 'error', surface: 'layout', layoutId: l.id,
              message: `${where(l.id)}: icon ${i + 1} needs an image before building.` });
          }
        });
      }
      if (type === 'quiz_images' || type === 'quiz_gaming') {
        (c.questions || []).forEach((q, i) => {
          const inner = q.content && (q.content.image || q.content.video || q.content.html || (q.content.kind ? q.content : null));
          const url = inner && (inner.imageUrl || inner.videoUrl || inner.htmlUrl);
          if (missing(url) || /^content\/q\./.test(String(url))) {
            errors.push({ id: `qmedia-${l.id}-${i}`, level: 'error', surface: 'layout', layoutId: l.id,
              message: `${where(l.id)}: question ${i + 1} needs media — pick an image, video or HTML template.` });
          }
        });
        if (type === 'quiz_gaming' && c.gamingHeader && c.gamingHeader.enabled &&
            (missing(c.gamingHeader.imgUrlCorrect) || missing(c.gamingHeader.imgUrlWrong))) {
          errors.push({ id: 'ghdr-' + l.id, level: 'error', surface: 'layout', layoutId: l.id,
            message: `${where(l.id)}: the score header is on but its correct/wrong icons are missing.` });
        }
      }
    }));
  }

  // ── The gaming-quiz flow is ON but no layout uses it ──────────────────────
  //
  // Omar, 2026-07-30: "I'm not able to export as I get this message though none of
  // the layout is using Quiz Gaming. The only place where this exist is on the
  // Course Settings where the toggle is turned on."
  //
  // The gateway was right to refuse. `dftiFlow.enabled` is not decoration: on its
  // own it switches the Player to course_template 5, whose segmentsOrder inserts
  // `profile_setup` and `dfti_intro` into the learner's navigation
  // (`emit-config-xml.ts`). Those screens are real, they need their text, and the
  // coverage gate therefore demands it in every enabled language. Relaxing the gate
  // would ship a course whose second screen is a blank profile form.
  //
  // What was wrong is that the author could not act on it. Eight blockers reading
  // "Missing text: courseSettings.dftiFlow.profileSetupDescription (en, it, ar)"
  // arrive from `surface: 'gateway'`, which carries no Jump to, name no screen, and
  // do not mention that a toggle is what pulled them in.
  //
  // So: pre-empt them with ONE blocker that names the screen, the choice, and jumps
  // there. It runs BEFORE the build, so the gateway's eight never appear
  // (`feedback_a_gates_order_is_part_of_its_contract` — the ORDER is the contract
  // here, and `fe-export-validate-orphans.test.ts` asserts it fires without needing
  // the gateway).
  //
  // Later the same day Omar reversed §13.4 Fix 4: deleting the last gaming quiz now
  // switches the flow off by itself (`app.jsx`, `gamingCount`). This blocker is NOT
  // redundant after that — it still catches the two states auto-disable deliberately
  // does not touch: a course saved in the on-with-nothing state BEFORE that rule
  // existed, and a flow switched on by hand in Course settings without ever adding a
  // gaming quiz. Auto-disable acts on a deletion; this acts on the state.
  const gamingOn = settings?.dftiFlow?.enabled === true;
  if (gamingOn) {
    const canon = (t) => (window.canonicalLayoutType ? window.canonicalLayoutType(t) : t);
    // The id, not just a boolean: an in-use flow needs somewhere to JUMP TO.
    let gamingLayoutId = null;
    course.modules.forEach(m => (m.layouts || []).forEach(l => {
      const t = (drafts && drafts[l.id] && drafts[l.id].type) || l.type;
      if (!gamingLayoutId && canon(t) === 'quiz_gaming') gamingLayoutId = l.id;
    }));
    if (!gamingLayoutId) {
      errors.push({
        id: 'dfti-flow-orphan', level: 'error', surface: 'brand',
        field: 'dftiFlow.enabled',
        message: 'Course settings: the gaming quiz flow is switched on, but no layout '
          + 'uses it. While it is on, every learner gets a profile form and an intro '
          + 'screen, so their text is required in all languages — which is what the '
          + 'eight "Missing text" errors were. Switch the gaming quiz flow off in '
          + 'Course settings, or add a gaming quiz layout and fill in its screen text.',
      });
    } else {
      // ── The flow IS in use, and its screen text is incomplete ──────────────
      //
      // Omar, 2026-07-30, having added a gaming quiz so the orphan blocker above
      // correctly stayed quiet: "I was about to run a test with the quiz gaming
      // but the localisation is still not set and therefore I cannot export the
      // zip." What he saw was `Missing text:
      // courseSettings.dftiFlow.dftiInfoTitle (en, it, ar)` from
      // `surface: 'gateway'` — a schema path, no screen named, no Jump to.
      //
      // Yesterday's fix made the ORPHAN case actionable and stopped there. The
      // in-use case has the identical defect, and generalising was the whole
      // point (`feedback_apply_a_security_finding_everywhere_at_once`): one
      // branch of a condition was fixed and its sibling left alone.
      //
      // The gateway is RIGHT to refuse — these four fields start deliberately
      // EMPTY in a blank course (`data.jsx makeBlankCourse`: "the coverage gate
      // makes the author write their own intro"), which is the honest-gate rule,
      // not a bug. Its gate is `findLocalizedCoverageGaps`, a schema-tree walker
      // with no notion of surfaces, so it cannot name a screen. This client-side
      // pass can, and it runs BEFORE the build so the gateway's version never
      // reaches the author (`feedback_a_gates_order_is_part_of_its_contract`).
      //
      // The emptiness predicate MATCHES the gateway's exactly — `typeof v ===
      // 'string' && v.length > 0`, NOT trimmed. Being stricter here would block
      // an export the server would have accepted, which is a worse failure than
      // the one being fixed. `fe-dfti-text-actionable.test.ts` asserts the two
      // predicates agree over a table including the whitespace case, because the
      // rule now exists in two languages and cannot share a function
      // (`feedback_one_rule_one_place`).
      //
      // Fields and labels mirror `DftiFlowSchema` and the real editor copy; the
      // same test asserts this list equals the schema's LocalizedString set, so
      // a field added there cannot stay silently unrouted
      // (`feedback_guard_the_invariant_not_the_list`).
      const DFTI_TEXT = [
        ['profileSetupDescription', 'Form description', 'Learner details form'],
        ['profileSetupFirstName', 'First-name label', 'Learner details form'],
        ['profileSetupLastName', 'Last-name label', 'Learner details form'],
        ['profileSetupEmail', 'Email label', 'Learner details form'],
        ['dftiInfoTitle', 'Headline', 'Intro panel'],
        ['dftiInfoLeftText', 'Left caption', 'Intro panel'],
        ['dftiInfoRightText', 'Right caption', 'Intro panel'],
      ];
      const covered = (rec, lang) =>
        !!rec && typeof rec[lang] === 'string' && rec[lang].length > 0;
      const f = settings.dftiFlow || {};
      DFTI_TEXT.forEach(([key, label, card]) => {
        const missing = langs.filter(l => !covered(f[key], l));
        if (!missing.length) return;
        // Name the ONE place it is authored, and both steps: the editor writes
        // the default language, Localisation fills the rest.
        const hasAny = langs.some(l => covered(f[key], l));
        errors.push({
          id: `dfti-text-${key}`, level: 'error', surface: 'layout',
          layoutId: gamingLayoutId, field: `dftiFlow.${key}`,
          message: `Gaming quiz screens — “${label}” in the “${card}” card is empty in `
            + `${missing.join(', ')}. Every learner sees these screens while the gaming `
            + `quiz flow is on. ${hasAny
              ? 'Translate it on the Localisation surface.'
              : 'Jump to the gaming quiz, open Profile form, and fill it in; then '
                + 'translate it on the Localisation surface.'}`,
        });
      });
    }
  }

  Object.entries(liveDrafts).forEach(([lid, d]) => {
    if (!d || !d.type) return;
    if (titleTypes.includes(d.type) && !hasText(d.titleMain)) {
      errors.push({ id: 'title-' + lid, level: 'error', surface: 'layout', layoutId: lid,
        field: 'titleMain', message: `${where(lid)}: Title is required.` });
    }
    if (!blankMode && videoTypes.includes(d.type) && !hasText(d.videoUrl)) {
      // Non-blocking ONLY when a stand-in really will be bundled. The stand-in
      // ids are per-environment (env-config.js); with none configured, nothing
      // fills the slot and the export 422s on unresolved media — so promising a
      // stand-in would be a false affordance. Say which of the two it is.
      if (STANDIN_VIDEO) {
        warnings.push({ id: 'video-' + lid, level: 'warning', surface: 'layout', layoutId: lid,
          field: 'videoUrl', message: `${where(lid)}: Video layout has no authored media — a stand-in will be bundled.` });
      } else {
        errors.push({ id: 'video-' + lid, level: 'error', surface: 'layout', layoutId: lid,
          field: 'videoUrl', message: `${where(lid)}: Video layout has no media — upload a video before building.` });
      }
    }
    if (d.type === 'quiz_gaming') {
      const qn = (d.questions || []).length;
      const need = d.correctAnswersNeeded;
      if (need != null && qn > 0 && need > qn) {
        errors.push({ id: 'thresh-' + lid, level: 'error', surface: 'layout', layoutId: lid,
          field: 'correctAnswersNeeded',
          message: `${where(lid)}: Pass threshold (${need}) exceeds total question count (${qn}).` });
      } else if (need != null && qn > 0 && need === qn) {
        warnings.push({ id: 'threshw-' + lid, level: 'warning', surface: 'layout', layoutId: lid,
          field: 'correctAnswersNeeded',
          message: `${where(lid)}: Pass threshold equals the total question count — learners need a perfect score to pass.` });
      }
      if (qn === 0) {
        warnings.push({ id: 'noq-' + lid, level: 'warning', surface: 'layout', layoutId: lid,
          message: `${where(lid)}: Gaming quiz has no questions yet.` });
      }
      // ── An answer-count token on the START screen ships the literal braces ──
      //
      // The Player substitutes {img1}/{img2}/{img3} in a start-screen body and
      // NOTHING else; {neededAnswers}/{totalAnswers}/{wrongAnswers} fill in only on
      // the Win and Fail bodies (components.js:5596, traced 2026-06-05, recorded on
      // `GamingScreenSchema.body`). Omar shipped "Answer {totalAnswers} questions"
      // to a learner on 2026-07-30.
      //
      // The editor no longer offers those chips on the start screen and the sample
      // seed no longer contains them — but neither of those repairs a course that
      // already has one, and Omar's does. Same split as the gaming-flow toggle: the
      // editor change acts on new authoring, this acts on the stored STATE.
      //
      // A WARNING rather than a blocker: it is authored prose, the package builds
      // and plays, and only one sentence reads oddly. Blocking would also be the
      // third export refusal of one session. It names the screen and jumps there,
      // which is the part that was missing.
      const startBody = (d.gamingStartScreen || {}).body;
      const DEAD_ON_START = ['neededAnswers', 'totalAnswers', 'wrongAnswers'];
      if (startBody) {
        const slots = (typeof startBody === 'string')
          ? { en: startBody }
          : (startBody && typeof startBody === 'object' ? startBody : {});
        const hits = [];
        DEAD_ON_START.forEach(tok => {
          const inLangs = Object.keys(slots).filter(lg =>
            typeof slots[lg] === 'string' && slots[lg].includes('{' + tok + '}'));
          if (inLangs.length) hits.push(`{${tok}} (${inLangs.join(', ')})`);
        });
        if (hits.length) {
          warnings.push({ id: 'starttok-' + lid, level: 'warning', surface: 'layout',
            layoutId: lid, field: 'gamingStartScreen.body',
            message: `${where(lid)}: the Start screen text contains ${hits.join(' and ')}. `
              + 'The Player only fills those in on the Win and Fail screens, so learners '
              + 'will read the braces exactly as written. Open the gaming quiz, go to '
              + 'Game → Start screen, and write the number out instead.' });
        }
      }
    }
  });

  errors.push(...validateAssessments(course, settings));

  return { errors, warnings };
}

// ─── Main surface ────────────────────────────────────────────────────────────
function SurfaceExport({ course, settings, layoutDrafts, onOpenPreview, onJump }) {
  const cs = settings || window.SAMPLE_COURSE_SETTINGS;
  const [valNonce, setValNonce] = React.useState(0);
  const validation = React.useMemo(
    () => validateExport(course, cs, layoutDrafts || {}),
    [course, cs, layoutDrafts, valNonce]);
  const hasErrors = validation.errors.length > 0;

  // Gateway-reported validation errors (HTTP 400/422 from the real gateway).
  // Merged into the Validation panel; cleared on Re-run and on each new build.
  const [gatewayErrors, setGatewayErrors] = React.useState([]);
  const mergedValidation = React.useMemo(() => ({
    errors: [...validation.errors, ...gatewayErrors],
    warnings: validation.warnings,
  }), [validation, gatewayErrors]);

  // Build state machine.
  const [build, setBuild] = React.useState({ status: 'idle' }); // idle|running|done
  const [showOptions, setShowOptions] = React.useState(false);
  const [showSuccess, setShowSuccess] = React.useState(false);
  const cancelledRef = React.useRef(false);

  // Export history — PER COURSE (a shared key would show another course's
  // builds after a switch). The pre-multicourse global key is simply ignored.
  const LS_HISTORY_COURSE = `${LS_HISTORY}.${course.id || 'default'}`;
  const [history, setHistory] = React.useState(() => readLS(LS_HISTORY_COURSE, []));
  React.useEffect(() => { setHistory(readLS(LS_HISTORY_COURSE, [])); }, [LS_HISTORY_COURSE]);
  const [reportFor, setReportFor] = React.useState(null);
  const historyRef = React.useRef(null);

  const withPhase = (b, i, status) => {
    const phases = (b.phases || []).map((p, j) =>
      j < i ? { ...p, status: 'done' } : j === i ? { ...p, status } : p);
    return { ...b, phases, currentPhase: i };
  };

  const runSimulated = React.useCallback(async (opts, warnings, gatewayIssue) => {
    cancelledRef.current = false;
    setBuild({
      status: 'running', simulated: true, runtimeHash: 'unknown',
      phases: EXPORT_PHASES.map((n, i) => ({ name: n, status: i === 0 ? 'active' : 'pending' })),
      currentPhase: 0, opts,
    });
    for (let i = 0; i < EXPORT_PHASES.length; i++) {
      if (cancelledRef.current) return;
      setBuild(b => withPhase(b, i, 'active'));
      const isBundle = EXPORT_PHASES[i] === 'Bundling runtime';
      const dur = isBundle ? 1200 : 400;
      if (isBundle) {
        const start = Date.now();
        // animate pct + eta on the slow phase
        while (Date.now() - start < dur && !cancelledRef.current) {
          const el = Date.now() - start;
          const pct = Math.min(100, Math.round((el / dur) * 100));
          setBuild(b => {
            const phases = b.phases.slice();
            if (phases[i]) phases[i] = { ...phases[i], pct, etaMs: Math.max(0, dur - el) };
            return { ...b, phases };
          });
          await sleep(130);
        }
      } else {
        await sleep(dur);
      }
      if (cancelledRef.current) return;
      setBuild(b => withPhase(b, i, 'done'));
    }
    // Complete.
    const sizeMb = (12.5 + (course.modules.length * 0.9) + Math.random() * 1.4);
    const result = {
      ts: Date.now(), simulated: true, downloadUrl: null,
      size: `${sizeMb.toFixed(1)} MB`, runtimeHash: 'unknown',
      scormVersion: opts.scormVersion, languages: opts.languages,
      warnings: warnings.map(w => w.message), editor: 'You',
      phases: EXPORT_PHASES.slice(),
      gatewayIssue: gatewayIssue || null,
    };
    setBuild(b => ({ ...b, status: 'done', result }));
    setHistory(h => { const next = [result, ...h].slice(0, 12); writeLS(LS_HISTORY_COURSE, next); return next; });
    setShowSuccess(true);
  }, [course.modules.length, LS_HISTORY_COURSE]);

  // Real build — the gateway returns the SCORM ZIP synchronously as
  // application/zip bytes (not a JSON job to poll). Turn the body into an
  // object URL, name it from Content-Disposition, record the pinned runtime
  // hash, mark every phase done, and save it to disk immediately.
  const completeRealBuild = React.useCallback(async (opts, warnings, res) => {
    const courseId = course.id || '7bf69e2d-51e7-4856-86bb-bb2b77b47216';
    const blob = await res.blob();
    const downloadUrl = URL.createObjectURL(blob);
    const filename = filenameFromDisposition(
      res.headers.get('content-disposition'), `course-${courseId}.zip`);
    const runtimeHash = res.headers.get('x-pinned-runtime-hash') || 'unknown';
    const result = {
      ts: Date.now(), simulated: false, downloadUrl, filename,
      size: `${(blob.size / 1048576).toFixed(1)} MB`, runtimeHash,
      scormVersion: opts.scormVersion, languages: opts.languages,
      warnings: warnings.map(w => w.message), editor: 'You',
      phases: EXPORT_PHASES.map(p => ({ name: p, status: 'done', pct: 100, etaMs: 0 })),
      gatewayIssue: null,
    };
    setBuild(b => ({ ...b, status: 'done', result }));
    setHistory(h => { const next = [result, ...h].slice(0, 12); writeLS(LS_HISTORY_COURSE, next); return next; });
    setShowSuccess(true);
    // Save it straight away — the author clicked Build to get a file.
    triggerDownload(downloadUrl, filename);
  }, [course.id]);

  const startBuild = React.useCallback(async (opts) => {
    setShowOptions(false);
    setGatewayErrors([]);
    // Persist "last used" SCORM version, and the full opts if "remember".
    writeLS(LS_SCORM, opts.scormVersion);
    if (opts.remember) writeLS(LS_OPTS, { scormVersion: opts.scormVersion, languages: opts.languages });

    // Fallback mirrors the seeded test-course UUID (data.jsx SAMPLE_COURSE.id)
    // — the gateway's course.id column is uuid-typed and 500s on non-UUIDs.
    const courseId = course.id || '7bf69e2d-51e7-4856-86bb-bb2b77b47216';

    // Token comes from the signed-in Auth0 session (the splash gate
    // guarantees one exists). getTokenSilently serves from cache / refresh
    // token, so this is fast on the happy path.
    try {
      window.AUTH_TOKEN = await window.dynamoGetAccessToken();
    } catch (e) {
      await runSimulated(opts, validation.warnings, {
        kind: 'auth',
        message: 'Could not get an access token from the signed-in session — fell back to simulated. Try signing out and back in.',
        detail: String((e && (e.message || e.error)) || e),
      });
      return;
    }

    // ── Save the on-screen course to the server BEFORE exporting ──
    // The gateway builds the ZIP from the saved draft, so without this the
    // export would ship stale DB content. A failed save MUST abort the
    // export — shipping stale content is worse than not shipping — and must
    // NOT silently fall back to simulated.
    // The body comes from `window.dynamoDraftSaveBody()` — the SINGLE definition
    // of what a save sends (app.jsx). This used to build its own `{ content }`
    // body, which omitted the authoring state; because `GET /draft` reads the
    // CURRENT version, every export then left the server reporting "no copy of
    // this course", and the next machine to open it got an empty editor. Two
    // savers with two bodies is the bug; there is now one body.
    const saveUrl = `${GATEWAY_BASE}/v1/courses/${courseId}/draft`;
    let saveRes = null;
    try {
      const payload = window.dynamoDraftSaveBody
        ? window.dynamoDraftSaveBody()
        : { content: toDraftContent(course, cs, layoutDrafts) };
      saveRes = await fetch(saveUrl, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${window.AUTH_TOKEN}`,
        },
        body: JSON.stringify(payload),
      }).catch(() => null);
    } catch { saveRes = null; }

    if (!saveRes) {
      // Network / CORS — the request never reached the server.
      setGatewayErrors([{ id: 'save-net', level: 'error', surface: 'gateway',
        message: 'Could not save your course to the server — export aborted.' }]);
      setBuild({ status: 'idle' });
      return;
    }
    if (!saveRes.ok) {
      // 400 / 401 / 404 etc — surface the gateway's message and stop.
      let msg = '';
      try {
        const body = await saveRes.clone().json();
        if (Array.isArray(body?.errors) && body.errors.length) {
          msg = body.errors.map(e => typeof e === 'string' ? e : (e.message || JSON.stringify(e))).join('; ');
        } else if (body && body.message) {
          msg = humanizeSchemaFailure(body.message);
        }
      } catch { /* body wasn't JSON */ }
      if (!msg) { try { msg = (await saveRes.text()).slice(0, 300); } catch { /* ignore */ } }
      setGatewayErrors([{ id: 'save-err', level: 'error', surface: 'gateway',
        message: `Could not save your course (HTTP ${saveRes.status})${msg ? `: ${msg}` : ''} — export aborted.` }]);
      setBuild({ status: 'idle' });
      return;
    }
    // Save OK — the draft now matches the screen; the export below is fresh.
    //
    // RECORD the version this save produced. Without this the tab kept sending
    // the version id it read at boot, so the SECOND save of a session — a second
    // Build, or any Save button anywhere in the app — came back 409 "Someone else
    // saved this course after you opened it" to an author working alone, and the
    // only remedy offered was a reload. One export made the whole app unsaveable
    // until the page was reloaded. app.jsx owns the marker; this is the same
    // recorder its own save path uses, so the two cannot drift again (the shared
    // BODY was not enough — the RESPONSE has to be handled in one place too).
    try {
      const saved = await saveRes.clone().json();
      if (saved && saved.versionId && window.dynamoRecordSavedVersion) {
        await window.dynamoRecordSavedVersion(saved.versionId);
      }
    } catch { /* a 200 whose body we could not read must not abort the export */ }

    // Session token in hand → attempt the real gateway call.
    const url = `${GATEWAY_BASE}/v1/courses/${courseId}/export`;
    let res = null;
    try {
      res = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...(window.AUTH_TOKEN && { Authorization: `Bearer ${window.AUTH_TOKEN}` }),
        },
        body: JSON.stringify({
          scormVersion: opts.scormVersion, languages: opts.languages,
        }),
      }).catch(() => null);
    } catch { res = null; }

    // ── Error classification (diagnostic for Phase 4 testing) ──
    if (!res) {
      // fetch threw → CORS preflight failure or network error.
      await runSimulated(opts, validation.warnings, {
        kind: 'network',
        message: 'Gateway unreachable (CORS or network) — fell back to simulated.',
        detail: `POST ${url}`,
      });
      return;
    }
    if (res.status === 401) {
      await runSimulated(opts, validation.warnings, {
        kind: 'auth',
        message: 'Auth token rejected by gateway (401) — fell back to simulated. Try a fresh token.',
        detail: `POST ${url} → 401 Unauthorized`,
      });
      return;
    }
    if (res.status === 400 || res.status === 422) {
      // Draft validation failed server-side — do NOT fall back to simulated.
      // Surface the gateway's error body in the Validation panel instead.
      let entries = [];
      try {
        const body = await res.clone().json();
        const list = Array.isArray(body?.errors) ? body.errors : (Array.isArray(body) ? body : null);
        if (list && list.length) {
          entries = list.map((e, i) => ({
            id: `gw-${i}`, level: 'error', surface: 'gateway',
            message: typeof e === 'string' ? e : (e.message || JSON.stringify(e)),
          }));
        } else if (body && body.message) {
          entries = [{ id: 'gw-0', level: 'error', surface: 'gateway',
            message: humanizeSchemaFailure(body.message) }];
        }
        // The gateway's 422s carry the actionable per-item lists in
        // `details` — surface them, not just the summary count.
        const d = body && body.details;
        if (d) {
          const items = [];
          (d.gaps || []).forEach(g =>
            items.push(`Missing text: ${g.path} (${(g.missingLanguages || []).join(', ')})`));
          (d.unresolvedMedia || []).forEach(r =>
            items.push(`Missing media: ${r.field} = "${r.value}" (${r.language})`));
          (d.missingAssetIds || []).forEach(id =>
            items.push(`Uploaded file missing from storage: ${id}`));
          (d.unknownTooltipKeys || []).forEach(k =>
            items.push(`Tooltip key doesn't match the template: ${typeof k === 'string' ? k : JSON.stringify(k)}`));
          // The assessment gate already writes author-facing sentences naming
          // the module and the question — pass them straight through. Without
          // this branch the author saw only "assessment is incomplete: 3
          // problem(s)" and no way to learn which three.
          (d.assessmentIssues || []).forEach(a =>
            items.push(typeof a === 'string' ? a : (a.message || JSON.stringify(a))));
          // The emptiness gate names each empty chapter and each module with no
          // screens; the summary message only counts them. Both sentences are
          // already author-facing, so pass them straight through.
          (d.emptyChapters || []).forEach(c =>
            items.push(typeof c === 'string' ? c : JSON.stringify(c)));
          (d.emptyModules || []).forEach(m =>
            items.push(typeof m === 'string' ? m : JSON.stringify(m)));
          const MAX = 12;
          items.slice(0, MAX).forEach((m, i) =>
            entries.push({ id: `gwd-${i}`, level: 'error', surface: 'gateway', message: m }));
          if (items.length > MAX) {
            entries.push({ id: 'gwd-more', level: 'error', surface: 'gateway',
              message: `…and ${items.length - MAX} more — fix the ones above and re-run.` });
          }
        }
      } catch { /* body wasn't JSON */ }
      if (!entries.length) {
        let text = '';
        try { text = (await res.text()).slice(0, 300); } catch { /* ignore */ }
        entries = [{ id: 'gw-0', level: 'error', surface: 'gateway',
          message: `Gateway rejected the draft (HTTP ${res.status}).${text ? ` ${text}` : ''}` }];
      }
      setGatewayErrors(entries);
      setBuild({ status: 'idle' });
      return;
    }
    if (!res.ok) {
      let snippet = '';
      try { snippet = (await res.text()).slice(0, 400); } catch { /* ignore */ }
      await runSimulated(opts, validation.warnings, {
        kind: 'http', code: res.status,
        message: `Gateway returned HTTP ${res.status} — fell back to simulated.`,
        detail: `POST ${url} → ${res.status}${snippet ? `\n${snippet}` : ''}`,
      });
      return;
    }
    // 200 OK — the gateway returns the ZIP synchronously as application/zip
    // bytes. Stream it to a real download (no job to poll).
    await completeRealBuild(opts, validation.warnings, res);
    return;
  }, [course, cs, layoutDrafts, runSimulated, completeRealBuild, validation.warnings]);

  const cancelBuild = () => { cancelledRef.current = true; setBuild({ status: 'idle' }); };

  return (
    <div style={{ height: '100%', background: 'var(--bg)', display: 'flex',
                  flexDirection: 'column' }}
         data-screen-label="Surface 8 · Export">
      {/* The shared page header (2026-08-14). It used to be a 20px <h1> INSIDE the
          scrolling body, so it slid away as the author read the validation list —
          the only surface whose title did not stay put. */}
      <window.SurfaceHeader title="Export"
        description={<>Check the course, build the SCORM package, and download it or push
          it to your LMS. Anything that would break the package is listed below.</>}>
        <span className="chip" style={{ fontFamily: 'var(--font-mono)' }}>
          SCORM {cs.metadata.scormVersion}
        </span>
        <span className="chip">{course.modules.length} modules · {course.modules.reduce((n, m) => n + m.layouts.length, 0)} layouts</span>
      </window.SurfaceHeader>
      <div style={{ padding: '20px 28px 40px', flex: 1, minHeight: 0, overflowY: 'auto',
        display: 'flex', flexDirection: 'column', gap: 18 }}>

        {/* Validation panel (live + gateway-reported errors) */}
        <ValidationPanel validation={mergedValidation}
          onRerun={() => { setGatewayErrors([]); setValNonce(n => n + 1); }} onJump={onJump} />

        {/* Build SCORM */}
        <BuildScormCard hasErrors={hasErrors} running={build.status === 'running'}
          onBuild={() => setShowOptions(true)} />

        {/* Build progress (only once a build has started) */}
        {build.status !== 'idle' && (
          <BuildStepper build={build} onCancel={cancelBuild} />
        )}

        {/* Coverage summary */}
        <CoverageSummary course={course} />

        {/* Export history */}
        <div ref={historyRef}>
          <ExportHistory history={history} onReport={setReportFor} />
        </div>
      </div>

      {showOptions && (
        <BuildOptionsModal course={course} settings={cs} validation={validation}
          onCancel={() => setShowOptions(false)} onBuild={startBuild}
          onJump={onJump} />
      )}

      {showSuccess && build.result && (
        <BuildSuccessModal result={build.result}
          onClose={() => setShowSuccess(false)}
          onOpenPreview={() => { setShowSuccess(false); onOpenPreview?.(); }}
          onViewHistory={() => {
            setShowSuccess(false);
            requestAnimationFrame(() => historyRef.current?.scrollIntoView
              ? historyRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' })
              : null);
          }} />
      )}

      {reportFor && (
        <ExportReportModal entry={reportFor} onClose={() => setReportFor(null)} />
      )}
    </div>
  );
}

// ─── Validation panel ────────────────────────────────────────────────────────
function ValidationPanel({ validation, onRerun, onJump }) {
  const { errors, warnings } = validation;
  const all = [...errors, ...warnings];
  const hasErrors = errors.length > 0;
  const clean = all.length === 0;
  const summary = clean ? 'All clear · ready to build'
    : `${errors.length ? `${errors.length} blocker${errors.length === 1 ? '' : 's'}` : 'No blockers'}` +
      `${warnings.length ? ` · ${warnings.length} warning${warnings.length === 1 ? '' : 's'}` : ''}`;
  return (
    <div className="card" style={{ borderColor: hasErrors ? 'var(--error)' : 'var(--border)' }}>
      <div style={{
        padding: '14px 18px', borderBottom: clean ? 'none' : '1px solid var(--border)',
        display: 'flex', alignItems: 'center', gap: 10,
        background: hasErrors ? 'var(--error-bg)' : clean ? 'transparent' : 'transparent',
      }}>
        {hasErrors
          ? <I.AlertCircle size={16} style={{ color: 'var(--error-text)' }} />
          : clean
            ? <I.CheckCircle size={16} style={{ color: 'var(--success)' }} />
            : <I.AlertTriangle size={16} style={{ color: 'var(--warning)' }} />}
        <h3 style={{ margin: 0, fontSize: 14, fontWeight: 600,
          color: hasErrors ? 'var(--error-text)' : 'var(--text)' }}>
          {hasErrors ? `Validation blocked — ${summary}` : clean ? summary : `Builds with warnings — ${summary}`}
        </h3>
        <div style={{ flex: 1 }} />
        <button className="btn sm ghost" onClick={onRerun}><I.RefreshCw size={12} />Re-run</button>
      </div>
      {!clean && (
        <div style={{ padding: '8px 12px', display: 'grid', gap: 4 }}>
          {all.map(v => (
            <div key={v.id} style={{
              display: 'grid', gridTemplateColumns: '20px 1fr auto',
              gap: 10, alignItems: 'center', padding: '8px 10px', borderRadius: 'var(--radius)',
              background: v.level === 'error' ? 'var(--error-bg)' : 'transparent',
            }}
              onMouseOver={e => { if (v.level !== 'error') e.currentTarget.style.background = 'var(--surface-inset)'; }}
              onMouseOut={e => { if (v.level !== 'error') e.currentTarget.style.background = 'transparent'; }}>
              {v.level === 'error'
                ? <I.AlertCircle size={14} style={{ color: 'var(--error-text)' }} />
                : <I.AlertTriangle size={14} style={{ color: 'var(--warning)' }} />}
              <div style={{ minWidth: 0 }}>
                <div style={{ fontSize: 12.5, color: 'var(--text)' }}>{v.message}</div>
                <div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }}>
                  {v.surface}{v.moduleId ? ` · ${v.moduleId}` : ''}{v.layoutId ? ` · ${v.layoutId}` : ''}{v.field ? ` · ${v.field}` : ''}
                </div>
              </div>
              {(v.layoutId || v.surface) && v.surface !== 'gateway' && (
                <button className="btn sm ghost" onClick={() => onJump?.(v)}>
                  <I.ArrowRight size={12} />Jump to
                </button>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ─── Build card ──────────────────────────────────────────────────────────────
function BuildScormCard({ hasErrors, running, onBuild }) {
  return (
    <div className="card" style={{ padding: 20,
      background: hasErrors ? 'var(--surface)' : 'var(--accent-bg)',
      borderColor: hasErrors ? 'var(--border)' : 'var(--accent-border)' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
        <div style={{ flex: 1 }}>
          <h2 style={{ margin: '0 0 4px', fontSize: 16, fontWeight: 600 }}>Build SCORM package</h2>
          <p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-muted)', lineHeight: 1.5 }}>
            Bundles content + Player runtime + assets into a single .zip
            ready for upload to any SCORM-compatible LMS.
          </p>
        </div>
        {/* A reviewer's POST /export is refused by the gateway (auth/roles.ts),
            so do not offer the button — say why instead. */}
        <button className="btn lg primary"
          disabled={running || window.dynamoReadOnly}
          title={window.dynamoReadOnly
            ? 'Your role is reviewer — building a package is not available to you'
            : undefined}
          onClick={onBuild}>
          <I.Package size={14} />
          {running ? 'Building…' : 'Build SCORM package'}
        </button>
      </div>
    </div>
  );
}

// ─── Stepper (controlled by real phase state) ───────────────────────────────
function BuildStepper({ build, onCancel }) {
  const phases = build.phases || [];
  const active = phases.find(p => p.status === 'active');
  const done = build.status === 'done';
  const startedRef = React.useRef(Date.now());
  return (
    <div className="card" style={{ padding: 16 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
        <h3 style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>Build progress · most recent</h3>
        {build.simulated && <span className="pill" style={{ fontSize: 10.5 }}>Simulated build</span>}
        <div style={{ flex: 1 }} />
        {done
          ? <span className="pill accepted" style={{ fontSize: 11 }}><I.Check size={11} />Ready</span>
          : <button className="btn sm ghost danger" onClick={onCancel}>Cancel</button>}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
        {phases.map((s, i) => (
          <React.Fragment key={s.name}>
            <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center',
              gap: 6, minWidth: 80, position: 'relative' }}>
              <div style={{
                width: 28, height: 28, borderRadius: '50%',
                background: s.status === 'done' ? 'var(--success)'
                  : s.status === 'active' ? 'var(--accent)'
                  : s.status === 'error' ? 'var(--error)'
                  : 'var(--surface-inset)',
                color: s.status === 'pending' ? 'var(--text-faint)' : '#fff',
                display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
                border: s.status === 'pending' ? '1px solid var(--border)' : 'none',
              }}>
                {s.status === 'done' && <I.Check size={14} />}
                {s.status === 'active' && <I.Loader size={14} className="spin" />}
                {s.status === 'error' && <I.X size={14} />}
                {s.status === 'pending' && <span style={{ fontSize: 11, fontWeight: 600 }}>{i + 1}</span>}
              </div>
              <div style={{ fontSize: 11.5, fontWeight: s.status === 'active' ? 600 : 500,
                color: s.status === 'active' ? 'var(--accent-text)'
                  : s.status === 'done' ? 'var(--text)' : 'var(--text-faint)',
                textAlign: 'center' }}>{s.name}</div>
            </div>
            {i < phases.length - 1 && (
              <div style={{ flex: 1, height: 2, background: 'var(--border)', marginTop: -16, position: 'relative' }}>
                <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0,
                  width: phases[i].status === 'done' ? '100%' : phases[i].status === 'active' ? '50%' : '0',
                  background: 'var(--success)', transition: 'width 400ms ease' }} />
              </div>
            )}
          </React.Fragment>
        ))}
      </div>

      {/* Active-phase detail strip — sub-detail (runtime hash), progress, ETA. */}
      {active && (
        <div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--surface-inset)',
          borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', gap: 12 }}>
          <I.Loader size={13} className="spin" style={{ color: 'var(--accent)' }} />
          <div style={{ minWidth: 0 }}>
            <div style={{ fontSize: 12.5, fontWeight: 500 }}>{active.name}</div>
            {active.name === 'Bundling runtime' && (
              <div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }}>
                runtime {shortHash(build.runtimeHash)}…
              </div>
            )}
          </div>
          <div style={{ flex: 1 }} />
          {active.pct != null && (
            <div style={{ width: 160, height: 5, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
              <div style={{ height: '100%', width: `${active.pct}%`, background: 'var(--accent)',
                transition: 'width 130ms linear' }} />
            </div>
          )}
          {active.etaMs != null && active.etaMs > 0 && (
            <span style={{ fontSize: 11.5, color: 'var(--text-muted)', fontFamily: 'var(--font-mono)' }}>
              ≈{Math.ceil(active.etaMs / 1000)}s left
            </span>
          )}
        </div>
      )}
    </div>
  );
}

// ─── Pre-build options modal (Fix 1) ─────────────────────────────────────────
function BuildOptionsModal({ course, settings, validation, onCancel, onBuild, onJump }) {
  const langs = course.languages || ['en'];

  const remembered = readLS(LS_OPTS, null);
  const [scormVersion, setScormVersion] = React.useState(
    readLS(LS_SCORM, null) || settings.metadata.scormVersion || '1.2');
  // Always the full enabled set — the gateway builds every enabled language;
  // per-build subsets aren't supported (a previously "remembered" subset must
  // not silently narrow the payload either).
  const selLangs = langs;
  const [remember, setRemember] = React.useState(false);

  const showLangs = langs.length > 1;
  const errors = validation.errors;
  const blocked = errors.length > 0;
  const canBuild = !blocked && !window.dynamoReadOnly
    && (!showLangs || selLangs.length > 0);

  React.useEffect(() => {
    const h = e => { if (e.key === 'Escape') onCancel(); };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [onCancel]);


  return (
    <ModalShell icon="Package" title="Build SCORM package"
      subtitle={`${course.title} · ${course.modules.length} modules`} onCancel={onCancel}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
        {blocked && (
          <div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, padding: '10px 12px',
            background: 'var(--error-bg)', border: '1px solid var(--error)', borderRadius: 'var(--radius-md)' }}>
            <I.AlertCircle size={15} style={{ color: 'var(--error-text)', flexShrink: 0, marginTop: 1 }} />
            <div style={{ fontSize: 12.5, color: 'var(--error-text)' }}>
              {errors.length} blocker{errors.length === 1 ? '' : 's'} must be resolved before building.{' '}
              <button onClick={() => onJump?.(errors[0])}
                style={{ border: 0, background: 'transparent', color: 'var(--error-text)',
                  textDecoration: 'underline', cursor: 'default', fontFamily: 'inherit', fontSize: 12.5, padding: 0 }}>
                Jump to first blocker
              </button>
            </div>
          </div>
        )}

        <MField label="SCORM version">
          <div style={{ display: 'flex', gap: 8 }}>
            {['1.2', '2004'].map(v => {
              // The pinned Player runtime is 1.2-only; the packager hardcodes
              // "1.2". Offering 2004 as clickable would be a false affordance
              // (FE→ZIP rule) — it stays visible but explicitly unavailable.
              const disabled = v === '2004';
              return (
                <button key={v} onClick={disabled ? undefined : () => setScormVersion(v)}
                  className="focusable" disabled={disabled}
                  title={disabled ? 'The Player runtime supports SCORM 1.2 only — 2004 will unlock when a 2004-capable runtime ships.' : undefined}
                  style={{
                    padding: '6px 14px', height: 32, fontSize: 12.5, cursor: 'default', fontFamily: 'inherit',
                    background: scormVersion === v ? 'var(--accent-bg)' : 'var(--surface)',
                    color: disabled ? 'var(--text-faint)' : scormVersion === v ? 'var(--accent-text)' : 'var(--text-muted)',
                    border: '1px solid', borderColor: scormVersion === v ? 'var(--accent-border)' : 'var(--border-strong)',
                    borderRadius: 'var(--radius)', fontWeight: scormVersion === v ? 600 : 500,
                    opacity: disabled ? 0.55 : 1,
                  }}>
                  {v === '2004' ? '2004 4th Ed · not available yet' : v}
                </button>
              );
            })}
          </div>
        </MField>

        {showLangs && (
          <MField label="Languages"
            hint="Every enabled language is included in the package. Add or remove languages on the Localisation surface.">
            {/* Informational, not selectable: the gateway always builds from the
                course's enabled languages — per-build subsets aren't supported
                yet, so checkboxes here would be a false affordance (FE→ZIP rule). */}
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
              {langs.map(l => (
                <span key={l} style={{
                  display: 'inline-flex', alignItems: 'center', gap: 6, padding: '5px 10px', height: 30,
                  fontSize: 12.5, fontFamily: 'inherit', borderRadius: 'var(--radius)',
                  background: 'var(--accent-bg)', color: 'var(--accent-text)',
                  border: '1px solid var(--accent-border)', fontWeight: 600,
                }}>
                  <span style={{ fontSize: 13 }}>{window.LANG_FLAGS?.[l] || ''}</span>
                  {window.LANG_NAMES?.[l] || l}
                  <I.Check size={12} />
                </span>
              ))}
            </div>
          </MField>
        )}

        {/* ── The build-time "Brand" picker is GONE (2026-08-12, Phase 4c) ──────
            It was a disabled select, hinted "Not applied yet … real brand
            selection lands with the brand model", fed from `settings.organisations`
            — the MOCK slice, not the real `courseSettings.brands`.
            It is removed rather than implemented, because the shipped design makes
            it WRONG rather than merely unfinished. Omar's Q8 decision: an
            organisation is "real for the learner" — the LEARNER picks their
            organisation on a screen in the player, and that choice filters which
            roles they are offered. There is no build-time choice to make: the
            package contains every organisation. A control implying the author
            picks one would misdescribe the feature that now exists, which is worse
            than the honest "not applied yet" it replaced
            (`feedback_no_false_affordance_toggles`,
            `feedback_a_setting_that_describes_state_is_not_a_preference`). */}

        <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'default' }}>
          <input type="checkbox" checked={remember} onChange={e => setRemember(e.target.checked)} />
          <span style={{ fontSize: 12.5, color: 'var(--text)' }}>Remember these settings</span>
        </label>
      </div>

      <ModalFooter>
        <button className="btn sm" onClick={onCancel}>Cancel</button>
        {/* ⚠️ `brand` was dropped from this call on 2026-08-12, together with the
            build-time organisation picker. REMOVING THE VARIABLE WITHOUT REMOVING
            THIS REFERENCE broke Build completely: the click threw
            `ReferenceError: brand is not defined`, so the modal did nothing at all
            and Omar hit it on the first press — "it doesn't do nothing when I try
            to build the zip".
            `npx esbuild --outfile=/dev/null` passed on it, because an undefined
            identifier is a RUNTIME error and not a syntax error. A parse check is
            not a render check. `fe-build-modal.test.ts` now RENDERS this modal and
            clicks this button (`feedback_fix_must_be_reachable_on_the_users_path`). */}
        <button className="btn sm primary" disabled={!canBuild}
          onClick={() => onBuild({ scormVersion, languages: selLangs, remember })}>
          <I.Package size={12} />Build
        </button>
      </ModalFooter>
    </ModalShell>
  );
}

// ─── Post-build success modal (Fix 5) ────────────────────────────────────────
function BuildSuccessModal({ result, onClose, onOpenPreview, onViewHistory }) {
  const [copied, setCopied] = React.useState(false);
  const simulated = result.simulated;
  const warnCount = (result.warnings || []).length;

  const copyLink = () => {
    if (!result.downloadUrl) return;
    try { navigator.clipboard?.writeText(result.downloadUrl); } catch { /* ignore */ }
    setCopied(true); setTimeout(() => setCopied(false), 1600);
  };

  return (
    <ModalShell icon="CheckCircle" iconTone="success" title="Build complete"
      onCancel={onClose}
      headerExtra={
        <div style={{ display: 'inline-flex', gap: 6 }}>
          <span className="chip" style={{ fontFamily: 'var(--font-mono)' }}>{result.size}</span>
          {warnCount > 0 && (
            <span className="pill issues" style={{ fontSize: 10.5 }}>
              <I.AlertTriangle size={10} />Shipped with {warnCount} warning{warnCount === 1 ? '' : 's'}
            </span>
          )}
          {simulated && <span className="pill" style={{ fontSize: 10.5 }}>Simulated build</span>}
        </div>
      }>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        <p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
          Your SCORM {result.scormVersion === '2004' ? '2004' : '1.2'} package is ready.
          {simulated && ' This was a simulated build — no real ZIP was produced.'}
        </p>

        {/* Gateway diagnostic (Phase 4) — why this build fell back to simulated. */}
        {result.gatewayIssue && (
          <div style={{ display: 'grid', gap: 6, padding: '10px 12px',
            background: 'var(--warning-bg)', borderRadius: 'var(--radius-md)' }}>
            <div style={{ fontSize: 12, color: 'var(--warning-text)', fontWeight: 500,
              display: 'flex', gap: 6, alignItems: 'flex-start' }}>
              <I.AlertTriangle size={12} style={{ marginTop: 2, flexShrink: 0 }} />
              {result.gatewayIssue.message}
            </div>
            {result.gatewayIssue.detail && (
              <details style={{ fontSize: 11, color: 'var(--warning-text)' }}>
                <summary style={{ cursor: 'default', opacity: 0.8 }}>Details</summary>
                <pre style={{ margin: '6px 0 0', padding: '6px 8px', whiteSpace: 'pre-wrap',
                  wordBreak: 'break-all', userSelect: 'all', fontFamily: 'var(--font-mono)',
                  fontSize: 10.5, background: 'var(--surface)', borderRadius: 'var(--radius)',
                  color: 'var(--text-muted)' }}>{result.gatewayIssue.detail}</pre>
              </details>
            )}
          </div>
        )}

        {warnCount > 0 && (
          <div style={{ display: 'grid', gap: 4, padding: '8px 10px',
            background: 'var(--warning-bg)', borderRadius: 'var(--radius-md)' }}>
            {result.warnings.map((w, i) => (
              <div key={i} style={{ fontSize: 11.5, color: 'var(--warning-text)',
                display: 'flex', gap: 6, alignItems: 'flex-start' }}>
                <I.AlertTriangle size={11} style={{ marginTop: 2, flexShrink: 0 }} />{w}
              </div>
            ))}
          </div>
        )}

        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
          <span title={simulated ? 'Simulated — no real ZIP' : undefined}
            style={{ display: 'inline-flex' }}>
            <button className="btn primary" disabled={simulated || !result.downloadUrl}
              onClick={() => triggerDownload(result.downloadUrl, result.filename)}>
              <I.Download size={13} />Download SCORM package
            </button>
          </span>
          <button className="btn" onClick={onOpenPreview}>
            <I.Eye size={13} />Open in Preview
          </button>
          {!simulated && (
            <button className="btn" onClick={copyLink}>
              <I.Copy size={13} />{copied ? 'Copied' : 'Copy share link'}
            </button>
          )}
        </div>
      </div>

      <ModalFooter>
        <button className="btn sm ghost" onClick={onViewHistory}>View export history</button>
        <div style={{ flex: 1 }} />
        <button className="btn sm" onClick={onClose}>Done</button>
      </ModalFooter>
    </ModalShell>
  );
}

// ─── Export report modal (Fix 7) ─────────────────────────────────────────────
function ExportReportModal({ entry, onClose }) {
  return (
    <ModalShell icon="ListChecks" title="Build report"
      subtitle={relTime(entry.ts)} onCancel={onClose}
      headerExtra={<span className="chip" style={{ fontFamily: 'var(--font-mono)' }}>{entry.size}</span>}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <div>
          <div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-muted)',
            textTransform: 'uppercase', letterSpacing: '.04em', marginBottom: 8 }}>Phases</div>
          <div style={{ display: 'grid', gap: 4 }}>
            {(entry.phases || EXPORT_PHASES).map(p => (
              <div key={p} style={{ display: 'flex', alignItems: 'center', gap: 8,
                padding: '6px 10px', background: 'var(--surface-inset)', borderRadius: 'var(--radius)',
                fontSize: 12.5 }}>
                <I.Check size={12} style={{ color: 'var(--success)' }} />{p}
              </div>
            ))}
          </div>
        </div>
        <div>
          <div style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-muted)',
            textTransform: 'uppercase', letterSpacing: '.04em', marginBottom: 8 }}>
            Warnings {entry.warnings?.length ? `(${entry.warnings.length})` : ''}
          </div>
          {entry.warnings?.length ? (
            <div style={{ display: 'grid', gap: 4 }}>
              {entry.warnings.map((w, i) => (
                <div key={i} style={{ fontSize: 12, color: 'var(--warning-text)',
                  display: 'flex', gap: 6, alignItems: 'flex-start' }}>
                  <I.AlertTriangle size={11} style={{ marginTop: 2, flexShrink: 0 }} />{w}
                </div>
              ))}
            </div>
          ) : (
            <div style={{ fontSize: 12.5, color: 'var(--text-muted)' }}>
              <I.Check size={12} style={{ color: 'var(--success)' }} /> No warnings — clean build.
            </div>
          )}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11.5,
          color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }}>
          runtime {shortHash(entry.runtimeHash)} · SCORM {entry.scormVersion} · by {entry.editor || 'You'}
        </div>
      </div>
      <ModalFooter>
        <div style={{ flex: 1 }} />
        <button className="btn sm" onClick={onClose}>Close</button>
      </ModalFooter>
    </ModalShell>
  );
}

// ─── Coverage summary (unchanged visual) ─────────────────────────────────────
function CoverageSummary({ course }) {
  const rows = course.modules.flatMap(m => m.layouts.map(l => ({ ...l, mod: m.id })));
  const shown = rows.slice(0, 12);
  // Size the type-chip column off the LONGEST layout name so every row's
  // grey chip box is the same width and never overlaps the summary column.
  const typeColPx = React.useMemo(() => {
    const longest = shown.reduce((a, r) => (r.type.length > a.length ? r.type : a), '');
    const mono = getComputedStyle(document.documentElement)
      .getPropertyValue('--font-mono').trim() || 'monospace';
    const ctx = document.createElement('canvas').getContext('2d');
    ctx.font = `500 11px ${mono}`; // matches .chip
    // text + chip padding (8px ×2) + borders (1px ×2), rounded up
    return Math.ceil(ctx.measureText(longest).width) + 18;
  }, [shown.map(r => r.type).join()]);
  return (
    <div className="card">
      <div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)',
        display: 'flex', alignItems: 'center', gap: 10 }}>
        <h3 style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>Per-layout coverage</h3>
        <span style={{ fontSize: 11.5, color: 'var(--text-muted)' }}>
          Final pass · spot the weak ones before shipping
        </span>
        <div style={{ flex: 1 }} />
        <span className="pill" style={{ fontSize: 11 }}>avg 91%</span>
      </div>
      <div style={{ padding: '8px 12px', maxHeight: 240, overflowY: 'auto' }}>
        {shown.map(r => {
          const cov = r.status === 'accepted' ? 90 + (r.n * 3) % 10
            : r.status === 'issues' ? 64
            : r.status === 'pending' ? 0 : 88;
          return (
            <div key={r.id} style={{
              display: 'grid', gridTemplateColumns: `60px ${typeColPx}px 1fr 60px 140px auto`,
              gap: 10, alignItems: 'center', padding: '6px 10px', borderRadius: 'var(--radius)', fontSize: 12,
            }}>
              <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--text-muted)' }}>{r.mod}.L{r.n}</span>
              <span className="chip" style={{ fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap',
                width: '100%', boxSizing: 'border-box', justifyContent: 'center' }}>{r.type}</span>
              <span className="truncate" style={{ color: 'var(--text-muted)' }}>{locText(r.summary)}</span>
              <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600,
                color: cov > 85 ? 'var(--success-text)' : cov > 70 ? 'var(--text)'
                  : cov > 0 ? 'var(--warning-text)' : 'var(--text-faint)' }}>{cov}%</span>
              <div style={{ height: 4, background: 'var(--surface-inset)', borderRadius: 2, overflow: 'hidden' }}>
                <div style={{ height: '100%', width: `${cov}%`,
                  background: cov > 85 ? 'var(--success)' : cov > 70 ? 'var(--accent)'
                    : cov > 0 ? 'var(--warning)' : 'var(--border)' }} />
              </div>
              <StatusPill status={r.status} />
            </div>
          );
        })}
      </div>
    </div>
  );
}

// ─── Export history (Fix 7) ──────────────────────────────────────────────────
function ExportHistory({ history, onReport }) {
  const latest = history[0];
  return (
    <div className="card">
      <div style={{ padding: '14px 18px', borderBottom: '1px solid var(--border)',
        display: 'flex', alignItems: 'center', gap: 10 }}>
        <h3 style={{ margin: 0, fontSize: 13, fontWeight: 600 }}>Export history</h3>
        <span style={{ fontSize: 11.5, color: 'var(--text-faint)' }}>
          {history.length} build{history.length === 1 ? '' : 's'}
        </span>
        <div style={{ flex: 1 }} />
        <button className="btn sm ghost" disabled={!latest || latest.simulated || !latest.downloadUrl}
          title={latest && latest.simulated ? 'Simulated — no real ZIP' : undefined}
          onClick={() => latest?.downloadUrl && triggerDownload(latest.downloadUrl, latest.filename)}>
          <I.Download size={12} />Latest
        </button>
      </div>
      {history.length === 0 ? (
        <div style={{ padding: '28px 18px', textAlign: 'center', color: 'var(--text-faint)', fontSize: 12.5 }}>
          No builds yet. Your completed builds will appear here.
        </div>
      ) : (
        <div style={{ padding: '8px 12px', display: 'grid', gap: 4 }}>
          {history.map((e, i) => {
            const isLatestRuntime = e.runtimeHash === CURRENT_RUNTIME_HASH || e.runtimeHash === 'unknown';
            return (
              <div key={e.ts || i} style={{
                display: 'grid', gridTemplateColumns: '150px 70px 1fr 80px auto auto',
                gap: 10, alignItems: 'center', padding: '8px 10px', fontSize: 12, borderRadius: 'var(--radius)',
              }}
                onMouseOver={el => el.currentTarget.style.background = 'var(--surface-inset)'}
                onMouseOut={el => el.currentTarget.style.background = 'transparent'}>
                <span style={{ color: 'var(--text-muted)', fontSize: 11.5 }}>{relTime(e.ts)}</span>
                <span>{e.editor || 'You'}</span>
                <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
                  <span className="chip" style={{ fontSize: 10 }}>{shortHash(e.runtimeHash)}</span>
                  {isLatestRuntime && <span className="pill accepted" style={{ fontSize: 9.5 }}>latest</span>}
                  {e.simulated && <span style={{ fontSize: 10.5, color: 'var(--text-faint)' }}>simulated</span>}
                </span>
                <span style={{ fontFamily: 'var(--font-mono)' }}>{e.size}</span>
                <button className="btn sm ghost" onClick={() => onReport(e)}>
                  <I.ListChecks size={12} />Report
                </button>
                <button className="btn sm ghost" disabled={e.simulated || !e.downloadUrl}
                  title={e.simulated ? 'Simulated — no real ZIP' : (!e.downloadUrl ? 'Link expired' : undefined)}
                  onClick={() => e.downloadUrl && triggerDownload(e.downloadUrl, e.filename)}>
                  <I.Download size={12} />
                </button>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}

// ─── Modal primitives ────────────────────────────────────────────────────────
function ModalShell({ icon, iconTone, title, subtitle, headerExtra, onCancel, children }) {
  const Ic = I[icon] || I.Package;
  const tone = iconTone === 'success' ? { bg: 'var(--success-bg)', fg: 'var(--success)' }
    : { bg: 'var(--accent-bg)', fg: 'var(--accent)' };
  return (
    <div onClick={onCancel} style={{
      position: 'fixed', inset: 0, zIndex: 80,
      background: 'color-mix(in oklab, var(--text) 35%, transparent)', backdropFilter: 'blur(2px)',
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
    }}>
      <div onClick={e => e.stopPropagation()} className="slide-in" style={{
        width: 560, maxWidth: '100%', maxHeight: 'calc(100vh - 48px)',
        background: 'var(--surface)', border: '1px solid var(--border)',
        borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-xl)',
        display: 'flex', flexDirection: 'column', overflow: 'hidden',
      }}>
        <div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)',
          display: 'flex', alignItems: 'center', gap: 12 }}>
          <span style={{ width: 30, height: 30, borderRadius: 8, background: tone.bg, color: tone.fg,
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
            <Ic size={15} />
          </span>
          <div style={{ flex: 1, minWidth: 0 }}>
            <h2 style={{ margin: 0, fontSize: 14.5, fontWeight: 600 }}>{title}</h2>
            {subtitle && <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{subtitle}</div>}
          </div>
          {headerExtra}
          <button className="btn sm ghost" onClick={onCancel}
            style={{ width: 28, height: 28, padding: 0, justifyContent: 'center' }}>
            <I.X size={13} />
          </button>
        </div>
        <div style={{ flex: 1, overflowY: 'auto', padding: '18px 20px' }}>{children}</div>
      </div>
    </div>
  );
}

function ModalFooter({ children }) {
  return (
    <div style={{ marginTop: 18, paddingTop: 14, borderTop: '1px solid var(--border)',
      display: 'flex', alignItems: 'center', gap: 10 }}>
      {children}
    </div>
  );
}

function MField({ label, hint, children }) {
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
        <span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text-muted)',
          letterSpacing: '.02em' }}>{label}</span>
        {hint && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{hint}</span>}
      </div>
      {children}
    </div>
  );
}

Object.assign(window, { SurfaceExport, schemaModuleId });
