// Surface 2 — Draft (Modules + Layouts overview).
// Two columns: Modules · Layouts.
// (The right-hand AI suggestions panel was removed; suggestions live in the Layout editor.)

function SurfaceDraft({ course, selectedModuleId, selectedLayoutId, onSelectModule, onSelectLayout, onOpenLayoutEditor, onReorderModules, onReorderLayouts, onRenameGroup, onAddModule, onEditModule, onAddLayout, onDeleteLayout, onDeleteModule, onDeleteGroup, onPreviewModule, sources }) {
  const [showAddModule, setShowAddModule] = React.useState(false);
  // A from-zero course starts with NO modules — render a dedicated empty
  // state instead of the layouts column (whose add/rename affordances would
  // write under module id null and silently vanish).
  const noModules = course.modules.length === 0;
  const module = course.modules.find((m) => m.id === selectedModuleId) || course.modules[0] ||
  { id: null, title: { en: '' }, layouts: [], group: null, n: 0 };
  const layout = module.layouts.find((l) => l.id === selectedLayoutId) ||
  module.layouts.find((l) => l && l.selected) ||
  module.layouts[0] ||
  { id: '—' };
  const activeGroup = (course.moduleGroups || []).find((g) => g.id === module.group);

  return (
    <div style={{
      display: 'flex',
      flexDirection: 'column',
      height: '100%',
      background: 'var(--bg)',
      overflow: 'hidden',
      minHeight: 0
    }} data-screen-label="Surface 2 · Course architecture">

      <RequestedActions modules={course.modules} moduleGroups={course.moduleGroups} />

      <div style={{
        display: 'grid',
        gridTemplateColumns: '252px minmax(0, 1fr)',
        gridTemplateRows: 'minmax(0, 1fr)',
        gap: 0,
        flex: 1, minHeight: 0,
        overflow: 'hidden'
      }}>
        {/* ── Left column · Modules ───────────────────────────────── */}
        <ModulesColumn modules={course.modules}
        moduleGroups={course.moduleGroups}
        selectedId={module.id}
        onSelect={onSelectModule}
        onReorder={onReorderModules}
        onDeleteModule={onDeleteModule}
        onDeleteGroup={onDeleteGroup}
        onAddModuleClick={() => setShowAddModule(true)} />

        {/* ── Middle column · Layouts in selected module ──────────── */}
        {noModules ? (
          <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center',
            justifyContent: 'center', gap: 12, background: 'var(--bg)' }}>
            <I.Layers size={28} style={{ color: 'var(--text-faint)' }} />
            <div style={{ fontSize: 14.5, fontWeight: 600, color: 'var(--text)' }}>
              Start your course
            </div>
            <p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-muted)',
              maxWidth: 380, textAlign: 'center', lineHeight: 1.5 }}>
              This course is empty. Add your first module, then add layouts to it —
              each layout is one screen of the course.
            </p>
            <button className="btn sm primary" onClick={() => setShowAddModule(true)}>
              <I.Plus size={12} />Add your first module
            </button>
          </div>
        ) : (
        <LayoutsColumn module={module}
        modules={course.modules}
        group={activeGroup}
        selectedId={layout.id}
        onSelect={onSelectLayout}
        onOpen={onOpenLayoutEditor}
        onReorder={onReorderLayouts}
        onRenameGroup={onRenameGroup}
        onEditModule={onEditModule}
        onAddLayout={onAddLayout}
        onDeleteLayout={onDeleteLayout}
        onPreviewModule={onPreviewModule} />
        )}

      </div>

      {showAddModule &&
      <AddModuleDialog
        course={course}
        sources={sources}
        onCancel={() => setShowAddModule(false)}
        onCreate={(payload) => {
          onAddModule && onAddModule(payload);
          setShowAddModule(false);
        }} />
      }
    </div>);

}

// ── Modules column ──────────────────────────────────────────────────────────
function ModulesColumn({ modules, moduleGroups, selectedId, onSelect, onReorder, onDeleteModule, onDeleteGroup, onAddModuleClick }) {
  const [dragId, setDragId] = React.useState(null);
  const [overId, setOverId] = React.useState(null);

  const handleDrop = (targetId) => {
    if (!dragId || dragId === targetId) {setDragId(null);setOverId(null);return;}
    const from = modules.findIndex((m) => m.id === dragId);
    const to = modules.findIndex((m) => m.id === targetId);
    if (from < 0 || to < 0) return;
    const target = modules[to];
    const next = modules.slice();
    const [moved] = next.splice(from, 1);
    // Dropped module inherits the target's chapter. Build a NEW object rather
    // than mutating in place, so React sees the change and the group actually
    // travels to onReorder (which persists it).
    next.splice(to, 0, { ...moved, group: target.group ?? null });
    onReorder && onReorder(next);
    setDragId(null);setOverId(null);
  };

  // Drop a module ONTO A CHAPTER (its header or its body) to move it into that
  // chapter, appended after its current modules. Without this the only drop
  // targets were other module cards, so a module could not be filed into an
  // empty or collapsed chapter at all — the case Omar hit with a module created
  // outside any chapter (fixed 2026-07-26).
  const handleDropOnGroup = (groupId) => {
    if (!dragId) {setDragId(null);setOverId(null);return;}
    const from = modules.findIndex((m) => m.id === dragId);
    if (from < 0) {setDragId(null);setOverId(null);return;}
    const target = groupId === '_' ? null : groupId;
    const next = modules.slice();
    const [moved] = next.splice(from, 1);
    // Insert after the last module already in that chapter, so the drop lands
    // where the chapter visually ends rather than jumping to the top.
    let insertAt = next.length;
    for (let i = next.length - 1; i >= 0; i--) {
      if ((next[i].group ?? null) === target) { insertAt = i + 1; break; }
    }
    next.splice(insertAt, 0, { ...moved, group: target });
    onReorder && onReorder(next);
    setDragId(null);setOverId(null);
  };

  // Build group buckets in the order groups are declared. Modules without a
  // group are collected into an ‘(Ungrouped)’ bucket at the bottom — only
  // rendered if non-empty, so single-track courses look unchanged.
  const groups = (moduleGroups || []).map((g) => ({
    ...g, modules: modules.filter((m) => m.group === g.id)
  }));
  const ungrouped = modules.filter((m) => !m.group);
  // Render the "(Ungrouped)" bucket when it has members OR while a module is
  // being dragged: it is the only drop target that clears a module's chapter, so
  // hiding it made filing a module into a chapter a ONE-WAY trip with no way
  // back out (fixed 2026-07-27).
  if (ungrouped.length || dragId) {
    groups.push({ id: '_', title: '(Ungrouped)', modules: ungrouped });
  }

  return (
    <section style={{
      borderRight: '1px solid var(--border)',
      background: 'var(--surface)',
      display: 'flex', flexDirection: 'column',
      minHeight: 0, height: '100%', overflow: 'hidden'
    }} aria-label="Modules">
      <ColumnHeader title="Modules" />
      <div style={{ flex: 1, overflowY: 'auto', padding: '8px 8px 12px' }}>
        {groups.map((g, gi) =>
        <ModuleGroupBlock key={g.id}
        group={g}
        isFirst={gi === 0}
        selectedId={selectedId}
        dragId={dragId}
        overId={overId}
        onSelect={onSelect}
        setDragId={setDragId}
        setOverId={setOverId}
        handleDrop={handleDrop}
        handleDropOnGroup={handleDropOnGroup}
        onDeleteModule={onDeleteModule}
        onDeleteGroup={onDeleteGroup} />
        )}
        <button onClick={onAddModuleClick}
        className="btn sm ghost"
        style={{ width: '100%', marginTop: 12, height: 36,
          justifyContent: 'center', borderStyle: 'dashed',
          border: '1px dashed var(--border-strong)' }}
        onMouseOver={(e) => {
          e.currentTarget.style.borderColor = 'var(--accent)';
          e.currentTarget.style.color = 'var(--accent-text)';
          e.currentTarget.style.background = 'var(--accent-bg)';
        }}
        onMouseOut={(e) => {
          e.currentTarget.style.borderColor = 'var(--border-strong)';
          e.currentTarget.style.color = '';
          e.currentTarget.style.background = '';
        }}>
          <I.Plus size={13} />Add module
        </button>
      </div>
    </section>);

}

