// Surface — Content mapping
//
// Split out of the old combined Roles surface (see §25.14). This page owns
// ONLY the binding matrix: rows are the bindable content (modules + pre/post
// question groups & questions), columns are the roles (grouped by their
// organisation, read-only here). The author's whole job is to tick / untick
// cells. Roles and organisations are CREATED and EDITED on the
// "Organisations & roles" surface — never here. No inline role inspector,
// no add/delete role, no organisation editing.

// `role.label` is a LocalizedString since Phase 2b. The grid shows the name in
// the course's default language; rendering the object itself would throw
// "Objects are not valid as a React child" and blank the screen, since this app
// has no error boundary (`feedback_localizedstring_bound_to_plain_widget`).
function roleName(role, lang) {
  const l = role && role.label;
  if (!l || typeof l !== 'object') return '';
  return l[lang] || '';
}

function SurfaceContentMapping({ roles: rolesIn, brands: brandsIn, course, defaultLang,
                                assessments, onNavigate, onUpdateRoles, onUpdateSettings }) {
  // Phase 4b: organisations come from the PERSISTED slice, not from `course`.
  // Read-only here — this grid groups by organisation and never edits one.
  const brands = brandsIn || [];
  // ★ DERIVED, not read from a flag. It used to be `!!course.selectBrand`, which
  // exists ONLY in the two fixtures (`data.jsx:243`, `sample-content.jsx:546`) —
  // so on a real course loaded from the server it was always false and this grid
  // would have stopped grouping the moment organisations became real, while Org &
  // Roles (which already derives it from `brands.length > 0`) kept grouping. Two
  // views of one fact, disagreeing (`feedback_one_rule_one_place`). Same
  // derivation in both places now.
  const selectBrand = brands.length > 0;
  const roles = rolesIn || [];
  const lang = defaultLang || 'en';
  const roleCodes = React.useMemo(() => roles.map(r => r.code), [roles]);

  // ── The REAL assessments slice, adapted to this grid's row vocabulary ───────
  //
  // Phase 3b. Until now this surface was handed `SAMPLE_ASSESSMENTS` — demo
  // questions belonging to no course — and every tick on a question row was
  // thrown away when the author navigated elsewhere. Both halves are gone: the
  // rows are the author's own questions, and a tick writes to the draft.
  //
  // The adapter earns its keep twice over. It flattens the LocalizedString
  // `prompt` to a plain string (binding the object as a React child throws and
  // blanks the screen — no error boundary anywhere), and it supplies `mod`, the
  // FE module id, WITHOUT which both of Q3's rules silently pass everything:
  // they key on `group.mod` and the real slice calls it `moduleId`. That failure
  // would have been invisible, because their tests fixture the old shape.
  const adapted = React.useMemo(
    () => window.adaptAssessmentsForGrid(assessments, course, v => locText(v, lang)),
    [assessments, course, lang]);

  // ── Q2 · a role with no modules ticked (Omar: "Allow it, just warn") ───────
  // Read from the PERSISTED roles, so the warning tracks what will actually be
  // exported rather than a local copy of it.
  const allModuleIds = window.allCourseModuleIds(course);
  const emptyRoleCodes = window.rolesSeeingNoModules(roles);
  const completionBased = !!(course.assessmentMode === 'completion'
    || (course.assessments && course.assessments.completionBased));
  const emptyWarning = emptyRoleCodes.length
    ? window.noModulesWarning(
        emptyRoleCodes.map(c => {
          const r = roles.find(x => x.code === c);
          return roleName(r, lang) || c;
        }), completionBased)
    : null;

  // Q3's refusal, surfaced as a transient banner rather than a browser alert().
  const [blocked, setBlocked] = React.useState(null);
  // What a module untick just removed, announced rather than done silently.
  const [cascaded, setCascaded] = React.useState(null);

  // ── There is NO local binding state on this surface any more ───────────────
  //
  // Phase 2c moved module ticks onto the persisted `roles[].modules`; Phase 3b
  // moves question tags onto the persisted `assessments…questions[].roles` and
  // DERIVES the group cell from them (Q7). So every cell on this screen now reads
  // the saved value directly and there is exactly one copy of each fact
  // (`feedback_one_rule_one_place`).
  //
  // Deleting the old `bindings` Set retires three defects at once, which is why
  // it is a deletion rather than an addition:
  //   - question ticks no longer evaporate on navigation (it was per-mount state,
  //     and the next visit re-seeded from the FIXTURE's own selections, so the
  //     screen showed plausible choices the author never made);
  //   - `bindings[r.code]` was dereferenced unguarded for a role added after
  //     mount, which would have crashed the surface;
  //   - a local Set shadowing a saved value is precisely how a tick can look
  //     applied and never be saved.
  const [openGroups, setOpenGroups] = React.useState(new Set());

  const toggle = (roleCode, kind, id) => {
    setBlocked(null);
    setCascaded(null);
    const role = roles.find(r => r.code === roleCode);
    const label = roleName(role, lang) || roleCode;

    // ── A MODULE tick · persisted ──────────────────────────────────────────
    if (kind === 'modules') {
      const turningOff = window.roleSeesModule(role, id);
      if (turningOff && onUpdateSettings) {
        // Q3, direction 2: unticking the module strands the questions inside it.
        // Cascade — the author's intent is unambiguous — but SAY what went, because
        // a silent removal is a quieter loss, not a smaller one
        // (`feedback_a_fix_can_trade_one_loss_for_a_worse_one`).
        //
        // Now that the tags are persisted this really does rewrite the draft, so
        // the announcement counts what was ACTUALLY changed rather than what was
        // eligible.
        let next = assessments;
        let n = 0;
        ['pre', 'post'].forEach(sec => {
          (adapted[sec].groups || []).forEach(g => {
            if (g.mod !== id) return;
            (g.questions || []).forEach(q => {
              if (!window.roleSeesQuestion(q, roleCode)) return;
              next = window.toggleQuestionForRole(
                next, sec, g.uid, q.uid, roleCode, roleCodes);
              n += 1;
            });
          });
        });
        if (n > 0) {
          onUpdateSettings({ assessments: next });
          setCascaded(`${id} is no longer visible to ${label}, so ${n} question ` +
            `${n === 1 ? 'row' : 'rows'} inside it ${n === 1 ? 'was' : 'were'} unticked too — ` +
            `a question the learner can never reach would have been asked of nobody.`);
        }
      }
      if (onUpdateRoles) {
        onUpdateRoles(window.toggleModuleForRole(roles, roleCode, id, allModuleIds));
      }
      return;
    }

    // ── An ASSESSMENT tick · PERSISTED as of Phase 3b ───────────────────────
    const [bucket, sub] = kind.includes('.') ? kind.split('.') : [kind, null];
    const secData = adapted[bucket] || {};
    const group = (secData.groups || []).find(g =>
      g.id === id || (g.questions || []).some(q => q.id === id));
    if (!group || !onUpdateSettings) return;

    // Q3, direction 1: refuse to tick a question inside a module this role cannot
    // see. Only on the way ON — unticking is always allowed. Those cells are also
    // dimmed BEFORE being clicked, so the refusal is never a surprise.
    const currentlyOn = sub === 'groups'
      ? window.groupTickState(group, roleCode) !== 'none'
      : window.roleSeesQuestion(
          (group.questions || []).find(q => q.id === id), roleCode);
    if (!currentlyOn) {
      const reason = window.assessmentTickBlockedReason(role, group, label);
      if (reason) { setBlocked(reason); return; }
    }

    if (sub === 'groups') {
      // Q7 — the group cell is a BULK ACTION over its questions, never a stored
      // fact: the Player reads no group-level role attribute, so storing one
      // would promise something the runtime cannot keep. A partial group FILLS
      // rather than empties, which is what a half-ticked box invites.
      const turnOn = window.groupTickState(group, roleCode) !== 'all';
      onUpdateSettings({
        assessments: window.setGroupForRole(
          assessments, bucket, group.uid, roleCode, turnOn, roleCodes),
      });
      return;
    }

    onUpdateSettings({
      assessments: window.toggleQuestionForRole(
        assessments, bucket, group.uid, id, roleCode, roleCodes),
    });
  };

  // ── Q1 and Q5, told to the author HERE rather than only at export ──────────
  // Both are enforced at export too — Q1 as nothing (the runtime clamps) and Q5
  // as a 422. An export-time 422 is the wrong moment to learn that a role scoped
  // an hour ago is asked nothing (`feedback_honest_gates_over_standins`).
  const coverage = React.useMemo(
    () => window.questionCoverageProblems(adapted, roles), [adapted, roles]);

  // ── Column order: roles clustered by organisation band ───────────────────
  const orderedRoles = React.useMemo(() => {
    if (!selectBrand) return roles;
    const out = [];
    brands.forEach(b => out.push(...roles.filter(r => r.brand === b.code)));
    out.push(...roles.filter(r => !brands.some(x => x.code === r.brand)));
    return out;
  }, [roles, brands, selectBrand]);

  const brandBands = React.useMemo(() => {
    if (!selectBrand) return null;
    const counts = brands.map(b => ({
      brand: b, count: orderedRoles.filter(r => r.brand === b.code).length,
    }));
    const unbranded = orderedRoles.filter(r => !brands.some(x => x.code === r.brand)).length;
    if (unbranded > 0) {
      counts.push({
        brand: { id: '__none', label: 'No organisation',
          color: 'var(--surface-inset)', textColor: 'var(--text-muted)' },
        count: unbranded,
      });
    }
    return counts.filter(c => c.count > 0);
  }, [orderedRoles, brands, selectBrand]);

  // ── Rows: chapters · pre · post ───────────────────────────────────────────
  const moduleSections = (course.moduleGroups || [{ id: '_', title: 'Modules' }])
    .map(g => ({
      kind: 'section', label: locText(g.title),
      rows: course.modules
        .filter(m => (m.group || '_') === g.id)
        .map(m => ({
          kind: 'module', id: m.id, code: `M${m.n}`, label: locText(m.title),
          meta: { duration: m.duration, mandatory: m.mandatory },
          bindKind: 'modules',
        })),
    }));
  const assessmentSection = (key, label) => ({
    kind: 'section', label,
    rows: (adapted[key].groups || []).flatMap(g => {
      const out = [{
        // `code` used to be `g.id.toUpperCase()` — a raw uid, meaningless to the
        // author and 20-odd characters wide. The module code is what identifies a
        // group, because a group IS its module (the export gate rejects two groups
        // on one module).
        kind: 'group', section: key, id: g.id, code: g.mod || '—',
        label: g.label, meta: { mod: g.mod, count: g.questions?.length || 0,
          asked: g.questionsShown },
        // The adapted group/question objects travel WITH the row, so a cell
        // derives its state from the persisted data rather than from a lookup
        // that could go stale between render and click.
        group: g,
        bindKind: `${key}.groups`,
      }];
      if (openGroups.has(g.id)) {
        (g.questions || []).forEach((q, qi) => {
          out.push({
            kind: 'question', section: key, groupId: g.id, id: q.id,
            // `mod` travels onto the question row too, so a cell can be shown
            // as unavailable BEFORE it is clicked. Offering a tick and then
            // refusing it is the false affordance
            // `feedback_no_false_affordance_toggles` warns about.
            mod: g.mod,
            // `label` is a PLAIN STRING: the adapter flattened the real slice's
            // LocalizedString `prompt` through `locText`. Binding the object here
            // would throw "Objects are not valid as a React child" and blank the
            // whole screen (`feedback_localizedstring_bound_to_plain_widget`).
            code: `Q${qi + 1}`, label: q.prompt, question: q,
            bindKind: `${key}.questions`,
          });
        });
      }
      return out;
    }),
  });
  const allSections = [
    ...moduleSections,
    assessmentSection('pre',  'Pre-assessment · question groups'),
    assessmentSection('post', 'Post-assessment · question groups'),
  ];

  const COL_W = 130;
  const FIRST_W = 440;
  const ROLE_GRID = `${FIRST_W}px repeat(${orderedRoles.length}, ${COL_W}px)`;
  const BRAND_GRID = brandBands && brandBands.length > 0
    ? `${FIRST_W}px ${brandBands.map(b => `${b.count * COL_W}px`).join(' ')}`
    : ROLE_GRID;

  return (
    <div data-screen-label="Surface · Content mapping" style={{
      display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg)',
    }}>
      <window.SurfaceHeader title="Content mapping"
        description={<>Decide who sees what — tick a cell to include a module or question
          for a role. Add or rename roles in <strong>Organisations &amp; roles</strong>.</>}>
        {/* THE MISSING DOOR — Omar's report, 2026-08-11: "there is no Save button
            on the Content mapping page". He was right, and it was not cosmetic:
            this screen had no path to the server at all, so every tick lived in
            one browser until an unrelated screen's Save or a Build pushed the
            whole aggregate. `dirtySignal` covers BOTH things this grid writes —
            the roles' module lists and the questions' role tags — so the button
            cannot report "Saved to the server" over a tick made after the save. */}
        <window.DraftSaveButton
          dirtySignal={JSON.stringify([roles, assessments])}
          title="Write this mapping to the server, so it survives a cleared browser and can be opened on another machine" />
      </window.SurfaceHeader>

      {/* REMOVED for Stage 5 Phase 3b, because the gap it named has closed. It
          read "Question rows are not live yet — they are still sample data, and
          every learner is asked all of them whatever you tick here." Both halves
          are now false: the rows are the author's own questions and a tick reaches
          the ZIP as that question's `roles` attribute.
          Deleting an honest badge once its subject is fixed is the other half of
          `feedback_an_honest_badge_is_not_a_fix` — a warning that outlives its
          defect trains the author to ignore the next one. What replaces it is not
          a reassurance but the per-role coverage warnings below, which say
          something only when something is actually wrong. */}

      {/* Q5 first — it BLOCKS the export, so it cannot be one bullet among
          equals. `questionCoverageProblems` sorts blocked ahead of short. */}
      {coverage.filter(p => p.kind === 'blocked').length > 0 && (
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 24px',
          background: 'rgba(220,38,38,.12)', borderBottom: '1px solid var(--danger, #dc2626)',
          flex: '0 0 auto', fontSize: 12, color: 'var(--danger-text, #b91c1c)' }}>
          <I.AlertTriangle size={13} style={{ flexShrink: 0, marginTop: 1 }} />
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            <strong>The export is blocked until these are fixed.</strong>
            {coverage.filter(p => p.kind === 'blocked').map(p => (
              <span key={`${p.sec}-${p.groupUid}-${p.roleCode}`}>
                {window.questionCoverageMessage(
                  p, roleName(roles.find(r => r.code === p.roleCode), lang) || p.roleCode)}
              </span>
            ))}
          </div>
        </div>
      )}

      {/* Q1 — LOSSY, never blocking. Omar, 2026-08-09: "just inform the admin, but
          do not block and make it mandatory." The runtime clamps "ask N" down to
          what the role can actually see, which is why a warning is the right level
          of intervention rather than an error. */}
      {coverage.filter(p => p.kind === 'short').length > 0 && (
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 24px',
          background: 'var(--warning-bg, rgba(217,119,6,.08))',
          borderBottom: '1px solid var(--warning)', flex: '0 0 auto',
          fontSize: 12, color: 'var(--warning-text)' }}>
          <I.Info size={13} style={{ flexShrink: 0, marginTop: 1 }} />
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            {coverage.filter(p => p.kind === 'short').map(p => (
              <span key={`${p.sec}-${p.groupUid}-${p.roleCode}`}>
                {window.questionCoverageMessage(
                  p, roleName(roles.find(r => r.code === p.roleCode), lang) || p.roleCode)}
              </span>
            ))}
          </div>
        </div>
      )}

      {/* Q2's warning · a role with nothing ticked. Placed above the grid because
          it describes the state of the whole mapping rather than one cell — and
          worded to cover BOTH halves of the behaviour, since an empty list shows
          every module while a completion-based course simultaneously asks no
          questions. */}
      {emptyWarning && (
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 24px',
          background: 'rgba(217,119,6,.14)', borderBottom: '1px solid var(--warning)',
          flex: '0 0 auto', fontSize: 12, color: 'var(--warning-text)' }}>
          <I.AlertTriangle size={13} style={{ flexShrink: 0, marginTop: 1 }} />
          <span>{emptyWarning}</span>
        </div>
      )}

      {/* Q3's refusal, and the cascade announcement. Both describe the click
          that just happened, so both are dismissible rather than permanent. */}
      {blocked && (
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 24px',
          background: 'rgba(220,38,38,.12)', borderBottom: '1px solid var(--danger, #dc2626)',
          flex: '0 0 auto', fontSize: 12, color: 'var(--text)' }}>
          <I.X size={13} style={{ flexShrink: 0, marginTop: 1 }} />
          <span style={{ flex: 1 }}>{blocked}</span>
          <button onClick={() => setBlocked(null)} style={{ background: 'transparent',
            border: 0, color: 'var(--text-muted)', cursor: 'default', fontSize: 11 }}>Dismiss</button>
        </div>
      )}
      {cascaded && (
        <div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, padding: '10px 24px',
          background: 'var(--surface-inset)', borderBottom: '1px solid var(--border)',
          flex: '0 0 auto', fontSize: 12, color: 'var(--text-muted)' }}>
          <I.Info size={13} style={{ flexShrink: 0, marginTop: 1 }} />
          <span style={{ flex: 1 }}>{cascaded}</span>
          <button onClick={() => setCascaded(null)} style={{ background: 'transparent',
            border: 0, color: 'var(--text-muted)', cursor: 'default', fontSize: 11 }}>Dismiss</button>
        </div>
      )}

      {orderedRoles.length === 0 ? (
        <ContentMappingEmpty onNavigate={onNavigate} />
      ) : (
        <div style={{ flex: 1, overflow: 'hidden', display: 'flex', padding: '16px 24px 24px' }}>
          <div style={{ flex: 1, minHeight: 0,
            display: 'flex', flexDirection: 'column' }}>
            <div style={{ flex: 1, minHeight: 0, background: 'var(--surface)',
              border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', overflow: 'auto' }}>

              {/* Sticky header bundle */}
              <div style={{ position: 'sticky', top: 0, zIndex: 3, background: 'var(--surface)',
                borderBottom: '1px solid var(--border)' }}>
                {/* Organisation super-header */}
                {brandBands && brandBands.length > 0 && (
                  <div style={{ display: 'grid', gridTemplateColumns: BRAND_GRID }}>
                    <div />
                    {brandBands.map(({ brand }) => (
                      <div key={brand.code} style={{
                        padding: '6px 10px', background: brand.color,
                        color: brand.textColor || '#fff', fontSize: 12, fontWeight: 600,
                        letterSpacing: '.04em', textAlign: 'center',
                        borderLeft: '1px solid rgba(255,255,255,.18)',
                      }}>{roleName(brand, lang) || brand.code}</div>
                    ))}
                  </div>
                )}
                {/* Role-name row (read-only) */}
                <div style={{ display: 'grid', gridTemplateColumns: ROLE_GRID }}>
                  <div style={{ padding: '12px 14px', fontSize: 11.5, fontWeight: 600,
                    textTransform: 'uppercase', letterSpacing: '.06em', color: 'var(--text-muted)' }}>
                    Chapter / module / question group
                  </div>
                  {orderedRoles.map(r => (
                    <RoleColumnHeader key={r.code} role={r} name={roleName(r, lang)}
                      brand={brands.find(b => b.code === r.brand)} />
                  ))}
                </div>
              </div>

              {/* Body — section rows */}
              {allSections.map((section, sIdx) => (
                <React.Fragment key={`s${sIdx}`}>
                  <div style={{
                    gridColumn: '1 / -1', padding: '10px 14px',
                    background: 'var(--surface-inset)',
                    borderTop: sIdx === 0 ? 0 : '1px solid var(--border)',
                    borderBottom: '1px solid var(--border)',
                    fontSize: 11, fontWeight: 600, textTransform: 'uppercase',
                    letterSpacing: '.08em', color: 'var(--text-muted)',
                  }}>{section.label}</div>
                  {section.rows.map(row => (
                    <MatrixRow key={`${row.kind}-${row.id}`} row={row} grid={ROLE_GRID}
                      roles={orderedRoles} onToggle={toggle}
                      expanded={openGroups.has(row.id)}
                      onToggleExpand={() => {
                        if (row.kind !== 'group') return;
                        setOpenGroups(s => {
                          const n = new Set(s);
                          n.has(row.id) ? n.delete(row.id) : n.add(row.id);
                          return n;
                        });
                      }} />
                  ))}
                </React.Fragment>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

// ── ContentMappingEmpty · no roles to map yet ────────────────────────────────
function ContentMappingEmpty({ onNavigate }) {
  return (
    <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center',
      justifyContent: 'center', gap: 14, padding: '60px 24px', textAlign: 'center',
      color: 'var(--text-muted)' }}>
      <I.Table size={34} style={{ color: 'var(--text-faint)' }} />
      <div>
        <h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--text)' }}>
          No roles to map yet
        </h3>
        <p style={{ margin: '6px 0 0', fontSize: 12.5, maxWidth: 400, lineHeight: 1.5 }}>
          Content mapping ties modules and assessment questions to roles. Create your
          roles first, then come back to tick what each one sees.
        </p>
      </div>
      {onNavigate && (
        <button className="btn sm primary" onClick={() => onNavigate('org-roles')}>
          <I.Users size={12} />Go to Organisations &amp; roles
        </button>
      )}
    </div>
  );
}

// ── RoleColumnHeader · read-only name + organisation-tinted accent ───────────
function RoleColumnHeader({ role, name, brand }) {
  return (
    <div style={{
      display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
      padding: '14px 8px 12px', minWidth: 0,
      borderLeft: '1px solid var(--border)',
      borderTop: brand ? `2px solid ${brand.color}` : '2px solid transparent',
      textAlign: 'center',
    }}>
      <span style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.2, maxWidth: '100%' }}
        className="truncate" title={name}>{name}</span>
    </div>
  );
}

// ── MatrixRow ─────────────────────────────────────────────────────────────────
function MatrixRow({ row, grid, roles, onToggle, expanded, onToggleExpand }) {
  return (
    <div style={{
      display: 'grid', gridTemplateColumns: grid,
      borderBottom: '1px solid var(--border-faint)',
      background: row.kind === 'question' ? 'var(--surface-inset)' : 'transparent',
    }}>
      <RowLabel row={row} expanded={expanded} onToggleExpand={onToggleExpand} />
      {roles.map(r => {
        // EVERY cell reads the persisted value. There is no local mirror on this
        // surface any more — modules since Phase 2c, questions since 3b.
        let checked = false;
        let partial = false;
        if (row.bindKind === 'modules') {
          checked = window.roleSeesModule(r, row.id);
        } else if (row.bindKind.endsWith('.groups')) {
          // Q7 — DERIVED from the questions beneath it, never stored, so the
          // group cell can never disagree with them.
          const state = window.groupTickState(row.group, r.code);
          checked = state === 'all';
          partial = state === 'partial';
        } else if (row.bindKind.endsWith('.questions')) {
          checked = window.roleSeesQuestion(row.question, r.code);
        }
        // Q3, shown rather than only enforced: an assessment row whose module
        // this role cannot see is not tickable. `mod` is on both the group row
        // (via meta) and the question row.
        const rowMod = row.kind === 'group' ? (row.meta && row.meta.mod) : row.mod;
        const unavailable = row.kind !== 'module' && !!rowMod
          && !window.roleSeesModule(r, rowMod);
        return (
          <MatrixCell key={r.code} checked={checked} partial={partial}
            unavailable={unavailable} title={unavailable
              ? `${rowMod} is unticked for this role, so this question cannot be included`
              : undefined}
            onChange={() => onToggle(r.code, row.bindKind, row.id)} />
        );
      })}
    </div>
  );
}

function RowLabel({ row, expanded, onToggleExpand }) {
  if (row.kind === 'module') {
    return (
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px' }}>
        <KCode>{row.code}</KCode>
        <span style={{ fontSize: 13, fontWeight: 500 }}>{row.label}</span>
        <div style={{ flex: 1 }} />
        {row.meta.mandatory && <I.Pin size={11} style={{ color: 'var(--text-faint)' }} />}
        <span style={{ fontSize: 11, color: 'var(--text-faint)',
          fontFamily: 'var(--font-mono)' }}>{row.meta.duration}</span>
      </div>
    );
  }
  if (row.kind === 'group') {
    return (
      <button onClick={onToggleExpand} style={{
        display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px',
        background: 'transparent', border: 0, width: '100%', textAlign: 'left',
        cursor: 'default', fontFamily: 'inherit', color: 'var(--text)' }}>
        {expanded ? <I.ChevronDown size={12} /> : <I.ChevronRight size={12} />}
        <KCode>{row.meta.mod}</KCode>
        <span style={{ fontSize: 13, fontWeight: 500 }}>{row.label}</span>
        <span className="pill" style={{ fontSize: 10 }}>{row.meta.count} Qs</span>
      </button>
    );
  }
  if (row.kind === 'question') {
    return <QuestionRowLabel code={row.code} prompt={row.label} />;
  }
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px 8px 38px' }}>
      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
        color: 'var(--text-faint)' }}>{row.code}</span>
      <span className="truncate" style={{ fontSize: 12.5, color: 'var(--text-muted)' }}>{row.label}</span>
    </div>
  );
}

