// Sample course: "Difficult Interactions" — leadership training
// 4 modules, 24 layouts, EN primary + IT secondary

// ─── LocalizedString helpers ────────────────────────────────────────────────
// Production schema types every author-facing text field as a LocalizedString:
// a per-language object `{ [langCode]: string }` (Zod: z.record(z.string(),
// z.string())). The prototype seeds English-only — i.e. `{ en: '…' }`.
//
// These helpers are the ONE place that knows the shape, so every read/write
// site (the controlled text widgets, the inline question editors, and the
// player preview) stays shape-agnostic. They tolerate a flat string too, so a
// not-yet-migrated value never renders blank — read auto-upgrades, write
// returns an object.
//
// Defined here in data.jsx (the earliest app script after icons) so every
// later script resolves them at render time regardless of load order.
const LOC_LANG_KEYS = new Set([
  'en','it','de','fr','es','pt','ptbr','nl','ja','jp','ko','zh','zht','zh-hant',
  'ar','th','tr','hk','ch','id','ru','pl','sv','da','fi','no','cs','el','he',
  'hi','uk','ro','hu','vi','fa','ms','fil','ca','bg','hr','sk','sl','lv','lt',
  'et','is','ga','gd','cy','af','sw','ta','bn','ur','sr','eslat',
]);

// The default language for non-localisation surfaces (architecture, the
// per-layout editor, the player preview). React context so a widget reads the
// active language without every caller threading a prop.
const LocDefaultLangContext = React.createContext('en');

// Read the active-language string out of a LocalizedString value. `value` may
// be a per-language object OR (legacy) a flat string; both resolve to a string.
// Falls back to the default language, then `en`, then any present language.
function locText(value, lang) {
  if (value == null) return '';
  if (typeof value === 'string') return value;
  if (typeof value !== 'object') return '';
  if (lang && typeof value[lang] === 'string') return value[lang];
  if (typeof value.en === 'string') return value.en;
  for (const k in value) if (typeof value[k] === 'string') return value[k];
  return '';
}

// Write `str` into one language slot, preserving every other language. Always
// returns a per-language object (auto-upgrades a legacy flat string to { en }).
function locSet(value, lang, str) {
  const base = (value && typeof value === 'object' && !Array.isArray(value))
    ? value
    : (typeof value === 'string' && value) ? { en: value } : {};
  return { ...base, [lang || 'en']: str };
}

// An empty assessments slice — ONE definition (Stage 5 Phase 3b).
//
// This literal had been written out four times: twice below in this file, and
// once each as `ASSESSMENT_EMPTY` in surface-assessments.jsx and
// `LOC_ASSESSMENT_EMPTY` in surface-localisation.jsx. Phase 3b needed a fifth for
// the Content Mapping grid, which is the point at which copying it again stops
// being cheaper than sharing it (`feedback_one_rule_one_place`).
//
// A FACTORY, not a shared object: every caller uses it as a fallback for a
// missing slice and then reads into it, and one frozen literal shared across
// three surfaces is a mutation waiting to be blamed on the wrong screen. Both
// phases are always present, because every consumer reads
// `assessments.pre.groups` unguarded.
//
// The two surface-local copies are deliberately left alone here — narrowing them
// is a separate, safe pass rather than a widening of this one
// (`feedback_a_second_narrow_pass_beats_widening_a_shared_one`).
function makeEmptyAssessments() {
  return {
    randomiseGroupOrder: true,
    pre: { enabled: false, groups: [] },
    post: { enabled: false, groups: [] },
  };
}

// ─── Per-language MEDIA references (OQ-081) ──────────────────────────────────
// Same `{ [lang]: string }` runtime shape as a LocalizedString, but the two reads
// must behave DIFFERENTLY, so they get separate helpers rather than a shared one.
// Reaching for locText/locSet on a subtitle field is the mistake these exist to
// make hard.

// Read one language's media reference. STRICT: no fallback to `en` or to "any
// present language", unlike locText above.
//
// The fallback is right for text — showing English prose beats showing nothing —
// and it is the whole defect for media. Returning the English track for a missing
// Italian one is how an Italian learner ends up reading English cues from a file
// sitting at an Italian path, with every export gate green. Absence must read as
// absence.
function locUrl(value, lang) {
  if (value == null) return '';
  if (typeof value === 'string') return value;          // legacy pre-OQ-081 shape
  if (typeof value !== 'object') return '';
  const v = value[lang || 'en'];
  return typeof v === 'string' ? v : '';
}

// Write one language's media reference, preserving every other language.
// An empty `ref` DELETES that language's key rather than storing `''`.
//
// `LocalizedUrlSchema`'s value type is `.min(1)`, so a `{ it: '' }` husk is
// rejected outright — and that strictness is deliberate: an empty-string husk
// would be a second encoding of "no track" that beats a real value in precedence
// tests. Omitting the key is the only representation, so removal must delete.
function locUrlSet(value, lang, ref) {
  const base = (value && typeof value === 'object' && !Array.isArray(value))
    ? { ...value }
    : (typeof value === 'string' && value) ? { en: value } : {};
  const key = lang || 'en';
  if (!ref) delete base[key];
  else base[key] = ref;
  return base;
}

// True when `o` is a LocalizedString object: a plain object whose keys are ALL
// language codes and whose values are ALL strings. Used to flatten arbitrary
// content trees for display (e.g. the player preview) without enumerating every
// field name. Strict by design — `{ state, color, textColor }` and the like are
// NOT mistaken for translations because their keys aren't language codes.
function isLocObject(o) {
  if (!o || typeof o !== 'object' || Array.isArray(o)) return false;
  const keys = Object.keys(o);
  if (!keys.length) return false;
  return keys.every(k => LOC_LANG_KEYS.has(k) && typeof o[k] === 'string');
}

// Deep-resolve every LocalizedString object inside `value` to its active-language
// string, recursing through arrays and plain objects. Returns a NEW structure;
// the original (the seed / draft) is never mutated.
function deepLocResolve(value, lang) {
  if (Array.isArray(value)) return value.map(v => deepLocResolve(v, lang));
  if (value && typeof value === 'object') {
    if (isLocObject(value)) return locText(value, lang);
    const out = {};
    for (const k in value) out[k] = deepLocResolve(value[k], lang);
    return out;
  }
  return value;
}

if (typeof window !== 'undefined') {
  Object.assign(window, {
    locText, locSet, locUrl, locUrlSet,
    isLocObject, deepLocResolve, LocDefaultLangContext, LOC_LANG_KEYS,
  });
}