function ModuleGroupBlock({ group, isFirst, selectedId, dragId, overId, onSelect,
  setDragId, setOverId, handleDrop, handleDropOnGroup, onDeleteModule, onDeleteGroup }) {
  const [collapsed, setCollapsed] = React.useState(false);
  const [confirmDel, setConfirmDel] = React.useState(false);
  const showGroupChrome = group.id !== '_';
  // True while a module is being dragged over this chapter (but not over one of
  // its own module cards, which handle their own drop for precise ordering).
  const [groupDragOver, setGroupDragOver] = React.useState(false);
  const draggedFromElsewhere = !!dragId && !group.modules.some((m) => m.id === dragId);
  // Accepting the drop anywhere on the chapter — header, padding, or empty body.
  const groupDropProps = {
    onDragOver: (e) => {
      // Only advertise a drop when one would actually happen — i.e. the module
      // comes from ANOTHER chapter. Dragging within this chapter is the module
      // cards' business (precise ordering); claiming it here moved the module to
      // the end of the chapter with no feedback at all.
      if (!dragId || !draggedFromElsewhere) return;
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      setGroupDragOver(true);
    },
    onDragLeave: (e) => {
      // dragleave also fires when the pointer crosses a CHILD element, which
      // made the highlight flicker. Ignore those: only clear when the pointer
      // truly leaves this chapter's box.
      if (e && e.currentTarget && e.relatedTarget &&
          e.currentTarget.contains(e.relatedTarget)) return;
      setGroupDragOver(false);
    },
    onDrop: (e) => {
      if (!dragId || !draggedFromElsewhere) return;
      e.preventDefault();
      e.stopPropagation();
      setGroupDragOver(false);
      handleDropOnGroup && handleDropOnGroup(group.id);
    },
  };
  const highlight = groupDragOver && draggedFromElsewhere;
  const KebabMenu = window.KebabMenu;
  const ConfirmDialog = window.ConfirmDialog;
  return (
    <div {...groupDropProps} style={{
      marginTop: isFirst ? 4 : 16,
      marginBottom: 4,
      ...(showGroupChrome && {
        background: 'var(--surface-inset)',
        borderRadius: 'var(--radius-md)',
        padding: '8px 6px 6px',
        border: '1px solid var(--border)'
      }),
      // Make the chapter read as a valid destination mid-drag.
      ...(highlight && {
        outline: '2px solid var(--accent)',
        outlineOffset: showGroupChrome ? 0 : 2,
        borderRadius: 'var(--radius-md)',
        background: 'var(--accent-bg)'
      })
    }}>
      {/* Group header */}
      {showGroupChrome &&
      <div
        onClick={() => setCollapsed((c) => !c)}
        style={{
          display: 'flex', alignItems: 'center', gap: 6,
          padding: '4px 8px',
          marginBottom: collapsed ? 0 : 6,
          cursor: 'default',
          borderRadius: 4,
          userSelect: 'none'
        }}>
          <I.ChevronDown size={11}
        style={{ color: 'var(--text-muted)',
          transform: collapsed ? 'rotate(-90deg)' : 'none',
          transition: 'transform 120ms' }} />
          <I.Folder size={12} style={{ color: 'var(--text-muted)' }} />
          <span style={{
          fontSize: 11.5, fontWeight: 700,
          color: 'var(--text)',
          letterSpacing: '.01em',
          flex: 1, minWidth: 0,
          overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'
        }} title={locText(group.title)}>{locText(group.title)}</span>
          <span style={{ fontSize: 10,
          fontFamily: 'var(--font-mono)',
          background: 'var(--surface)',
          padding: '1px 5px', borderRadius: 3, color: "rgb(99, 117, 141)" }}>{group.modules.length}</span>
          <span onClick={(e) => e.stopPropagation()} style={{ display: 'inline-flex' }}>
            <KebabMenu items={[
              { label: 'Delete chapter', icon: 'Trash', danger: true,
                hint: 'Removes the chapter and all its modules',
                onSelect: () => setConfirmDel(true) },
            ]} />
          </span>
        </div>
      }
      {!collapsed &&
      <div style={{ padding: showGroupChrome ? '0 2px' : 0 }}>
          {/* An empty chapter would otherwise be a sliver with nothing to aim
              at, so give the drag somewhere obvious to land. */}
          {group.modules.length === 0 &&
          <div style={{
            height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontSize: 11, color: 'var(--text-faint)',
            border: '1px dashed var(--border-strong)', borderRadius: 'var(--radius)',
            ...(highlight && { borderColor: 'var(--accent)', color: 'var(--accent-text)' })
          }}>
            {draggedFromElsewhere ? 'Drop to move into this chapter' : 'No modules yet'}
          </div>
          }
          {group.modules.map((m, i) =>
        <React.Fragment key={m.id}>
              {i > 0 &&
          <div style={{ height: 1,
            background: showGroupChrome ? 'var(--border-faint)' : 'var(--border)',
            margin: '6px 4px' }} />
          }
              <ModuleCard module={m}
          inGroup={showGroupChrome}
          selected={m.id === selectedId}
          dragging={dragId === m.id}
          dragOver={overId === m.id && dragId !== m.id}
          onClick={() => onSelect(m.id)}
          onDelete={onDeleteModule}
          onDragStart={() => setDragId(m.id)}
          onDragOver={(e) => {e.preventDefault();setOverId(m.id);}}
          onDragLeave={() => {if (overId === m.id) setOverId(null);}}
          onDrop={(e) => {
            // A card drop is the PRECISE one (drop before/after this module), so
            // it must not also bubble to the chapter-level drop, which would
            // re-file the module at the end of the chapter and undo the order.
            if (e) { e.preventDefault(); e.stopPropagation(); }
            setGroupDragOver(false);
            handleDrop(m.id);
          }}
          onDragEnd={() => {setDragId(null);setOverId(null);}} />
            </React.Fragment>
        )}
        </div>
      }
      {confirmDel && ConfirmDialog &&
        <ConfirmDialog icon="Trash" tone="danger"
          title={`Delete “${locText(group.title)}”?`}
          body={`This removes the chapter and its ${group.modules.length} module${group.modules.length === 1 ? '' : 's'} (and every layout inside them). The source paragraphs they covered will be marked unmapped in Validation.`}
          confirmLabel="Delete chapter"
          onCancel={() => setConfirmDel(false)}
          onConfirm={() => { setConfirmDel(false); onDeleteGroup && onDeleteGroup(group.id); }} />
      }
    </div>);

}

