// 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) {
  const l = { ...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];
  }

  // (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;
      if ((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 34 `: 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.
  const LOCALIZED_FIELDS = new Set([
    'body','caption','correct','description','descriptionText','descriptionTitle',
    'dftiInfoLeftText','dftiInfoRightText','dftiInfoTitle','feedback','feedbackText',
    'image360CoverText','info','initialCoverText','interactionText','itemText',
    'itemTitle','label','leftColumnContent','note','objectAltText',
    'profileSetupDescription','profileSetupEmail','profileSetupFirstName',
    'profileSetupLastName','rightColumnContent','subTitle','tabText','tabTitle',
    'text','title','titleMain','titleSub','wrong',
  ]);
  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 };
        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.
const STANDIN_IMAGE = 'asset://a0c8d8c6-acaa-4c40-b569-ba08aa1815d5'; // 01.jpg
const STANDIN_VIDEO = 'asset://8ab6501f-5e44-4600-8700-0d14cd5b6fec'; // 01.mp4
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)) {
    if (typeof v === 'string' && v !== '' && !v.startsWith('asset://')) {
      if (_IMG_MEDIA_FIELDS.has(k)) { out[k] = STANDIN_IMAGE; continue; }
      if (_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;
}

function toDraftContent(course, settings, layoutDrafts) {
  const drafts = layoutDrafts || {};
  const buildModule = (m) => ({
    // The schema's module id is numeric; liveCourse modules carry both a
    // string id ('M1') and a 1-based display number `n` — prefer the number.
    id: typeof m.n === 'number' ? m.n : m.id,
    title: m.title,                          // LocalizedString — pass through
    // A liveCourse layout `l` is a UI stub (id/n/status/summary) with NO
    // content fields — the real content lives in LAYOUT_CONTENT_SAMPLES[type],
    // 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.LAYOUT_CONTENT_SAMPLES && window.LAYOUT_CONTENT_SAMPLES[type]) || {};
      return { ...base, ...draft, type };   // content only — NOT ...l
    }).map(normalizeLayoutForSchema).map(substituteStandInMedia),
  });

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

  // 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: { en: 'Ungrouped' }, color: '#64748b',
      modules: orphans.map(buildModule) });
  }
  // Drop empty groups, but guarantee at least one group exists.
  const nonEmpty = moduleGroups.filter(g => g.modules.length > 0);
  const finalGroups = nonEmpty.length ? nonEmpty
    : [{ name: { en: 'Course' }, color: '#64748b', modules: [] }];

  // courseSettings is omitted — the prototype's settings shape doesn't match
  // the gateway's CourseSettingsSchema, and the field is optional.
  return { moduleGroups: finalGroups };
}

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. The export
// pass intentionally does NOT fold in cross-surface seed invariants
// (assessment bindings etc.) — those live on their own surfaces — so a fresh
// course validates clean and the happy path can build.
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; };

  if (!course.modules.length) {
    errors.push({ id: 'no-modules', level: 'error', surface: 'draft',
      message: 'Course has no modules — add at least one before building.' });
  }
  course.modules.forEach(m => {
    if (!m.layouts.length) {
      errors.push({ id: 'empty-' + m.id, level: 'error', surface: 'draft', moduleId: m.id,
        message: `Module ${m.n} (${locText(m.title)}): module has no layouts.` });
    }
  });
  if (!langs.includes(defLang)) {
    errors.push({ id: 'def-lang', level: 'error', surface: 'localisation',
      message: `Default language “${defLang}” is not in the enabled languages.` });
  }

  const titleTypes = ['fullscreen_text_and_image', 'title', 'two_columns_text'];
  const videoTypes = ['fullscreen_video', 'small_video'];
  Object.entries(drafts || {}).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 (videoTypes.includes(d.type) && !hasText(d.videoUrl)) {
      errors.push({ id: 'video-' + lid, level: 'error', surface: 'layout', layoutId: lid,
        field: 'videoUrl', message: `${where(lid)}: Video layout has no media bound.` });
    }
    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.` });
      }
    }
  });

  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 (persisted in localStorage for simulated builds).
  const [history, setHistory] = React.useState(() => readLS(LS_HISTORY, []));
  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, brand: opts.brand, 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, next); return next; });
    setShowSuccess(true);
  }, [course.modules.length]);

  // 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, brand: opts.brand, 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, 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, brand: opts.brand, 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.
    const saveUrl = `${GATEWAY_BASE}/v1/courses/${courseId}/draft`;
    let saveRes = null;
    try {
      saveRes = await fetch(saveUrl, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${window.AUTH_TOKEN}`,
        },
        body: JSON.stringify({ content: toDraftContent(course, cs, layoutDrafts) }),
      }).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 = 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.

    // 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, brand: opts.brand,
        }),
      }).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: body.message }];
        }
      } 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)', overflowY: 'auto' }}
         data-screen-label="Surface 8 · Export">
      <div style={{ padding: '20px 28px 40px',
        display: 'flex', flexDirection: 'column', gap: 18 }}>

        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
          <h1 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Export</h1>
          <span style={{ fontSize: 13, color: 'var(--text-muted)' }}>· Validate · Build · Push to LMS</span>
          <div style={{ flex: 1 }} />
          <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>
        </div>

        {/* 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>
        <button className="btn lg primary" disabled={running} 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 orgs = settings.organisations || [];
  const primary = orgs.find(o => o.isPrimary) || orgs[0];

  const remembered = readLS(LS_OPTS, null);
  const [scormVersion, setScormVersion] = React.useState(
    readLS(LS_SCORM, null) || settings.metadata.scormVersion || '1.2');
  const [selLangs, setSelLangs] = React.useState(remembered?.languages || langs.slice());
  const [brand, setBrand] = React.useState(remembered?.brand || primary?.id || '');
  const [remember, setRemember] = React.useState(false);

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

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

  const toggleLang = l => setSelLangs(s => s.includes(l) ? s.filter(x => x !== l) : [...s, l]);
  const orgLabel = o => (o.label?.en) || o.id;

  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 => (
              <button key={v} onClick={() => setScormVersion(v)} className="focusable"
                style={{
                  padding: '6px 14px', height: 32, fontSize: 12.5, cursor: 'default', fontFamily: 'inherit',
                  background: scormVersion === v ? 'var(--accent-bg)' : 'var(--surface)',
                  color: 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,
                }}>
                {v === '2004' ? '2004 4th Ed' : v}
              </button>
            ))}
          </div>
        </MField>

        {showLangs && (
          <MField label="Languages" hint={`${selLangs.length} of ${langs.length} selected`}>
            <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
              {langs.map(l => {
                const on = selLangs.includes(l);
                return (
                  <button key={l} onClick={() => toggleLang(l)} className="focusable"
                    style={{
                      display: 'inline-flex', alignItems: 'center', gap: 6, padding: '5px 10px', height: 30,
                      fontSize: 12.5, cursor: 'default', fontFamily: 'inherit', borderRadius: 'var(--radius)',
                      background: on ? 'var(--accent-bg)' : 'var(--surface)',
                      color: on ? 'var(--accent-text)' : 'var(--text-muted)',
                      border: '1px solid', borderColor: on ? 'var(--accent-border)' : 'var(--border-strong)',
                      fontWeight: on ? 600 : 500,
                    }}>
                    <span style={{ fontSize: 13 }}>{window.LANG_FLAGS?.[l] || ''}</span>
                    {window.LANG_NAMES?.[l] || l}
                    {on && <I.Check size={12} />}
                  </button>
                );
              })}
            </div>
          </MField>
        )}

        {showBrand && (
          <MField label="Brand">
            <select className="field select-elegant" value={brand}
              onChange={e => setBrand(e.target.value)}
              style={{ width: '100%', paddingRight: 26 }}>
              {orgs.map(o => (
                <option key={o.id} value={o.id}>
                  {orgLabel(o)}{o.isPrimary ? ' · primary' : ''}
                </option>
              ))}
            </select>
          </MField>
        )}

        <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>
        <button className="btn sm primary" disabled={!canBuild}
          onClick={() => onBuild({ scormVersion, languages: selLangs, brand, 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 });