const LAYOUT_TYPES = [
  { id: 'fullscreen_video',         label: 'Fullscreen video',         icon: 'Film',    group: 'Video' },
  { id: 'fullscreen_text_and_image', label: 'Fullscreen text + image', icon: 'Image',   group: 'Hero' },
  { id: 'text_and_image',           label: 'Text + image rows',        icon: 'Layers',  group: 'Content' },
  { id: 'small_video',              label: 'Small video',              icon: 'Film',    group: 'Video' },
  { id: 'sequence',                 label: 'Sequence (tabbed)',        icon: 'Tabs',    group: 'Interactive' },
  { id: 'icons_discover',           label: 'Icons discover',           icon: 'Grid',    group: 'Interactive' },
  { id: 'vertical_tabs',            label: 'Vertical tabs',            icon: 'Tabs',    group: 'Interactive' },
  { id: 'horizontal_tabs',          label: 'Horizontal tabs',          icon: 'Tabs',    group: 'Interactive' },
  { id: 'hidden_items_flat_image',  label: 'Hidden items · flat image', icon: 'Eye',    group: 'Interactive' },
  { id: 'hidden_items_360_image',   label: 'Hidden items · 360° image', icon: 'Globe',  group: 'Interactive' },
  // Canonical stored type for both hidden_items display ids (Fix 4). Hidden
  // from the picker (the two display ids above are what authors pick), but
  // registered so type chips / previews resolve a label + icon for the value
  // actually stored on the data.
  { id: 'hidden_items',             label: 'Hidden items',             icon: 'Eye',     group: 'Interactive', hidden: true },
  { id: 'mandatoryQuestion',        label: 'Mandatory question',       icon: 'Lock',    group: 'Assessment', hidden: true },
  { id: 'question',                 label: 'Question (non-blocking)',  icon: 'AlertCircle', group: 'Assessment', hidden: true },
  { id: 'quiz_images',              label: 'Quiz · image',             icon: 'ClipboardCheck', group: 'Assessment' },
  { id: 'quiz_gaming',              label: 'Quiz · gaming',            icon: 'Trophy',  group: 'Assessment' },
  { id: 'title',                    label: 'Title divider',            icon: 'Type',    group: 'Hero' },
  { id: 'two_columns_text',         label: 'Two columns text',         icon: 'FileText',group: 'Content' },
  { id: 'object_viewer',            label: '3D object viewer',         icon: 'Cube',    group: 'Interactive' },
];

// ── hidden_items is PURE UI SUGAR (Fix 4, locked 2026-06-04) ────────────────
// The two LAYOUT_TYPES entries above (`hidden_items_flat_image` /
// `hidden_items_360_image`) are DISPLAY ids for the "+ Add layout" picker only. The value
// stored at `layout.type` is ALWAYS the canonical `hidden_items`; the flat vs
// 360° variant is encoded by which media URL is populated (`imageUrl` for flat,
// `image360Url` for 360°) — the same branch the Player runtime uses. Never
// write a hidden_items display id to `layout.type`.
const LAYOUT_DISPLAY_TO_CANONICAL = {
  hidden_items_flat_image: 'hidden_items',
  hidden_items_360_image:  'hidden_items',
};
// display id (or canonical id) → the canonical id stored on the data.
function canonicalLayoutType(displayId) {
  return LAYOUT_DISPLAY_TO_CANONICAL[displayId] || displayId;
}
// a stored layout → the display id to open the editor with (reverse map).
// hidden_items reverses to flat / 360° based on which media URL is populated.
function displayLayoutType(layout) {
  if (!layout) return undefined;
  if (layout.type === 'hidden_items') {
    return layout.image360Url ? 'hidden_items_360_image' : 'hidden_items_flat_image';
  }
  return layout.type;
}

// The one course whose content base is the narrative SAMPLE_COURSE below.
// Every other course (created via the real new-course flow) opens in
// contentMode 'blank' — empty structure, skeleton layout content.
const DEMO_COURSE_ID = '7bf69e2d-51e7-4856-86bb-bb2b77b47216';