// ── Editable group breadcrumb shown above the module title ────────────────────────
function GroupBreadcrumb({ group, onRename }) {
  const [editing, setEditing] = React.useState(false);
  const src = locText(group.title);
  const [value, setValue] = React.useState(src);
  React.useEffect(() => {setValue(src);}, [src]);

  const commit = () => {
    setEditing(false);
    const v = value.trim();
    if (v && v !== src) onRename && onRename(group.id, v);else
    setValue(src);
  };

  if (editing) {
    return (
      <input autoFocus value={value} onChange={(e) => setValue(e.target.value)}
      onBlur={commit}
      onKeyDown={(e) => {
        if (e.key === 'Enter') commit();
        if (e.key === 'Escape') {setValue(src);setEditing(false);}
      }}
      className="field"
      style={{ height: 22, fontSize: 11, padding: '0 6px',
        fontWeight: 600, letterSpacing: '.02em' }} />);

  }
  return (
    <button onClick={() => setEditing(true)}
    title="Click to rename group"
    style={{
      display: 'inline-flex', alignItems: 'center', gap: 5,
      padding: '2px 6px', margin: '-2px -6px 0',
      background: 'transparent', border: 0,
      borderRadius: 4,
      color: 'var(--text-muted)',
      fontFamily: 'inherit', cursor: 'default',
      alignSelf: 'flex-start'
    }}
    onMouseOver={(e) => e.currentTarget.style.background = 'var(--surface-inset)'}
    onMouseOut={(e) => e.currentTarget.style.background = 'transparent'}>
      <I.Folder size={11} style={{ color: 'var(--text-faint)' }} />
      <span style={{ fontSize: 11, fontWeight: 600,
        letterSpacing: '.04em', textTransform: 'uppercase',
        color: 'var(--text-muted)' }}>
        {locText(group.title)}
      </span>
      <I.Edit size={9} style={{ color: 'var(--text-faint)', opacity: 0.6 }} />
    </button>);

}