// ── QuestionRowLabel · single-line truncated prompt + full-text popover ──────
// Question prompts often share an opening sentence, so the visible line is
// truncated with an ellipsis and an "expand" affordance opens the FULL prompt
// in a portal popover (rendered to document.body so the matrix's overflow:auto
// never clips it).
function QuestionRowLabel({ code, prompt }) {
  const [open, setOpen] = React.useState(false);
  const [rect, setRect] = React.useState(null);
  const btnRef = React.useRef(null);
  const show = () => { if (btnRef.current) setRect(btnRef.current.getBoundingClientRect()); setOpen(true); };
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 9,
      padding: '8px 12px 8px 38px', minWidth: 0 }}>
      <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
        color: 'var(--text-faint)', flexShrink: 0 }}>{code}</span>
      <span className="truncate" style={{ fontSize: 12.5, color: 'var(--text-muted)',
        minWidth: 0, flex: 1 }}>{prompt}</span>
      <button ref={btnRef} onClick={() => (open ? setOpen(false) : show())}
        title="View the full question"
        aria-label="View the full question" aria-expanded={open}
        style={{ flexShrink: 0, width: 22, height: 22, padding: 0,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          background: open ? 'var(--accent-bg)' : 'transparent',
          color: open ? 'var(--accent-text)' : 'var(--text-faint)',
          border: '1px solid', borderColor: open ? 'var(--accent-border)' : 'transparent',
          borderRadius: 'var(--radius)', cursor: 'default' }}
        onMouseOver={e => { if (!open) e.currentTarget.style.color = 'var(--text-muted)'; }}
        onMouseOut={e => { if (!open) e.currentTarget.style.color = 'var(--text-faint)'; }}>
        <I.Eye size={12} />
      </button>
      {open && rect && ReactDOM.createPortal(
        <>
          <div onClick={() => setOpen(false)}
            style={{ position: 'fixed', inset: 0, zIndex: 999 }} />
          <div className="slide-in" style={{
            position: 'fixed', zIndex: 1000, width: 380,
            top: Math.min(rect.bottom + 8, window.innerHeight - 180),
            left: Math.max(12, Math.min(rect.left - 340, window.innerWidth - 392)),
            padding: '13px 15px', background: 'var(--surface)', color: 'var(--text)',
            border: '1px solid var(--border)', borderRadius: 'var(--radius-md)',
            boxShadow: 'var(--shadow-xl)', fontSize: 13.5, lineHeight: 1.5 }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 7 }}>
              <KCode>{code}</KCode>
              <span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '.06em',
                textTransform: 'uppercase', color: 'var(--text-faint)' }}>Full question</span>
            </div>
            <div style={{ color: 'var(--text)', textWrap: 'pretty' }}>{prompt}</div>
          </div>
        </>,
        document.body
      )}
    </div>
  );
}