const SAMPLE_COURSE = {
  // Real seeded test-course UUID from the production Postgres (uuid-typed
  // course.id column) — the gateway 500s on non-UUID ids. Phase 4.
  id: DEMO_COURSE_ID,
  // Denormalised display copy of the course name (chrome breadcrumb / header).
  // NOT a LocalizedString — the canonical localised course name is
  // SAMPLE_COURSE_SETTINGS.metadata.title ({ en: … }). Left flat by design.
  title: 'Difficult Interactions',
  topic: 'Leadership · Conversational skills',
  defaultLanguage: 'en',
  languages: ['en', 'it', 'de', 'fr', 'es'],
  scormVersion: '2004',
  template: 'corporate-default',
  brand: 'MOHG',
  lastSaved: '2 minutes ago',
  aiSpend: { used: 4.82, cap: 25.00, currency: 'USD' },
  validation: {
    // The assessment entry that used to live here was a hardcoded literal
    // naming a module ("M4") that may not exist — a permanent red error no
    // author could clear. Real assessment problems now come from
    // `validateAssessments` in surface-export.jsx, which reads the actual
    // authored slice and names the actual module.
    total: 2,
    items: [
      { id: 'v1', level: 'warning', surface: 'draft', moduleId: 'M2', layoutId: 'M2-L5', message: 'Coverage below 80% — 4 source claims unmapped' },
      { id: 'v2', level: 'warning', surface: 'languages', message: '2 layouts in Italian need review after recent EN edits' },
    ],
  },
  moduleGroups: [
    { id: 'g1', title: { en: 'Preparing for difficult moments' }, color: '#7c3aed' },
    { id: 'g2', title: { en: 'Handling and recovering' },          color: '#0891b2' },
  ],
  /* ⚠️ SUPERSEDED 2026-08-12 (Phase 4b) — NOTHING READS THESE TWO FIELDS.
     Organisations are persisted in `courseSettings.brands` and reach both
     surfaces as props; grouping is derived from `brands.length > 0` in each,
     rather than from a flag. Kept, not deleted, as the record of what the demo
     course held — but do not add a reader: it would resurrect the two-copies
     problem this phase removed.

     Two things the old comment here got wrong, worth leaving visible:
       · "the brand is the gate the learner picks before their role surfaces" —
         RIGHT, and better than the code that followed it. That is exactly what
         the Player does (`js/scripts.js:3948`), and it took until Phase 4a to
         actually emit it.
       · "Brand colours ... matches what the learner sees on the brand-picker
         screen in the Player" — WRONG. The learner's brand pills are text-only
         (their `<img>` tags are commented out in the pinned runtime) and the
         theme resolves a CSS FOLDER name, of which one ships. The colour is an
         authoring-side aid and nothing more. A per-organisation LOGO *is*
         honoured, on the header and Home screen — that is Phase 4d. */
  selectBrand: true,
  brands: [
    { code: 'bt', label: { en: 'BT' },        color: '#5C2D91', textColor: '#ffffff' },
    { code: 'or', label: { en: 'Openreach' }, color: '#1B2A3D', textColor: '#ffffff' },
  ],
  modules: [
    {
      id: 'M1', n: 1, title: { en: 'Recognising tension early' }, group: 'g1',
      summary: { en: 'Spot the verbal and non-verbal cues that signal a conversation is sliding sideways.' },
      duration: '08:24', mandatory: true,
      layouts: [
        { id: 'M1-L1', n: 1, type: 'fullscreen_text_and_image', status: 'accepted', summary: { en: 'When does a conversation become difficult?' } },
        { id: 'M1-L2', n: 2, type: 'text_and_image',           status: 'accepted', summary: { en: 'Five everyday signals — paired examples and counter-examples' } },
        { id: 'M1-L3', n: 3, type: 'small_video',              status: 'accepted', summary: { en: 'SME interview · Dr. Priya Naidu · 02:14' } },
        { id: 'M1-L4', n: 4, type: 'sequence',                 status: 'accepted', summary: { en: '6-step scenario · the standup that went wrong' } },
        { id: 'M1-L5', n: 5, type: 'icons_discover',           status: 'proposed', summary: { en: '4 patterns that escalate quickly' } },
        { id: 'M1-L6', n: 6, type: 'quiz_images',             status: 'accepted', summary: { en: 'Knowledge check — what is the earliest signal?' } },
      ],
    },
    {
      id: 'M2', n: 2, title: { en: 'Frameworks for tough talks' }, group: 'g1',
      summary: { en: 'Two named frameworks (CLEAR and SBI) and when each one fits.' },
      duration: '12:10', mandatory: true,
      layouts: [
        { id: 'M2-L1', n: 1, type: 'title',                    status: 'accepted', summary: { en: 'Section opener · Frameworks' } },
        { id: 'M2-L2', n: 2, type: 'fullscreen_text_and_image',status: 'accepted', summary: { en: 'Why frameworks beat improvisation' } },
        { id: 'M2-L3', n: 3, type: 'sequence',                 status: 'proposed', summary: { en: 'Sarah & Jordan · a feedback scenario in 6 tabs' }, selected: true },
        { id: 'M2-L4', n: 4, type: 'fullscreen_video',         status: 'pending',  summary: { en: 'Sarah & Jordan · the full conversation, in-video drills' } },
        { id: 'M2-L5', n: 5, type: 'vertical_tabs',            status: 'accepted', summary: { en: 'CLEAR vs SBI — side-by-side' } },
        { id: 'M2-L6', n: 6, type: 'small_video',              status: 'issues',   summary: { en: 'In-video drill: identify the framework being used' } },
        { id: 'M2-L7', n: 7, type: 'horizontal_tabs',          status: 'pending',  summary: { en: 'Coaching · giving · receiving · 3-tab carousel' } },
        { id: 'M2-L8', n: 8, type: 'quiz_images',             status: 'pending',  summary: { en: 'Pick the right framework for the scenario' } },
      ],
    },
    {
      id: 'M3', n: 3, title: { en: 'De-escalation in practice' }, group: 'g2',
      summary: { en: 'When tempers rise — concrete moves to slow the conversation down.' },
      duration: '09:47', mandatory: true,
      layouts: [
        { id: 'M3-L1', n: 1, type: 'fullscreen_text_and_image',status: 'accepted', summary: { en: 'The temperature dial · setting the mental model' } },
        { id: 'M3-L2', n: 2, type: 'icons_discover',           status: 'accepted', summary: { en: '6 verbal moves to lower the temperature' } },
        { id: 'M3-L3', n: 3, type: 'sequence',                 status: 'accepted', summary: { en: '5-step scenario · the customer escalation' } },
        { id: 'M3-L4', n: 4, type: 'hidden_items',             status: 'proposed', summary: { en: 'Interactive · spot the calming signals in the photo' } },
        { id: 'M3-L5', n: 5, type: 'two_columns_text',         status: 'accepted', summary: { en: 'Do · Avoid — paired guidance' } },
        { id: 'M3-L6', n: 6, type: 'quiz_images',             status: 'accepted', summary: { en: 'Practice · select the most effective response' } },
      ],
    },
    {
      id: 'M4', n: 4, title: { en: 'After the conversation' }, group: 'g2',
      summary: { en: 'Repair, follow-through, and what to write down so the moment isn\'t lost.' },
      duration: '06:32', mandatory: false,
      layouts: [
        { id: 'M4-L1', n: 1, type: 'fullscreen_text_and_image',status: 'accepted', summary: { en: 'Why follow-through is the conversation' } },
        { id: 'M4-L2', n: 2, type: 'small_video',              status: 'accepted', summary: { en: 'Reflection prompt · 60-second written exercise' } },
        { id: 'M4-L3', n: 3, type: 'text_and_image',           status: 'proposed', summary: { en: 'Three repair moves with examples' } },
        { id: 'M4-L4', n: 4, type: 'horizontal_tabs',          status: 'pending',  summary: { en: 'Write · share · check-in · 3-tab carousel' } },
        { id: 'M4-L5', n: 5, type: 'quiz_gaming',             status: 'pending',  summary: { en: 'Final check · what should follow-through include?' } },
      ],
    },
  ],
};

// ── Sources (raw uploads being processed) ───────────────────────────────────
const SAMPLE_SOURCES = [
  { id: 's1', name: 'Difficult-Interactions_v3-final.docx', kind: 'doc', size: '847 KB',
    uploaded: '2 hrs ago', status: 'ready', classification: 'Expository', warnings: 0,
    paragraphs: 142, anecdotes: 8 },
  { id: 's2', name: 'Priya-Naidu_SME-interview.mp4', kind: 'video', size: '184 MB',
    uploaded: '2 hrs ago', status: 'ready', classification: 'Narrative', warnings: 1,
    duration: '11:42', transcriptWords: 1842 },
  { id: 's3', name: 'Customer-escalation-case_audio.mp3', kind: 'audio', size: '24 MB',
    uploaded: '38 min ago', status: 'ready', classification: 'Narrative', warnings: 2,
    duration: '08:12', transcriptWords: 1204 },
  { id: 's4', name: 'CLEAR-framework_handbook.docx', kind: 'doc', size: '312 KB',
    uploaded: '38 min ago', status: 'ingesting', classification: 'Expository', warnings: 0,
    paragraphs: 67, progress: 0.62 },
  { id: 's5', name: 'Manager-anecdotes-collected.docx', kind: 'doc', size: '198 KB',
    uploaded: '12 min ago', status: 'queued', classification: 'Narrative', warnings: 0 },
  { id: 's6', name: 'old-handout_2019.docx', kind: 'doc', size: '94 KB',
    uploaded: '5 min ago', status: 'failed', classification: 'Expository', warnings: 3 },
];