function ModuleCard({ module: m, inGroup, selected, dragging, dragOver,
  onClick, onDelete, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd }) {
  const layoutStatuses = m.layouts.map((l) => l.status);
  const hasIssues = layoutStatuses.includes('issues');
  const hasPending = layoutStatuses.includes('pending');
  const validationLevel = hasIssues ? 'issues' : hasPending ? 'pending' : 'accepted';
  const [gripHover, setGripHover] = React.useState(false);
  const [confirmDel, setConfirmDel] = React.useState(false);
  const KebabMenu = window.KebabMenu;
  const ConfirmDialog = window.ConfirmDialog;
  return (
    <div
      draggable
      onClick={onClick}
      onDragStart={(e) => {
        e.dataTransfer.effectAllowed = 'move';
        try {e.dataTransfer.setData('text/plain', m.id);} catch (_) {}
        onDragStart && onDragStart();
      }}
      onDragOver={onDragOver}
      onDragLeave={onDragLeave}
      onDrop={onDrop}
      onDragEnd={onDragEnd}
      style={{
        display: 'block', width: '100%', textAlign: 'left',
        padding: '10px 11px',
        margin: '0 0 4px',
        borderRadius: 'var(--radius-md)',

        borderColor: dragOver ? 'var(--accent)' :
        selected ? 'var(--accent-border)' : 'transparent',
        background: selected ? 'var(--accent-bg)' : inGroup ? 'var(--surface)' : 'transparent',
        boxShadow: dragOver ? '0 -2px 0 0 var(--accent)' : 'none',
        cursor: gripHover ? 'grab' : 'default',
        opacity: dragging ? 0.4 : 1,
        fontFamily: 'inherit', color: 'var(--text)',
        position: 'relative',
        userSelect: 'none',
        transition: 'border-color 80ms, box-shadow 80ms, opacity 80ms', border: "1px solid rgb(99, 117, 141)"
      }}
      onMouseOver={(e) => {if (!selected && !dragOver) e.currentTarget.style.background = 'var(--surface-inset)';}}
      onMouseOut={(e) => {if (!selected) e.currentTarget.style.background = inGroup ? 'var(--surface)' : 'transparent';}}>
      {selected &&
      <span style={{
        position: 'absolute', left: -1, top: 8, bottom: 8, width: 3,
        background: 'var(--accent)', borderRadius: '0 2px 2px 0'
      }} />
      }
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
        <span
          onMouseEnter={() => setGripHover(true)}
          onMouseLeave={() => setGripHover(false)}
          title="Drag to reorder"
          style={{ marginLeft: -4, display: 'inline-flex', cursor: 'grab',
            color: 'var(--text-faint)', opacity: selected || gripHover ? 1 : 0.55 }}>
          <I.GripVertical size={13} />
        </span>
        <span style={{
          fontFamily: 'var(--font-mono)', fontSize: 10.5, fontWeight: 600,
          padding: '1px 5px', borderRadius: 3,
          background: selected ? 'var(--surface)' : 'var(--surface-inset)',
          color: 'var(--text-muted)', letterSpacing: '.04em'
        }}>M{m.n}</span>
        <StatusPill iconOnly status={validationLevel === 'accepted' ? 'accepted' : validationLevel === 'issues' ? 'issues' : 'pending'} />
        <div style={{ flex: 1 }} />
        <span onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
          draggable={false} onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
          style={{ display: 'inline-flex' }}>
          <KebabMenu items={[
            { label: 'Delete module', icon: 'Trash', danger: true,
              hint: 'Removes this module and all its layouts',
              onSelect: () => setConfirmDel(true) },
          ]} />
        </span>
      </div>
      <div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text)', marginBottom: 2,
        lineHeight: 1.3 }}>{locText(m.title)}</div>
      <div style={{ fontSize: 11.5, color: 'var(--text-muted)', display: 'flex', gap: 10, alignItems: 'center' }}>
        <span>{m.layouts.length} layouts</span>
        <span>·</span>
        <span style={{ fontFamily: 'var(--font-mono)' }}>{m.duration}</span>
      </div>
      {confirmDel && ConfirmDialog &&
        <ConfirmDialog icon="Trash" tone="danger"
          title={`Delete ${m.id} — “${locText(m.title)}”?`}
          body={`This removes the module and its ${m.layouts.length} layout${m.layouts.length === 1 ? '' : 's'}. The source paragraphs it covered will be marked unmapped in Validation.`}
          confirmLabel="Delete module"
          onCancel={() => setConfirmDel(false)}
          onConfirm={() => { setConfirmDel(false); onDelete && onDelete(m.id); }} />
      }
    </div>);

}