function KCode({ children }) {
  return (
    <span style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5, fontWeight: 600,
      padding: '1px 6px', borderRadius: 3, background: 'var(--surface-inset)',
      color: 'var(--text-muted)', letterSpacing: '.04em' }}>{children}</span>
  );
}

// `partial` replaced the old `inherited` in Phase 3b, and the two are opposites
// worth not confusing. `inherited` meant "the group above is unticked, so this
// question is off by inheritance" — a parent gate. Q7 removed the parent: the
// group cell is now DERIVED from its questions, so a question is never off
// because of its group. `partial` is that derived middle state on the GROUP row —
// some of its questions ticked, not all.
//
// It renders as a native indeterminate checkbox rather than a styled div, via a
// ref, because that is the one thing `checked` cannot express and screen readers
// already announce it ("mixed"). A greyed tick would say "off"; a full tick would
// say "all"; both would be lies about the same box.
function MatrixCell({ checked, partial, unavailable, title, onChange }) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (ref.current) ref.current.indeterminate = !checked && !!partial;
  }, [checked, partial]);
  return (
    <label title={title} style={{
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      borderLeft: '1px solid var(--border-faint)', cursor: 'default',
      // `unavailable` is the stronger statement — a cell that cannot be ticked at
      // all should not look like an ordinary empty one.
      opacity: unavailable ? 0.28 : 1 }}
      onMouseOver={e => e.currentTarget.style.background = 'var(--surface-inset)'}
      onMouseOut={e => e.currentTarget.style.background = 'transparent'}>
      <input ref={ref} type="checkbox" checked={checked} onChange={onChange}
        style={{ width: 16, height: 16, accentColor: 'var(--accent)', cursor: 'default' }} />
    </label>
  );
}

Object.assign(window, { SurfaceContentMapping });