// ── Section context for the currently-selected layout (M2-L3 sequence) ──────
// AI section-analysis context (source provenance, paragraph mapping). `summary`
// here is editorial analysis, NOT learner-facing course content — not a
// LocalizedString, intentionally left flat.
const SAMPLE_SECTION_CONTEXT = {
  sectionId: 'M2.S3',
  summary: 'Sarah, a senior engineer, delivers difficult feedback to Jordan after a missed deadline. Demonstrates the SBI framework end-to-end.',
  sourceFile: 'Difficult-Interactions_v3-final.docx',
  paragraphs: [
    { id: 'p1', text: 'Sarah had been dreading the conversation all week. Jordan had missed two deadlines in a row, and the team was beginning to whisper.', kept: true },
    { id: 'p2', text: 'She thought about the SBI framework — Situation, Behavior, Impact — that her own manager had used with her three years earlier. The clarity had stung at the time, but it had also stuck.', kept: true },
    { id: 'p3', text: '"Jordan, when you missed the API spec review on Tuesday and the design sign-off on Friday, the consequence is that two engineers were blocked and we slipped the integration."', kept: true },
    { id: 'p4', text: 'Jordan was quiet for a moment. Then: "I should have told you sooner. I\'ve been struggling with the new auth layer and didn\'t want to flag it."', kept: true },
    { id: 'p5', text: 'This is the moment SBI is designed for: surface the behaviour, name the impact, then leave room for the other person to bring context.', kept: true },
  ],
  coverage: { covered: 24, total: 25, pct: 96 },
  anecdotes: [
    { id: 'a1', text: 'Three years earlier, Sarah\'s own manager used SBI with her.', status: 'verified', cite: 'Source p.2, ¶3' },
    { id: 'a2', text: 'The team was beginning to whisper about Jordan.', status: 'verified', cite: 'Source p.2, ¶1' },
    { id: 'a3', text: 'Jordan struggled silently with the new auth layer.', status: 'verifying', cite: 'Source p.3, ¶4' },
  ],
  otherSuggestions: [
    { type: 'vertical_tabs',    score: 0.81, rationale: 'Could be reframed as Sarah-view vs Jordan-view tabs' },
    { type: 'small_video',      score: 0.74, rationale: 'Could re-shoot as dramatised dialogue, but adds production time' },
    { type: 'horizontal_tabs',  score: 0.62, rationale: 'Less optimal — fragments the narrative thread' },
  ],
};

// (SAMPLE_SUGGESTIONS removed — right-hand suggestions panel deleted from Course architecture)

// ── Sequence tabs for the currently-edited layout ──────────────────────────
// Legacy standalone demo for the original sequence editor. The canonical,
// schema-shaped sequence content (with LocalizedString tabTitle/tabText) lives
// in LAYOUT_CONTENT_SAMPLES.sequence — that one is migrated. These flat
// title/body strings drive only the legacy demo path; left flat by design.
const SAMPLE_SEQUENCE_TABS = [
  { id: 't1', kind: 'text',     mobileText: 'Set-up',         title: 'The missed deadline',     body: 'Sarah notices Jordan has missed two consecutive deliverables. The team is starting to whisper.' },
  { id: 't2', kind: 'video',    mobileText: 'Prep',           title: 'Sarah prepares',          body: 'Watch how Sarah uses SBI — Situation, Behavior, Impact — to scaffold what she wants to say.', duration: '01:24' },
  { id: 't3', kind: 'questionMultiple', mobileText: 'Decide', title: 'Which opener works best?', body: 'Pick the opener that uses SBI most cleanly.', answers: 4 },
  { id: 't4', kind: 'text',     mobileText: 'Feedback ✓',     title: 'Correct — that\'s an SBI opener',   body: 'Naming the specific behaviour without judgement is the SBI core move.', branch: 'correct' },
  { id: 't5', kind: 'text',     mobileText: 'The talk',       title: 'The conversation',        body: 'See how Sarah names the behaviour, then waits. Jordan brings context unprompted.' },
  { id: 't6', kind: 'text',     mobileText: 'Wrap',           title: 'After the talk',          body: 'Both walk away with a concrete next step and a follow-up scheduled.' },
];

const LANG_FLAGS = {
  en: '🇬🇧', it: '🇮🇹', de: '🇩🇪', fr: '🇫🇷', es: '🇪🇸', jp: '🇯🇵',
  ar: '🇸🇦', hk: '🇭🇰', ch: '🇨🇳', id: '🇮🇩', ms: '🇲🇾', th: '🇹🇭', tr: '🇹🇷', pt: '🇵🇹', nl: '🇳🇱',
};
// Display names are ENDONYMS (each language's own name) so the picker matches the
// exported runtime's <label_text>. The 15 codes are exactly those with a bundled
// UI.xml template + flags in @dynamo/scorm-packager. hk=Traditional / ch=Simplified.
const LANG_NAMES = {
  en: 'English', it: 'Italiano', de: 'Deutsch', fr: 'Français', es: 'Español',
  jp: '日本語', ar: 'العربية', hk: '中文(繁體)', ch: '中文(简体)',
  id: 'Indonesia', ms: 'Bahasa Malaysia', th: 'ภาษาไทย', tr: 'Türkçe', pt: 'Português', nl: 'Nederlands',
};

const SAMPLE_ORGS = [
  { id: 'mohg',     name: 'MOHG',     full: 'Mandarin Oriental Hotel Group',
    desc: 'Hospitality · Code of Conduct, Fire awareness, GDPR',
    color: ['#0F4F44', '#0A2C28'], glyph: 'M', currentUser: 'Lisa Park · Senior Editor',
    plan: 'Enterprise', stats: { courses: 12, modules: 47, editors: 8 } },
  { id: 'deloitte', name: 'Deloitte', full: 'Deloitte Touche Tohmatsu',
    desc: 'Professional services · Onboarding, Ethics, Auditing',
    color: ['#86BC25', '#3F6F0F'], glyph: 'D', currentUser: '',
    plan: 'Enterprise', stats: { courses: 31, modules: 142, editors: 24 } },
  { id: 'bt',       name: 'BT',       full: 'BT Group',
    desc: 'Telecom · Customer Experience, Compliance',
    color: ['#5514B4', '#2D0466'], glyph: 'BT', currentUser: '',
    plan: 'Enterprise', stats: { courses: 28, modules: 168, editors: 19 } },
];