// ── Layouts column ──────────────────────────────────────────────────────────
function LayoutsColumn({ module, modules, group, selectedId, onSelect, onOpen, onReorder, onRenameGroup, onEditModule, onAddLayout, onDeleteLayout, onPreviewModule }) {
  const [dragId, setDragId] = React.useState(null);
  const [overId, setOverId] = React.useState(null);

  const handleDrop = (targetId) => {
    if (!dragId || dragId === targetId) {setDragId(null);setOverId(null);return;}
    const from = module.layouts.findIndex((l) => l.id === dragId);
    const to = module.layouts.findIndex((l) => l.id === targetId);
    if (from < 0 || to < 0) return;
    const next = module.layouts.slice();
    const [moved] = next.splice(from, 1);
    next.splice(to, 0, moved);
    onReorder && onReorder(module.id, next);
    setDragId(null);setOverId(null);
  };

  return (
    <section style={{
      background: 'var(--bg)',
      display: 'flex', flexDirection: 'column',
      minHeight: 0, height: '100%', overflow: 'hidden'
    }} aria-label="Layouts in selected module">
      <div style={{ flex: 1, overflowY: 'auto',
        padding: '16px 16px 16px',
        display: 'flex', flexDirection: 'column', gap: 12 }}>

        {/* Module info card — same visual treatment as a layout row, so
            it reads as a distinct element sitting inside the column. */}
        <div style={{
          background: 'var(--surface)',
          border: '1px solid var(--border)',
          borderRadius: 'var(--radius-md)',
          padding: '12px 14px',
          display: 'flex', gap: 12, alignItems: 'stretch',
        }}>
          {/* Module thumbnail — emitted as <thumbnailURL> in the package. */}
          <ImageUploadSlot value={module.thumbnailUrl}
            onChange={(url) => onEditModule && onEditModule(module.id, { thumbnailUrl: url })} />
          <div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
            {group && (
              <GroupBreadcrumb group={group} onRename={onRenameGroup} />
            )}
            <div style={{ display: 'flex', alignItems: 'center', gap: 8,
              minWidth: 0 }}>
              <span style={{
                fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
                padding: '1px 5px', borderRadius: 3,
                background: 'var(--surface-inset)',
                color: 'var(--text-muted)', letterSpacing: '.04em',
                flexShrink: 0,
              }}>{module.id}</span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <ModuleInlineEdit
                  value={module.title}
                  placeholder="Module title…"
                  onChange={(v) => onEditModule && onEditModule(module.id, { title: locSet(module.title, 'en', v) })}
                  fontSize={14}
                  fontWeight={600}
                  allowEmpty={false}
                  title="Click to rename module" />
              </div>
              {/* ── Module preview ────────────────────────────────────────────────
                  Omar, 2026-08-13: *"add a button 'Module preview' … I'm not sure where
                  to position the button as I don't want to add a new row and I want to
                  avoid to confuse it with the Course preview button."*

                  ★ IT LIVES HERE BECAUSE THIS CARD *IS* THE MODULE. The card carries the
                  chapter breadcrumb, the `M2` badge, the module's title, summary and
                  thumbnail — so a button inside it is read as acting on this module
                  before its label is even read. Scope comes from containment far more
                  reliably than from wording. Course preview stays in the app's top
                  chrome, which owns the org, the course title and the course's AI spend.

                  Four things keep the two apart, none of which is the label alone:
                    · CONTAINER — inside the module's own card vs. the app header band.
                    · WEIGHT — this is a ghost button, deliberately the QUIETEST thing in
                      the row. Course preview is the only filled one in the app.
                    · HUE — `#A53E53` as TEXT here, as a FILL up there: same family,
                      unmistakably different rank.
                    · RANK — it sits LEFT of "+ Add layout", which stays rightmost because
                      it is the module's primary action and already has that muscle
                      memory. The divider marks them as separate concerns rather than a
                      pair (`feedback_no_false_affordance_toggles`).

                  No new row: it joins the row the title and "+ Add layout" already share.

                  The label is the full "Module preview" rather than a bare eye icon. An
                  icon-only twin of a button that also exists in the header is exactly how
                  two scopes get confused, and this is the one that needs saying out loud. */}
              <button className="btn sm ghost" style={{ flexShrink: 0, color: '#A53E53' }}
                /* The SCHEMA's numeric id, through the one shared rule — the server scopes
                   by `ModuleSchema.id`, and this module object carries a string id ('M2')
                   for React alongside the number. Read off `window` because this file
                   loads before `surface-export.jsx`; by click time both are there. */
                onClick={() => onPreviewModule
                  && onPreviewModule(window.schemaModuleId(module), module)}
                title={`Preview only this module's layouts — no assessment, roles or intro screens`}>
                <I.Eye size={12} />Module preview
              </button>
              <div style={{ width: 1, height: 20, background: 'var(--border)', flexShrink: 0 }} />
              <AddLayoutPicker variant="header"
                onPick={(type) => onAddLayout && onAddLayout(module.id, type)} />
            </div>
            <ModuleInlineEdit
              value={module.summary || ''}
              placeholder="Add a one-line summary…"
              onChange={(v) => onEditModule && onEditModule(module.id, { summary: locSet(module.summary, 'en', v) })}
              fontSize={12.5}
              fontWeight={400}
              color="var(--text-muted)"
              allowEmpty={true}
              multiline
              title="Click to edit summary" />
          </div>
        </div>

        {/* Layout rows */}
        <div style={{ display: 'grid', gap: 4 }}>
          {module.layouts.map((l, i) =>
          <LayoutRow key={l.id} layout={l}
          module={module}
          modules={modules}
          selected={l.id === selectedId}
          dragging={dragId === l.id}
          dragOver={overId === l.id && dragId !== l.id}
          onClick={() => onOpen(l.id)}
          onDoubleClick={() => onOpen(l.id)}
          onDelete={onDeleteLayout}
          onDragStart={() => setDragId(l.id)}
          onDragOver={(e) => {e.preventDefault();setOverId(l.id);}}
          onDragLeave={() => {if (overId === l.id) setOverId(null);}}
          onDrop={() => handleDrop(l.id)}
          onDragEnd={() => {setDragId(null);setOverId(null);}} />
          )}
          <AddLayoutPicker variant="full"
            onPick={(type) => onAddLayout && onAddLayout(module.id, type)} />
        </div>
      </div>
    </section>);

}

function LayoutRow({ layout, module, modules, selected, dragging, dragOver,
  onClick, onDoubleClick, onDelete,
  onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd }) {
  const meta = (window.LAYOUT_TYPES || []).find((t) => t.id === layout.type) || {};
  const Icon = I[meta.icon] || I.Hash;
  const friendly = (layout.type || '').replace(/_/g, ' ');
  const [gripHover, setGripHover] = React.useState(false);
  const [confirmDel, setConfirmDel] = React.useState(false);
  const [duplicating, setDuplicating] = React.useState(false);
  const KebabMenu = window.KebabMenu;
  const ConfirmDialog = window.ConfirmDialog;
  const DuplicateLayoutDialog = window.DuplicateLayoutDialog;
  return (
    <div onClick={onClick} onDoubleClick={onDoubleClick}
    draggable
    onDragStart={(e) => {
      e.dataTransfer.effectAllowed = 'move';
      try {e.dataTransfer.setData('text/plain', layout.id);} catch (_) {}
      onDragStart && onDragStart();
    }}
    onDragOver={onDragOver}
    onDragLeave={onDragLeave}
    onDrop={onDrop}
    onDragEnd={onDragEnd}
    style={{
      display: 'grid',
      gridTemplateColumns: 'auto 36px minmax(160px, max-content) minmax(0, 1fr) auto auto',
      gap: 10, alignItems: 'center',
      padding: '8px 10px',
      background: selected ? 'var(--accent-bg)' : 'var(--surface)',
      border: '1px solid',
      borderColor: dragOver ? 'var(--accent)' :
      selected ? 'var(--accent-border)' : 'var(--border)',
      borderRadius: 'var(--radius-md)',
      cursor: gripHover ? 'grab' : 'default',
      position: 'relative',
      boxShadow: dragOver ? '0 -2px 0 0 var(--accent)' :
      selected ? '0 0 0 1px var(--accent-border)' : 'none',
      opacity: dragging ? 0.4 : 1,
      userSelect: 'none',
      transition: 'border-color 80ms, box-shadow 80ms, opacity 80ms'
    }}
    onMouseOver={(e) => {if (!selected && !dragOver) e.currentTarget.style.borderColor = 'var(--border-strong)';}}
    onMouseOut={(e) => {if (!selected && !dragOver) e.currentTarget.style.borderColor = 'var(--border)';}}>
      <span
        onMouseEnter={() => setGripHover(true)}
        onMouseLeave={() => setGripHover(false)}
        onClick={(e) => e.stopPropagation()}
        title="Drag to reorder"
        style={{ display: 'inline-flex', cursor: 'grab', color: 'var(--text-faint)' }}>
        <I.GripVertical size={14} />
      </span>
      <span style={{
        fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
        color: 'var(--text-muted)', letterSpacing: '.04em'
      }}>L{layout.n}</span>
      <span className="chip" style={{ whiteSpace: 'nowrap' }}>
        <Icon size={11} />{friendly}
      </span>
      <span className="truncate" style={{
        fontSize: 13, color: 'var(--text)', minWidth: 0
      }}>{locText(layout.summary)}</span>
      <StatusPill status={layout.status} />
      <span onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
        draggable={false} onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }}
        style={{ display: 'inline-flex' }}>
        <KebabMenu items={[
          { label: 'Open in Layout editor', icon: 'ArrowRight', onSelect: onDoubleClick },
          { label: 'Duplicate layout', icon: 'Copy',
            hint: 'Copy this layout into another module',
            onSelect: () => setDuplicating(true) },
          { divider: true },
          { label: 'Delete layout', icon: 'Trash', danger: true,
            onSelect: () => setConfirmDel(true) },
        ]} />
      </span>
      {confirmDel && ConfirmDialog &&
        <span onClick={(e) => e.stopPropagation()}>
          <ConfirmDialog icon="Trash" tone="danger"
            title={`Delete layout L${layout.n}?`}
            body="This removes the layout from the module. The source paragraphs it covered will be marked unmapped and surface in Validation."
            confirmLabel="Delete layout"
            onCancel={() => setConfirmDel(false)}
            onConfirm={() => { setConfirmDel(false); onDelete && onDelete(layout.id); }} />
        </span>
      }
      {duplicating && DuplicateLayoutDialog &&
        <span onClick={(e) => e.stopPropagation()}>
          <DuplicateLayoutDialog
            layout={layout}
            sourceModule={module}
            modules={modules || []}
            onCancel={() => setDuplicating(false)}
            // ★ This used to be `() => setDuplicating(false)` — the dialog closed and
            // NOTHING was duplicated. Omar, 2026-08-12: "When I was trying to user the
            // functionality 'Duplicate layout / Copy this layout into another module' it
            // didn't work."
            //
            // The layout editor's copy of this dialog had the identical defect and was
            // fixed on 2026-07-26; that fix went to ONE of the two call sites, and this
            // one kept closing itself for another seventeen days. Its sibling's comment
            // even says "This used to just close the dialog, so the action silently did
            // nothing" (`feedback_enumerate_every_caller`).
            //
            // Both call sites now end in the SAME handler — `handleDuplicateLayout` in
            // app.jsx — which is what copies the authored fields out of `layoutDrafts`
            // under the new id. Duplicating the entry alone would produce a blank layout
            // (`feedback_one_rule_one_place`).
            onConfirm={(targetModuleId) => {
              setDuplicating(false);
              if (window.dynamoDuplicateLayout) {
                window.dynamoDuplicateLayout(layout.id, targetModuleId);
              }
            }} />
        </span>
      }
    </div>);

}

// ── Shared column header ────────────────────────────────────────────────────
function ColumnHeader({ title, subtitle, count, action }) {
  return (
    <div style={{
      display: 'flex', alignItems: 'flex-start', gap: 8,
      padding: '12px 16px',
      borderBottom: '1px solid var(--border)',
      background: 'var(--surface)',
      minHeight: 48
    }}>
      <div style={{ flex: 1, display: 'flex', alignItems: 'flex-start', gap: 8, minWidth: 0 }}>
        <h2 style={{
          margin: 0, fontSize: 13.5, fontWeight: 600,
          color: 'var(--text)', letterSpacing: '-.01em',
          minWidth: 0, flex: 1
        }}>{title}</h2>
        {count != null && <span style={{
          fontSize: 11, color: 'var(--text-faint)', fontFamily: 'var(--font-mono)',
          background: 'var(--surface-inset)', padding: '2px 6px', borderRadius: 4
        }}>{count}</span>}
        {subtitle && <span style={{ fontSize: 12, color: 'var(--text-faint)' }}>{subtitle}</span>}
      </div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 4, paddingTop: 2 }}>{action}</div>
    </div>);

}

// ── Click-to-edit inline editor used by the Layouts column header for the
//    module title + summary. Empty values show a placeholder; click anywhere
//    on the label enters edit mode. Enter (or blur) commits, Escape cancels.
//    Set `multiline` for summary-style fields (Shift+Enter for line breaks,
//    Enter alone still commits).
function ModuleInlineEdit({ value, placeholder, onChange, fontSize = 13.5,
  fontWeight = 500, color = 'var(--text)',
  allowEmpty = true, multiline = false, title }) {
  const [editing, setEditing] = React.useState(false);
  // `value` is a LocalizedString (per-language object) or legacy string. Edit
  // the active-language slot; the parent's onChange wraps the typed string back
  // into the object via locSet (see call sites).
  const str = locText(value);
  const [draft, setDraft] = React.useState(str);
  React.useEffect(() => {setDraft(str);}, [str]);

  const commit = () => {
    setEditing(false);
    const v = (draft || '').trim();
    if (v === str.trim()) return;
    if (!v && !allowEmpty) {setDraft(str);return;}
    onChange && onChange(v);
  };

  if (editing) {
    const Comp = multiline ? 'textarea' : 'input';
    return (
      <Comp autoFocus value={draft}
      onChange={(e) => setDraft(e.target.value)}
      onBlur={commit}
      onKeyDown={(e) => {
        if (e.key === 'Enter' && !(multiline && e.shiftKey)) {
          e.preventDefault();commit();
        } else if (e.key === 'Escape') {
          setDraft(str);setEditing(false);
        }
      }}
      rows={multiline ? 2 : undefined}
      className="field"
      style={{
        width: '100%', minWidth: 0,
        fontSize, fontWeight, color,
        fontFamily: 'inherit',
        height: multiline ? 'auto' : 26,
        padding: multiline ? '4px 6px' : '0 6px',
        minHeight: multiline ? 36 : undefined,
        resize: multiline ? 'vertical' : 'none',
        lineHeight: 1.4
      }} />);

  }

  const isEmpty = !str.trim();
  return (
    <span
      onClick={() => setEditing(true)}
      title={title}
      style={{
        display: multiline ? '-webkit-box' : 'inline-block',
        width: '100%', minWidth: 0,
        padding: '2px 6px', margin: '-2px -6px',
        borderRadius: 4,
        fontSize, fontWeight,
        color: isEmpty ? 'var(--text-faint)' : color,
        fontStyle: isEmpty ? 'italic' : 'normal',
        lineHeight: 1.4,
        cursor: 'text',
        overflow: 'hidden',
        textOverflow: 'ellipsis',
        whiteSpace: multiline ? 'normal' : 'nowrap',
        ...(multiline && {
          WebkitLineClamp: 2,
          WebkitBoxOrient: 'vertical'
        }),
        transition: 'background 80ms'
      }}
      onMouseOver={(e) => e.currentTarget.style.background = 'var(--surface-inset)'}
      onMouseOut={(e) => e.currentTarget.style.background = 'transparent'}>
      {isEmpty ? placeholder : str}
    </span>);

}