// Org-level course catalogue (home screen cards). `title` here is the
// catalogue display name across many courses — NOT the in-course module
// hierarchy Fix D migrates. Left flat by design.
const SAMPLE_COURSES_LIST = [
  // MOHG
  { id: 'c1', org: 'mohg', title: 'Difficult Interactions', topic: 'Leadership',
    modules: 4, layouts: 24, langs: ['en','it'], lastSaved: '2 min ago',
    editor: 'Lisa Park', validation: { level: 'warning', count: 3 }, status: 'in-progress',
    coverImage: 'difficult', current: true },
  { id: 'c2', org: 'mohg', title: 'Code of Conduct', topic: 'Compliance',
    modules: 6, layouts: 41, langs: ['en','it','jp','hk','ch'], lastSaved: '2 days ago',
    editor: 'Marco Rossi', validation: { level: 'success', count: 0 }, status: 'shipped',
    coverImage: 'conduct' },
  { id: 'c3', org: 'mohg', title: 'Fire Awareness', topic: 'Safety',
    modules: 3, layouts: 18, langs: ['en','it','jp','hk','ch','ar','fr'], lastSaved: '1 week ago',
    editor: 'Lisa Park', validation: { level: 'success', count: 0 }, status: 'shipped',
    coverImage: 'fire' },
  { id: 'c4', org: 'mohg', title: 'Anti-Bribery', topic: 'Compliance',
    modules: 5, layouts: 32, langs: ['en','it','jp'], lastSaved: '3 weeks ago',
    editor: 'Akira Sato', validation: { level: 'success', count: 0 }, status: 'shipped',
    coverImage: 'compliance' },
  { id: 'c5', org: 'mohg', title: 'Cybersecurity Foundations', topic: 'Security',
    modules: 4, layouts: 26, langs: ['en'], lastSaved: '32 min ago',
    editor: 'Marco Rossi', validation: { level: 'error', count: 2 }, status: 'in-progress',
    coverImage: 'security' },
  { id: 'c6', org: 'mohg', title: 'New Hire Welcome', topic: 'Onboarding',
    modules: 2, layouts: 14, langs: ['en','it','jp','hk','ch','ar','fr','es','de'], lastSaved: '5 days ago',
    editor: 'Lisa Park', validation: { level: 'success', count: 0 }, status: 'shipped',
    coverImage: 'welcome' },
  { id: 'c7', org: 'mohg', title: 'Guest Privacy & GDPR', topic: 'Compliance',
    modules: 4, layouts: 22, langs: ['en','it'], lastSaved: '1 hr ago',
    editor: 'Lisa Park', validation: { level: 'warning', count: 1 }, status: 'in-progress',
    coverImage: 'privacy' },
  { id: 'c8', org: 'mohg', title: 'Service Excellence', topic: 'Hospitality',
    modules: 5, layouts: 30, langs: ['en','jp','hk','ch'], lastSaved: '6 weeks ago',
    editor: 'Akira Sato', validation: { level: 'success', count: 0 }, status: 'archived',
    coverImage: 'service' },
];

const SAMPLE_ROLES = [
  /* `label` is the single role name shown everywhere (column header,
     Player picker). `code` is the internal id used for keying and
     <UI_${code}>.xml file lookup — never shown in the authoring UI.
     `brand` ties the role to a course brand; only consumed when
     `course.selectBrand` is true (§25.8). */
  { code: 'manager',  label: 'Manager',                brand: 'bt',  modules: ['M1','M2','M3','M4'], skipPreAssessment: false, uiOverride: false },
  { code: 'ic',       label: 'Individual contributor', brand: 'bt',  modules: ['M1','M2','M3'],      skipPreAssessment: false, uiOverride: false },
  { code: 'newhire',  label: 'New hire',               brand: 'or',  modules: ['M1','M2'],           skipPreAssessment: true,  uiOverride: true },
  { code: 'exec',     label: 'Executive',              brand: 'or',  modules: ['M2','M3','M4'],      skipPreAssessment: true,  uiOverride: false },
];

// Pre/post-assessment question groups, each bound to a subset of roles.
// Single source of truth so the Assessments surface and the Roles matrix
// stay in sync; the Roles surface flips role↔group bindings in place,
// the Assessments surface edits group + question text.
//
// Per-question role bindings are stored on the question itself so an
// author can include a group for a role but skip one of its questions
// for that role (rare but supported — usually the question inherits
// from the group's `roles` set).
const SAMPLE_ASSESSMENTS = {
  pre: {
    groups: [
      { id: 'pg1', mod: 'M1', label: 'Tension signals',     roles: ['manager','ic','newhire','exec'],
        questions: [
          { id: 'pq1', prompt: 'What is the earliest signal that a conversation is becoming difficult?', roles: ['manager','ic','newhire','exec'] },
          { id: 'pq2', prompt: 'Which scenario shows a "stockpiling" pattern most clearly?',             roles: ['manager','ic','newhire','exec'] },
          { id: 'pq3', prompt: 'Pair the verbal cue with its underlying meaning.',                       roles: ['manager','ic','exec'] },
        ] },
      { id: 'pg2', mod: 'M2', label: 'Framework recall',    roles: ['manager','ic','exec'],
        questions: [
          { id: 'pq4', prompt: 'Which framework fits a 1:1 feedback moment best?',                       roles: ['manager','ic','exec'] },
          { id: 'pq5', prompt: 'Match the framework to the situation.',                                   roles: ['manager','exec'] },
        ] },
      { id: 'pg3', mod: 'M3', label: 'De-escalation moves', roles: ['manager','ic','exec'],
        questions: [
          { id: 'pq6', prompt: 'Identify the verbal move that lowers temperature fastest.',              roles: ['manager','ic','exec'] },
          { id: 'pq7', prompt: 'Pick the response that re-grounds the conversation.',                    roles: ['manager','exec'] },
        ] },
    ],
  },
  post: {
    groups: [
      { id: 'pog1', mod: 'M1', label: 'Recognising tension early', roles: ['manager','ic','newhire','exec'],
        questions: [
          { id: 'poq1', prompt: 'Spot the earliest signal in this conversation snippet.', roles: ['manager','ic','newhire','exec'] },
          { id: 'poq2', prompt: 'Rank the four signals from weakest to strongest.',       roles: ['manager','ic','exec'] },
        ] },
      { id: 'pog2', mod: 'M2', label: 'Frameworks for tough talks', roles: ['manager','ic','exec'],
        questions: [
          { id: 'poq3', prompt: 'Apply CLEAR to a scenario.',                              roles: ['manager','ic','exec'] },
          { id: 'poq4', prompt: 'Compare CLEAR and SBI in a one-paragraph response.',      roles: ['manager','exec'] },
        ] },
      { id: 'pog3', mod: 'M3', label: 'De-escalation in practice', roles: ['manager','exec'],
        questions: [
          { id: 'poq5', prompt: 'Select the most effective response to this escalation.',  roles: ['manager','exec'] },
        ] },
      // M4 intentionally missing — the validation error on the Assessments
      // surface (and the matrix's coverage chip) calls this out.
    ],
  },
};

// The Assessments surface used to seed new question groups from a hardcoded
// ASSESSMENT_SEED bank keyed by module id (M1…M4). That was removed when the
// surface became real: a from-zero course numbers its own modules M1, M2, …
// too, so every new course silently inherited the demo course’s questions —
// as authored content, into a real package. New groups now start empty.

// ── Course-level settings slice ─────────────────────────────────────────────
// The single source of truth for the Course settings surface (Surface 7).
// Mirrors §3 of the Course_settings_design spec. Every control on
// surface-brand.jsx reads from + writes to this object via app-level state.
// Default values match what surface-brand.jsx rendered statically before the
// wiring pass. Localised fields are { [lang]: string } maps so a later
// localisation pass can fill secondary languages without a schema change.
const SAMPLE_COURSE_SETTINGS = {
  // The course title PER LANGUAGE — `<label_course_title>` in every UI.xml, and
  // the source the Localisation surface's Welcome tab translates from. Course
  // settings writes the default-language slot here and `metadata.title` below in
  // the same keystroke: this one ships inside the package, that one names the
  // ZIP and the manifest (`feedback_one_rule_one_place` — one editor, two
  // destinations, not two editors).
  courseTitle: { en: 'Difficult Interactions' },
  metadata: {
    // The `course` ROW's title. Localised in `courseTitle` above; this slot is
    // the row value and stays keyed `en` because the row has no language.
    title: { en: 'Difficult Interactions' },
    topic: 'Leadership · Conversational skills',
    // Validated against the course's enabled languages on the Localisation
    // surface. Until that slice is shared, the picker offers SAMPLE_COURSE.languages.
    defaultLanguage: 'en',
    scormVersion: '2004',        // '1.2' | '2004'
    // NOTE: "template" is a UX-level convenience only — it seeds defaults in
    // the authoring tool and is NOT emitted to config.xml / any production
    // schema. No XML emission downstream.
    template: 'corporate-default',
  },

  // One row per organisation. `logo` is the active variant; the other three
  // catalogue variants (§4.6) live under logoVariants behind a per-row
  // "More logo variants" disclosure. Exactly one org carries isPrimary.
  // ── `organisations` REMOVED 2026-08-12 (Phase 4c) ─────────────────────────
  // This was the Course-settings surface's own organisation slice — two sample
  // rows with `id` / `isPrimary` / `logoVariants`, a shape that shares NO field
  // with the real one (`courseSettings.brands[]`, keyed by `code`). Its editor
  // and the Build modal's disabled organisation picker both read it, and both
  // are gone: organisations shipped to production on 2026-08-12 and are edited
  // on Organisations & roles.
  // The seed is deleted rather than emptied so nothing can bind to it again by
  // accident — an empty array still type-checks and still reads as "the slice
  // exists" (`feedback_park_a_disabled_slice_dont_omit_it` does NOT apply here:
  // that rule protects a disabled feature's real CONTENT from being dropped,
  // and this content was never real — it never reached the package, and the
  // feature it described now has a live home with its own storage).

  // Pre-assessment is a course-level feature. learnerChoice + skipReturnees
  // only take effect when `pre` is on (enforced as disabled state in the UI).
  assessmentFlow: { pre: true, learnerChoice: true, skipReturnees: false },

  // The demo course's cover. `coverBgImgUrl` is deliberately EMPTY rather than
  // the old `placeholder:cover_difficult-interactions.jpg`: a `placeholder:`
  // token in a media field is precisely what the export gate rejects, so the
  // sample course would have shipped a 422 to anyone who switched the cover on
  // (`feedback_placeholder_token_in_raw_field`). An empty image is honest — the
  // Player renders the cover on its plain background until a real one is
  // uploaded. No `title`/`ctaLabel`: the screen uses the course title and the
  // built-in translated button.
  cover: {
    enabled: false,
    coverBgImgUrl: '',
    coverLogoImgUrl: '',
    sentence: { en: 'A short course on the conversations that matter most.' },
  },

  // The News screen — off, with no text. Same reasoning as the cover: a course
  // that never opens the panel must emit what it emitted before the slice
  // existed, which is `<have_newsScreen>0` and the empty stub news.xml.
  news: {
    enabled: false,
    title: { en: '' },
    message: { en: '' },
  },

  // The screen-level feature flags. Three of them now reach the package (see
  // FEATURE_FLAG_META's `wired`); the rest are inert defaults the UI never shows.
  //
  // ★ `haveNewToCompany` was `true` here until 2026-08-14, and had to become
  // false the moment the flag became real. While nothing read it, the value was
  // decoration; wired up, it would have put an unasked-for "are you new to the
  // company?" screen in front of the demo course and moved it to config template
  // 7. A sample value is only harmless while it is unreachable
  // (`feedback_a_dormant_error_branch_wakes_when_the_platform_changes`).
  featureFlags: {
    haveNewToCompany: false,
    haveNewsScreen: false,
    havePeopleManager: false,
    fancyAssessment: true,
    quizFrequentSave: true,
    scormSuspendDataCompression: true,
    sequentialModules: false,
    blockingByDefault: false,
    analyticsEvents: true,
    altTextRequired: true,
    transcriptDownload: true,
    accessibilityWidget: true,
  },

  // Gaming-quiz course-level slice. Two jobs:
  //   1. `enabled` gates whether `quiz_gaming` is offered as a layout type
  //      (see surface-layout-editor.jsx) — the master switch.
  //   2. The profile-setup form fields below are SHARED across every
  //      quiz_gaming layout in the course. The gamified quiz collects three
  //      hardcoded learner inputs (first name / last name / email) on a
  //      one-time setup screen; authors can relabel those inputs and edit the
  //      surrounding intro copy/imagery here, but cannot add/remove fields.
  //      Every quiz_gaming editor's "Profile form" tab binds to THIS slice, so
  //      an edit on one propagates to all.
  // (Author-facing copy never says "DFTI" — CLAUDE.md §23.2; the `dfti*` data
  // keys are historical/internal only and never shown to authors.)
  dftiFlow: {
    enabled: true,
    profileSetupDescription: { en: 'Please fill the fields below to set up your account and move on with your learning. These details will be used to enhance your experience. This is NOT a phishing attempt.' },
    profileSetupFirstName: { en: 'First name' },
    profileSetupLastName:  { en: 'Last name' },
    profileSetupEmail:     { en: 'Email address' },
    dftiInfoTitle:         { en: "Don't feed the 'ish" },
    dftiInfoLeftText:      { en: 'Helpful systems work for you.' },
    dftiInfoRightText:     { en: 'Attackers try to trick you into acting.' },
    // The two intro images start UNSET. They used to be seeded with
    // `dfti/images/robot_icon_big.png` / `…/hacker_icon_big.png`, which looked
    // like working defaults and could never render: nothing bundles a
    // `dfti/images/` folder — not the pinned Player runtime and not the
    // packager (verified against a real 181-entry QA export). So the seed was a
    // reference to a file that has never existed in any package we ship.
    // Empty is the honest value; the Player's own `dfti_leftImg ? … : ''`
    // branch simply renders no image, and the author's upload (Course
    // architecture → a gaming-quiz layout → Profile form → Intro) now reaches
    // the ZIP for real.
    dftiLeftImg:  '',
    dftiRightImg: '',
  },

  // Pre-/post-assessment slice — see surface-assessments.jsx for the shape and
  // for the list of controls the runtime cannot honour (deliberately absent).
  // Starts EMPTY for every course, demo included: this slice now exports, and
  // seeding it would put sample questions into a real package as authored
  // content. Authors add a group per module and write their own.
  assessments: makeEmptyAssessments(),

  // Role slice (Stage 5 Phase 2) — see wiki `Roles_and_content_mapping_design`.
  //
  // Starts EMPTY for every course, demo included, for exactly the reason the
  // assessments slice above does: this slice now EXPORTS, so seeding it would
  // put sample roles into a real package as authored content — and worse than
  // sample questions, two seeded roles flip `<selectRole>1</selectRole>` and the
  // course template, putting a role-selection screen in front of a learner the
  // author never asked for.
  //
  // This is also a FIX to what shipped before. `rolesStartBlank` defaults to
  // false (`app.jsx:11`), so until now EVERY course — a brand-new one included —
  // showed the three `SAMPLE_ROLES` on the Roles surface. Those roles were never
  // saved and never exported, so a fresh course displayed three personas it did
  // not have: the same false affordance `makeBlankCourse` exists to remove.
  //
  // SHAPE. `label` is a LocalizedString ({ en: 'Nurse' }), matching
  // `RoleSchema.label`. The Roles surface edits ONLY the default-language entry
  // — never the object — because binding a LocalizedString to a plain widget
  // crashes the screen and drops the other languages
  // (`feedback_localizedstring_bound_to_plain_widget`). Other languages are
  // written through Localisation › Roles, the door that already exists.
  // `modules` holds FE module ids ('M1'), converted to the schema's numeric ids
  // at projection; `brand` is authoring-only until brand selection is built.
  roles: [],
};