// ── Add-layout picker ────────────────────────────────────────────────────────
// A compact popover listing every (non-hidden) layout type, grouped. Used by
// the module-header "Add layout" button and the dashed add row at the bottom of
// the layout list. Picking a type appends a pending layout to the module.
function AddLayoutPicker({ variant = 'header', onPick }) {
  const [open, setOpen] = React.useState(false);
  const types = (window.LAYOUT_TYPES || []).filter((t) => !t.hidden);
  const groups = {};
  types.forEach((t) => {(groups[t.group] = groups[t.group] || []).push(t);});

  // The popover is rendered in a FIXED layer positioned from the trigger's
  // bounding box, not as an absolutely-positioned child.
  //
  // Why: as an absolute child it was clipped by a scrolling ancestor, so the
  // in-page ("full") variant — which also opened upwards — showed only its last
  // few entries while the identical menu from the module-header button rendered
  // fine. A fixed layer can't be clipped by any ancestor's overflow, and we pick
  // the side with room, so the whole list is always reachable.
  const MENU_W = 300;
  const MENU_MAX_H = 360;
  const wrapRef = React.useRef(null);
  const [pos, setPos] = React.useState(null);

  const place = React.useCallback(() => {
    const el = wrapRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const spaceBelow = window.innerHeight - r.bottom - 8;
    const spaceAbove = r.top - 8;
    const dropUp = spaceBelow < Math.min(MENU_MAX_H, 220) && spaceAbove > spaceBelow;
    const maxH = Math.max(160, Math.min(MENU_MAX_H, dropUp ? spaceAbove : spaceBelow));
    // Right-align with the trigger, but never off either edge of the viewport.
    const left = Math.min(
      Math.max(8, r.right - MENU_W),
      Math.max(8, window.innerWidth - MENU_W - 8),
    );
    setPos({
      left,
      ...(dropUp
        ? { bottom: Math.max(8, window.innerHeight - r.top + 4) }
        : { top: r.bottom + 4 }),
      maxHeight: maxH,
    });
  }, []);

  // Reposition while open: scrolling or resizing must not leave it stranded.
  React.useEffect(() => {
    if (!open) return undefined;
    place();
    const onChange = () => place();
    window.addEventListener('scroll', onChange, true);
    window.addEventListener('resize', onChange);
    return () => {
      window.removeEventListener('scroll', onChange, true);
      window.removeEventListener('resize', onChange);
    };
  }, [open, place]);

  const trigger = variant === 'full' ?
  <button className="btn sm ghost" onClick={() => setOpen((o) => !o)}
    style={{ width: '100%', height: 36, justifyContent: 'center',
      borderStyle: 'dashed', border: '1px dashed var(--border-strong)', marginTop: 4 }}>
      <I.Plus size={12} />Add layout
    </button> :
  <button className="btn sm" onClick={() => setOpen((o) => !o)} style={{ flexShrink: 0 }}>
      <I.Plus size={12} />Add layout
    </button>;

  return (
    <div ref={wrapRef}
      style={{ position: 'relative', ...(variant === 'full' && { width: '100%' }) }}>
      {trigger}
      {open && pos &&
      <>
        <div onClick={() => setOpen(false)}
        style={{ position: 'fixed', inset: 0, zIndex: 40 }} />
        <div style={{
          position: 'fixed', zIndex: 41, width: MENU_W,
          left: pos.left,
          ...(pos.top !== undefined ? { top: pos.top } : { bottom: pos.bottom }),
          background: 'var(--surface)', border: '1px solid var(--border)',
          borderRadius: 'var(--radius-md)', boxShadow: 'var(--shadow-lg)',
          padding: 8, maxHeight: pos.maxHeight, overflowY: 'auto'
        }}>
          <div style={{ padding: '6px 8px 4px', fontSize: 11, color: 'var(--text-faint)',
            fontWeight: 600, letterSpacing: '.04em', textTransform: 'uppercase' }}>
            Add a layout
          </div>
          {Object.entries(groups).map(([g, items]) =>
          <div key={g}>
              <div style={{ padding: '8px 8px 2px', fontSize: 10.5, color: 'var(--text-faint)',
              fontWeight: 600, letterSpacing: '.06em', textTransform: 'uppercase' }}>{g}</div>
              {items.map((t) => {
                const Ti = I[t.icon] || I.Hash;
                return (
                  <button key={t.id}
                  onClick={() => {onPick && onPick(t.id);setOpen(false);}}
                  style={{
                    display: 'flex', alignItems: 'center', gap: 8, width: '100%',
                    padding: '6px 8px', textAlign: 'left', border: 0,
                    borderRadius: 'var(--radius)', background: 'transparent',
                    color: 'var(--text)', cursor: 'default', fontSize: 12.5, fontFamily: 'inherit'
                  }}
                  onMouseOver={(e) => e.currentTarget.style.background = 'var(--surface-inset)'}
                  onMouseOut={(e) => e.currentTarget.style.background = 'transparent'}>
                    <Ti size={14} style={{ color: 'var(--text-muted)' }} />
                    <span style={{ flex: 1 }}>{t.label}</span>
                  </button>);

              })}
            </div>
          )}
        </div>
      </>
      }
    </div>);

}

// ── Module thumbnail ─────────────────────────────────────────────────────────
// The module image, emitted as `<thumbnailURL>` in content.xml.
//
// REAL as of campaign Stage 2 (backlog item 3). Until then this control set the
// value to a fabricated path — `modules/m1/image.jpg` — for a file that was never
// uploaded and never existed: a false affordance that looked like a bound image
// and shipped a 404 (or, once `thumbnailUrl` reached the export, an unresolved
// asset). It now does the same real presigned upload as every other media slot
// (`window.uploadAsset` → `asset://<id>`), shows the actual picture, and reports a
// failure instead of silently leaving the tile bound.
//
// The chain this completes: here → `buildModule` (surface-export.jsx) →
// `thumbnailUrl` in PREFIXED_MEDIA_FIELDS (asset-inline.ts) → `<thumbnailURL>`
// (emit-content-xml.ts) → the bytes in the ZIP.
// GENERALISED + PUBLISHED 2026-08-12 (Phase 4d). Phase 4d needed a per-organisation
// logo slot, and this app already had FOUR image-upload controls (`MediaSlot`,
// `BackgroundPicker`, `CompanionSlot`, this one). A fifth hand-copy is how they
// quietly stop behaving alike — the same reasoning that produced the shared
// `DraftSaveButton` and `RetranslateButton`
// (`feedback_a_test_double_that_reimplements_drifts`).
//
// THIS one is the model rather than `CompanionSlot` for a specific reason:
// `CompanionSlot` swallows an upload failure into `console.error` and leaves the
// slot looking unchanged, so the author believes the image is set. This one shows
// "Upload failed · tap to retry" (`feedback_honest_gates_over_standins`).
//
// `moduleId` was accepted and never read; dropped rather than carried forward.
// Every prop below defaults to the module-thumbnail behaviour, so the existing
// call site is unchanged.
function ImageUploadSlot({ value, onChange, label = 'Add image', hint = '16 : 9',
  title = 'Add module image', width = 104, minHeight = 64, fit = 'cover',
  // Opt-in cap on the longest edge, applied before the upload. Off by default: a
  // module image or a background fills a large area, and shrinking those would throw
  // away pixels the learner sees. Only a caller that KNOWS how small its target box is
  // — the organisation logo, drawn in at most ~164×82 CSS px — should set it.
  maxEdge = null }) {
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [preview, setPreview] = React.useState(null);
  const fileRef = React.useRef(null);
  const courseId = window.dynamoCourseId;

  // `asset://<id>` is opaque and the bucket is private — resolve it to something
  // an <img> can load (a blob URL from this session, else a signed GET).
  React.useEffect(() => {
    let alive = true;
    if (!value) { setPreview(null); return () => { alive = false; }; }
    Promise.resolve(window.resolveAssetUrl ? window.resolveAssetUrl(value) : value)
      .then(url => { if (alive) setPreview(url || null); })
      .catch(() => { if (alive) setPreview(null); });
    return () => { alive = false; };
  }, [value]);

  const pick = async (e) => {
    const file = e.target.files && e.target.files[0];
    e.target.value = '';                    // allow re-picking the same file
    if (!file) return;
    setBusy(true); setError(null);
    try {
      // Downscale-only, and it returns the original file unchanged on any failure —
      // so this can cost bytes, never the upload itself.
      const toUpload = maxEdge && window.shrinkImageFile
        ? await window.shrinkImageFile(file, maxEdge)
        : file;
      const ref = await window.uploadAsset(courseId, toUpload, 'image');
      onChange && onChange(ref);
    } catch (err) {
      // Say so. A silent failure here left the author believing the image was set.
      setError((err && err.message) || 'upload failed');
    } finally {
      setBusy(false);
    }
  };

  const shell = {
    position: 'relative', width, flexShrink: 0, minHeight, borderRadius: 6,
    overflow: 'hidden', border: '1px solid var(--border)', background: 'var(--surface-inset)',
  };

  if (value) {
    return (
      <div style={shell}>
        {preview
          ? <img src={preview} alt="" style={{ width: '100%', height: '100%',
              objectFit: fit, display: 'block', minHeight }} />
          : <div style={{ position: 'absolute', inset: 0, display: 'flex',
              alignItems: 'center', justifyContent: 'center', color: 'var(--text-faint)' }}>
              <I.Image size={20} />
            </div>}
        <div className="truncate" style={{ position: 'absolute', left: 0, right: 0, bottom: 0,
          padding: '3px 6px', fontSize: 9.5, fontFamily: 'var(--font-mono)',
          color: '#fff', background: 'linear-gradient(to top, rgba(0,0,0,.75), transparent)' }}>
          {(window.assetLabel && window.assetLabel(value)) || String(value).split('/').pop()}
        </div>
        <button title="Remove image" onClick={() => onChange && onChange(null)}
          style={{ position: 'absolute', top: 4, right: 4, width: 18, height: 18,
            borderRadius: '50%', border: 0, cursor: 'pointer',
            background: 'rgba(15,23,42,.7)', color: '#fff',
            display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
          <I.X size={11} />
        </button>
      </div>);
  }
  return (
    <>
      <input ref={fileRef} type="file" accept="image/*" onChange={pick}
        style={{ display: 'none' }} />
      <button title={title} disabled={busy}
        onClick={() => fileRef.current && fileRef.current.click()}
        style={{ width, flexShrink: 0, minHeight, borderRadius: 6,
          border: '1px dashed var(--border-strong)', background: 'var(--surface-inset)',
          display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
          gap: 4, color: error ? 'var(--danger, #dc2626)' : 'var(--text-muted)',
          cursor: busy ? 'progress' : 'pointer', fontFamily: 'inherit' }}>
        <I.Image size={17} style={{ color: 'var(--text-faint)' }} />
        <span style={{ fontSize: 10.5, fontWeight: 500 }}>
          {busy ? 'Uploading…' : error ? 'Upload failed' : label}
        </span>
        <span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
          {error ? 'tap to retry' : hint}
        </span>
      </button>
    </>);
}

Object.assign(window, { SurfaceDraft, ColumnHeader, ModuleInlineEdit, ImageUploadSlot });