// ── Blank-course factories (from-zero authoring) ────────────────────────────
// A course opened from the real course list (any id except DEMO_COURSE_ID)
// gets an EMPTY base instead of SAMPLE_COURSE: no modules, no groups, no
// narrative content. `row` is the course row returned by the gateway
// (GET/POST /v1/courses). The factory fills every field the app chrome reads
// (title, validation, aiSpend, languages…) with safe empties so no surface
// crashes on a fresh course.
function makeBlankCourse(row) {
  return {
    id: row.id,
    contentMode: 'blank',
    title: row.title || 'Untitled course',
    topic: row.topic || '',
    defaultLanguage: row.defaultLang || 'en',
    languages: (row.enabledLangs && row.enabledLangs.length) ? row.enabledLangs : ['en'],
    scormVersion: '1.2',
    template: 'corporate-default',
    brand: row.brand || '',
    lastSaved: '—',
    aiSpend: { used: 0, cap: 25.00, currency: 'USD' },
    validation: { total: 0, items: [] },
    selectBrand: false,
    brands: [],
    moduleGroups: [],
    modules: [],
  };
}

// Course-settings slice for a blank course. Mirrors SAMPLE_COURSE_SETTINGS in
// shape (the Course-settings surface binds to every key) but with the
// feature's real defaults: no organisations, gaming flow OFF (auto-flips on
// when the first gaming quiz is added), blank cover. The gaming profile-form
// labels keep their generic defaults — they're functional UI copy the Player
// needs, not narrative sample content.
function makeBlankCourseSettings(row) {
  const sample = SAMPLE_COURSE_SETTINGS;
  return {
    metadata: {
      title: { en: row.title || 'Untitled course' },
      topic: row.topic || '',
      defaultLanguage: row.defaultLang || 'en',
      scormVersion: '1.2',
      template: 'corporate-default',
    },
    assessmentFlow: { pre: false, learnerChoice: false, skipReturnees: false },
    // A real course starts with the cover OFF, and that is not a placeholder
    // default — `<have_startingCover>0` is what every course has emitted until
    // now, so anything else would put a new opening screen in front of courses
    // that never asked for one. The author switches it on in Course settings.
    // No `title`/`ctaLabel` here: the cover renders the course title and the
    // built-in translated button (see the panel in surface-brand.jsx).
    cover: {
      enabled: false,
      coverBgImgUrl: '',
      coverLogoImgUrl: '',
      sentence: { en: '' },
    },
    // Off and empty, for the same reason the cover is.
    news: {
      enabled: false,
      title: { en: '' },
      message: { en: '' },
    },
    // Seeded from the course row so the title the author sees in Course settings
    // and the title the package ships start out as the same string. The panel
    // keeps them in step from there; `emit-ui-xml` falls back to the row title
    // for any language with no entry, so a course that never opens the panel is
    // unaffected either way.
    courseTitle: { [row.defaultLang || 'en']: row.title || '' },
    featureFlags: { ...sample.featureFlags },
    // Keep the functional defaults (the three profile-form field labels) but
    // blank the demo course's narrative copy — the coverage gate makes the
    // author write their own intro when they enable the gaming flow. The two
    // intro images are already empty in `sample`, so the spread inherits unset.
    dftiFlow: {
      ...sample.dftiFlow,
      enabled: false,
      profileSetupDescription: { en: '' },
      dftiInfoTitle: { en: '' },
      dftiInfoLeftText: { en: '' },
      dftiInfoRightText: { en: '' },
    },
    // Same empty slice as the demo course — assessments are never seeded.
    assessments: makeEmptyAssessments(),
    // Same reasoning again: roles export, so they are never seeded.
    roles: [],
  };
}

// Metadata for the 12 feature flags — the human label, the config.xml flag
// name surfaced in the tooltip, a one-line behaviour note, and whether the
// flag is "core" (shown by default) vs "advanced" (behind the disclosure).
//
// ★ `wired` — and NOT `core` — is what puts a flag on the Course settings
// screen (2026-08-14). `core` used to do both jobs, and it meant a flag was
// shown because someone considered it important rather than because switching it
// changes the exported package. The three below are wired: each has a chain that
// ends in a file inside the ZIP, pinned by a test. Everything under them keeps
// `core` for grouping and is not rendered anywhere.
//
// `tag` is the config.xml element the flag drives, shown under the label. It is
// NOT the same string as `key` for any of them, which is why it is a field
// rather than a template of the key.
const FEATURE_FLAG_META = [
  { key: 'sequentialModules',  core: true, wired: true, tag: 'sequential_modules',
    label: 'Modules must complete in order',
    desc: 'Locks each module until the previous one is complete.' },
  // No `tag`: there is no course-level element for this. The gate is per screen
  // (`<blockingSection>`), written by `applyBlockingDefault` at export.
  { key: 'blockingByDefault',  core: true, wired: true,
    label: 'Block progression by default',
    desc: 'Every screen gates the next one until the learner has finished it. '
      + 'Screens with nothing to complete (a title, two columns of text) open '
      + 'their own gate as soon as they are drawn, so only videos, tabs and '
      + 'quizzes actually hold the learner up.' },
  // "New to company" left this grid on 2026-08-14, for the same reason the News
  // screen did an hour earlier and on Omar's instruction: the switch is only half
  // the feature. The screen it shows carries four blocks of wording the author
  // can override, and those boxes live in the "New to company" panel in Course
  // settings — so the switch went to sit beside them. Kept here, unwired, so the
  // next reader finds where it went rather than finding it missing.
  { key: 'haveNewToCompany',   core: true, tag: 'have_newToCompany',
    label: 'Show "new to company" intro',
    desc: 'Moved to the "New to company" panel in Course settings, which also '
      + 'carries the four blocks of wording that screen shows.' },
  // The News screen left this list on 2026-08-14. It is not a flag an author can
  // simply switch — the Player needs a per-language news.xml with real text
  // before it will show the screen — so it became its own panel in Course
  // settings, backed by `courseSettings.news`. Kept here, unwired, so the next
  // reader can see where it went rather than finding it missing.
  { key: 'haveNewsScreen',     core: true, tag: 'have_newsScreen',
    label: 'Enable News screen',
    desc: 'Moved to the News screen panel in Course settings, which also carries '
      + 'the headline and message the screen needs.' },

  // Always-on / hidden from the author UI. `accessibilityWidget` is a
  // compliance must-have — kept permanently true and never surfaced as a
  // toggle (filtered out via `alwaysOn`).
  { key: 'accessibilityWidget', core: true, alwaysOn: true,
    label: 'Enable accessibility widget',
    desc: 'Shows the in-player accessibility controls (text size, contrast, motion).' },
  { key: 'havePeopleManager',  core: false,
    label: 'People Manager directory',
    desc: 'Shows a directory of people-manager contacts inside the course.' },
  { key: 'fancyAssessment',    core: false,
    label: 'Fancy assessment animations',
    desc: 'Enables the richer transition animations on assessment screens.' },
  { key: 'quizFrequentSave',   core: false,
    label: 'Save after each question',
    desc: 'Commits SCORM progress after every answered question, not just at quiz end.' },
  { key: 'scormSuspendDataCompression', core: false,
    label: 'Compress SCORM suspend data',
    desc: 'Compresses suspend_data to stay under LMS size limits.' },
  { key: 'analyticsEvents',    core: false,
    label: 'Emit xAPI events',
    desc: 'Sends xAPI statements for fine-grained analytics alongside SCORM.' },
  { key: 'altTextRequired',    core: false,
    label: 'Require alt text on images',
    desc: 'Blocks export until every image has alt text — accessibility gate.' },
  { key: 'transcriptDownload', core: false,
    label: 'Allow transcript download',
    desc: 'Lets learners download a transcript of every video in the course.' },
];

// ── HTML media samples (catalogue) ──────────────────────────────────────────
// Registry of the embedded HTML-document samples an author can attach to a
// quiz_images / hidden_items mini-quiz question (nested content.html, mirroring
// content.image / content.video).
//
// These are ORIGINAL, brand-free awareness samples authored for the prototype —
// no real organisation, product, or logo is reproduced. They live as real,
// self-contained HTML files under uploads/html-samples/ (each keeps the
// Player's required <div class="quiz"> wrapper and loads a shared stylesheet
// via relative ../ paths). The UI lists them in the MediaSlot "Pick from
// samples" picker; selecting one writes its htmlUrl onto the field.
//
// htmlUrl is pre-prefixed in the data (project-relative) — the field stores it
// verbatim, matching the per-field path-prefix convention.
const HTML_SAMPLES = [
  { id: 'module_4_03', label: 'Phishy email — shipping invoice',
    htmlUrl: 'uploads/html-samples/content/en/modules/module_4/03.html',
    htmlAlt: 'A suspicious email from a "Freight Express" billing address asking you to download an invoice attachment and reply with your full name, address and phone number to release a parcel.',
    scenarioCategory: 'phishing-email', complexity: 'med',
    tokens: ['user-address'], tooltipCount: 5,
    notes: 'Most typical phishy email — light token use, moderate cues. Default sample.' },
  { id: 'module_4_05', label: 'Phishy email — password reset',
    htmlUrl: 'uploads/html-samples/content/en/modules/module_4/05.html',
    htmlAlt: 'A fake "account security" email warning of an unusual sign-in and pushing a time-limited password-reset link.',
    scenarioCategory: 'phishing-email', complexity: 'med',
    tokens: [], tooltipCount: 5,
    notes: 'Credential-reset lure built on alarm + a 30-minute deadline.' },
  { id: 'module_4_09', label: 'Phishy email — payroll update',
    htmlUrl: 'uploads/html-samples/content/en/modules/module_4/09.html',
    htmlAlt: 'An email posing as HR/payroll asking you to re-enter your bank account and sort code on a new portal before a same-day cut-off.',
    scenarioCategory: 'phishing-email', complexity: 'high',
    tokens: [], tooltipCount: 4,
    notes: 'Business-email-compromise style — financial detail harvest framed as a system migration.' },
  { id: 'module_4_01', label: 'Phishy email — gift card reward',
    htmlUrl: 'uploads/html-samples/content/en/modules/module_4/01.html',
    htmlAlt: 'A prize email claiming you won a £100 gift card and asking for a small delivery fee paid by card.',
    scenarioCategory: 'phishing-email', complexity: 'low',
    tokens: [], tooltipCount: 5,
    notes: 'Too-good-to-be-true reward with a small upfront fee and a 2-hour countdown.' },
  { id: 'module_3_05A_01', label: 'Social post — oversharing',
    htmlUrl: 'uploads/html-samples/content/en/modules/module_3/05A/01.html',
    htmlAlt: 'A public social media post announcing a two-week holiday, the home address, and where a spare key is hidden.',
    scenarioCategory: 'social-media', complexity: 'low',
    tokens: [], tooltipCount: 3,
    notes: 'Physical-security oversharing rather than a link lure.' },
  { id: 'module_3_05A_04', label: 'Chat — urgent gift-card request',
    htmlUrl: 'uploads/html-samples/content/en/modules/module_3/05A/04.html',
    htmlAlt: 'A chat thread where someone impersonating a director secretly asks the recipient to buy gift cards and send the codes within 20 minutes.',
    scenarioCategory: 'messaging', complexity: 'med',
    tokens: [], tooltipCount: 4,
    notes: 'Authority + urgency + secrecy pretext over an unverified channel.' },
  { id: 'module_2_02A_01', label: 'Fake login screen',
    htmlUrl: 'uploads/html-samples/content/en/modules/module_2/02A/01.html',
    htmlAlt: 'A spoofed sign-in page on an unfamiliar domain asking you to re-enter and confirm your email and password.',
    scenarioCategory: 'credential-harvest', complexity: 'high',
    tokens: [], tooltipCount: 2,
    notes: 'The tell is the address bar — a credential-harvesting page rather than an email.' },
  { id: 'module_2_02A_06', label: 'Smishing text — missed delivery',
    htmlUrl: 'uploads/html-samples/content/en/modules/module_2/02A/06.html',
    htmlAlt: 'A text message claiming a parcel could not be delivered and demanding a small redelivery fee via a shortened link before the parcel is returned today.',
    scenarioCategory: 'smishing', complexity: 'low',
    tokens: [], tooltipCount: 4,
    notes: 'SMS / smishing variant — fee + unfamiliar link + same-day threat.' },
];

Object.assign(window, {
  LAYOUT_TYPES, canonicalLayoutType, displayLayoutType, SAMPLE_COURSE, SAMPLE_COURSE_SETTINGS, FEATURE_FLAG_META,
  DEMO_COURSE_ID, makeBlankCourse, makeBlankCourseSettings,
  SAMPLE_SOURCES, SAMPLE_SECTION_CONTEXT, SAMPLE_SEQUENCE_TABS, SAMPLE_ROLES,
  SAMPLE_ASSESSMENTS, makeEmptyAssessments,
  SAMPLE_ORGS, SAMPLE_COURSES_LIST,
  LANG_FLAGS, LANG_NAMES,
  HTML_SAMPLES,
});

// reorder so SAMPLE_ROLES exists before being referenced above
;
