// Shared UI primitives for Dynamo Authoring.
// Buttons + pills come from the global stylesheet; this file owns components
// with behaviour: SuggestionCard, ValidationPill, MediaSlot, FourAnswerEditor, etc.

// ── StatusPill ──────────────────────────────────────────────────────────────
function StatusPill({ status, size = 'sm', iconOnly = false }) {
  const cfg = {
    // Layout states read as save states, not as AI accept/propose states — the
    // editor has a single Save action (Omar, 2026-07-26). The underlying status
    // ids are unchanged so stored drafts and the export path still match.
    accepted: { cls: 'accepted', label: 'Saved', icon: <I.Check size={11} /> },
    proposed: { cls: 'proposed', label: 'Unsaved changes', icon: <I.PenLine size={11} /> },
    issues:   { cls: 'issues',   label: 'Issues',   icon: <I.AlertTriangle size={11} /> },
    pending:  { cls: 'pending',  label: 'Not saved', icon: <I.Clock size={11} /> },
    error:    { cls: 'error',    label: 'Error',    icon: <I.AlertCircle size={11} /> },
    ready:    { cls: 'accepted', label: 'Ready',    icon: <I.Check size={11} /> },
    ingesting:{ cls: 'proposed', label: 'Ingesting', icon: <I.Loader size={11} className="spin" /> },
    queued:   { cls: 'pending',  label: 'Queued',   icon: <I.Clock size={11} /> },
    failed:   { cls: 'error',    label: 'Failed',   icon: <I.AlertCircle size={11} /> },
  }[status] || { cls: '', label: status, icon: null };
  if (iconOnly) {
    // Inline, chromeless status glyph. The icon itself carries the meaning
    // via the *-text token (deeper than the badge --success/--warning so it
    // clears 7+:1 contrast on any background, light or tinted accent). No
    // box, no shadow — keeps modules cards quiet.
    const iconColor = {
      accepted:  'var(--success-text)',
      proposed:  'var(--accent-text)',
      issues:    'var(--warning-text)',
      pending:   'var(--text-muted)',
      error:     'var(--error-text)',
      ready:     'var(--success-text)',
      ingesting: 'var(--accent-text)',
      queued:    'var(--text-muted)',
      failed:    'var(--error-text)',
    }[status] || 'var(--text-muted)';
    // Re-render the cfg icon at a slightly larger inline size for legibility.
    const SizedIcon = React.cloneElement(cfg.icon, { size: 14 });
    return (
      <span title={cfg.label}
        style={{
          width: 14, height: 14, color: iconColor, flexShrink: 0,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        }}>
        {SizedIcon}
      </span>
    );
  }
  return (
    <span className={`pill ${cfg.cls}`}>
      {cfg.icon}{cfg.label}
    </span>
  );
}

// ── ValidationPill ──────────────────────────────────────────────────────────
function ValidationPill({ count, level = 'warning', onClick }) {
  const cfg = level === 'error'
    ? { cls: 'error', icon: <I.AlertCircle size={12} />, label: count === 1 ? '1 blocker' : `${count} blockers` }
    : level === 'success' || count === 0
      ? { cls: 'accepted', icon: <I.Check size={12} />, label: 'All clear' }
      : { cls: 'issues', icon: <I.AlertTriangle size={12} />, label: count === 1 ? '1 issue' : `${count} issues` };
  return (
    <button className={`pill ${cfg.cls} focusable`} onClick={onClick}
            style={{ height: 24, padding: '0 9px', fontSize: 12, cursor: 'default', border: 0 }}>
      {cfg.icon}{cfg.label}
    </button>
  );
}

// ── SaveIndicator ───────────────────────────────────────────────────────────
//
// ⚠️ This used to render the tick for EVERY state that was not 'saving', and its
// one call site (`chrome.jsx`) passed the hardcoded literal `state="saved"`. So
// the header read "✓ Saved —" permanently, including while an author's work sat
// only in this browser's IndexedDB. That is how Omar lost an HR role on
// 2026-08-11: he created it in Chrome, the header said Saved, and Edge could not
// see it because nothing had ever been sent to the server.
//
// `'local'` is the honest default for this app: the debounced autosave writes
// IndexedDB on every keystroke, so work IS saved — just not anywhere another
// browser can read. Only a successful `PUT /draft` earns the tick.
function SaveIndicator({ state = 'local', timestamp }) {
  const base = { fontSize: 12, display: 'inline-flex', alignItems: 'center', gap: 6 };
  // The trailing divider belongs to whatever this renders, so that the decision
  // "is there anything to show?" is made ONCE. It used to live in `chrome.jsx`
  // beside the call, which was fine while this component always rendered
  // something; now that the resting state is silent, a divider owned by the caller
  // would need the same state test in a second place and would drift from it
  // (`feedback_one_rule_one_place`).
  const withRule = (el) => <>
    {el}
    <span style={{ width: 1, height: 24, background: 'var(--border)' }} />
  </>;
  if (state === 'saving') {
    return withRule(<span style={{ ...base, color: 'var(--text-faint)' }}>
      <I.Loader size={12} className="spin" /> Saving…
    </span>);
  }
  if (state === 'saved') {
    return withRule(<span style={{ ...base, color: 'var(--text-faint)' }}>
      <I.Check size={12} /> Saved to the server{timestamp && timestamp !== '—' ? ` ${timestamp}` : ''}
    </span>);
  }
  // 'local' (and any unknown state) — SILENT since 2026-08-12, on Omar's
  // instruction: "Remove also 'Saved on this device' as it is confusing for the
  // Admin."
  //
  // What is being given up, stated plainly: this line was added on 2026-08-11
  // precisely because the header had claimed "✓ Saved" unconditionally since the
  // day it was written, which is why roles lost in Chrome went unnoticed in Edge.
  // Removing a warning that exists for a real failure is only safe because the
  // remedy it pointed at now EXISTS on every screen that owns data — a
  // `DraftSaveButton` (PRs #129 / #131), which reports "Saved to the server" or
  // "Not saved: <reason>" for itself. So the honest signal did not disappear; it
  // moved to the one place that can actually act on it, and the header no longer
  // competes with it. `feedback_an_honest_badge_is_not_a_fix` is the record of why
  // the badge alone was never the fix: it warned him for nine days and he still
  // lost work.
  //
  // The INVARIANT that must survive: the resting state never claims a save. It
  // used to lie; now it says nothing. What it must never do is show a tick.
  // Returning null rather than an empty span also takes the divider with it (see
  // below) — a separator beside nothing reads as a rendering fault.
  return null;
}

// ── DraftSaveButton — THE save control, shared ───────────────────────────────
//
// One implementation, because there were two near-identical ones
// (`AssessmentSaveButton`, `LocSaveToServer`) and two screens that needed a
// third. A hand-copied fourth is how the presentation drifts while everyone
// believes they behave alike. Both originals now render this.
//
// `dirtySignal`: pass any value that CHANGES when the author edits (a counter, a
// JSON length, the edited object itself). When it changes, a stale "Saved" or
// "Not saved" resets to idle — without it the button keeps claiming success over
// edits made after the save.
//
// `idleLabel` defaults to NOTHING since 2026-08-12. It used to default to "Saved
// on this device", which Omar removed by name: "Remove also 'Saved on this device'
// as it is confusing for the Admin." He is right about the confusion — beside a
// button labelled Save, a line reading "Saved…" is read as a status of that
// button, so the control appeared to report success before it had been pressed.
// The label survives as an opt-in for any call site that has a genuine reason to
// caption the resting state; nothing passes one today.
//
// The states that MAKE a claim are untouched: "Saved to the server" only after a
// real 2xx, "Not saved: <reason>" on a refusal. Silence at rest claims nothing,
// which is the property that matters (`feedback_honest_gates_over_standins`).
//
// `alsoSave` (optional): an async function returning `{ok, message}`, run ALONGSIDE
// the draft save. Added 2026-08-14 for Course settings, the one screen whose data
// does not all travel in the draft: `title`/`topic`/`defaultLang` live on the course
// ROW behind a debounced PATCH, and the draft PUT does not carry them. Without this
// leg the button would report "Saved to the server" with a just-typed title still
// sitting in a 900 ms timer — a green tick over an unsent edit, which is the failure
// `feedback_verify_the_control_not_just_the_pipeline` is about, and worse than no
// button because it is believed.
//
// ★ IT IS NOT A GATE, AND THE FIRST VERSION OF IT WAS — caught in review the same
// day, before it shipped. It ran first and returned early on failure, so a refusal
// on one optional metadata field (a `defaultLang` not yet enabled server-side, or a
// blank Topic, which the gateway rejects outright) aborted the aggregate save.
// `dynamoSaveDraftToServer` carries the WHOLE draft, so what would have failed to
// reach the server is not the title — it is every layout edit, role change, cover
// sentence and News message made since the last save. That trades a small silent
// failure for a large loud one (`feedback_a_guard_on_the_save_path_blocks_work_in_progress`,
// `feedback_a_fix_can_trade_one_loss_for_a_worse_one`).
//
// So both legs always run, and the ORDER of the verdicts is the contract
// (`feedback_a_gates_order_is_part_of_its_contract`):
//   · draft save failed          → 'error'.  The serious one; its message wins.
//   · draft ok, side leg failed  → 'partial'. The author's content IS on the server;
//                                  one detail is not, and it says which.
//   · both ok                    → 'saved'.
// 'saved' still requires both, so the green tick never over-claims.
//
// ── READ-ONLY ROLES ARE NOT OFFERED THE BUTTON (2026-08-19) ──────────────────
// Omar, on the first real Reviewer login: *"the only thing about the Reviewer is
// the text that appear when trying to edit a content and by clicking save it says
// 'Not saved: your role is reviewer — you can'"*.
//
// Two defects in one sentence, and the visible one is the smaller:
//
//   1. The message was CUT MID-SENTENCE. The refusal is 79 characters and the
//      span was `maxWidth: 240, whiteSpace: 'nowrap', textOverflow: 'ellipsis'`
//      — about 43 characters — so it stopped at "you can". A refusal that breaks
//      off before saying what the reader can do reads as a rendering fault rather
//      than an answer. Fixed below by letting it WRAP.
//
//   2. He should never have been able to press Save at all. `readOnly` was
//      computed in app.jsx and published as `window.dynamoReadOnly`, and this
//      component — THE save control, rendered by five surfaces — never read it.
//      A `reviewer` holds exactly one capability, `preview:build`; every save
//      they attempt is refused by the gateway. So the button was a false
//      affordance: it invited work that could not land
//      (`feedback_no_false_affordance_toggles`, `feedback_honest_gates_over_standins`).
//
// The guard goes HERE, inside the one shared component, not at the five call
// sites — a sixth surface added next month is covered without anyone remembering
// (`feedback_guard_by_default_not_by_call_site`). It is also the fix for the
// class rather than the instance: the previous round fixed Preview for a reviewer
// and the banner wording, and left every Save button live
// (`feedback_guard_the_invariant_not_the_list`).
//
// `window.dynamoReadOnly` and NOT a role name, for the reason preview-modal.jsx
// records: a `language_reviewer` HOLDS `localisation:write`, so they must take
// the normal path.
//
// ★ AND `dynamoReadOnly` IS THE RIGHT PREDICATE **HERE** AND NOWHERE ELSE BY
// DEFAULT. app.jsx defines it as `!(course:write || localisation:write)` — i.e.
// out of exactly the two capabilities the draft PUT accepts. That makes it
// precisely right for THIS button and quietly wrong for any control whose route
// needs a different capability.
//
// Do not read the Build button at surface-export.jsx:2084 as the pattern to copy:
// it gates on `dynamoReadOnly` while `POST /export` requires `export:build`, so a
// `language_reviewer` — read-only FALSE, `export:build` absent — is offered an
// enabled Build. Correct for a `reviewer` by coincidence, not by construction.
// Filed 2026-08-19 in wiki/findings/2026-08-19-every-write-control-in-the-frontend.md.
//
// So: a control gates on `window.dynamoCan('<the capability its route requires>')`.
// This one gates on `dynamoReadOnly` because here the two are the same statement.
function DraftSaveButton({ dirtySignal, title, idleLabel = null, alsoSave = null }) {
  const [state, setState] = React.useState('idle');   // idle | saving | saved | partial | error
  const [error, setError] = React.useState(null);
  // Read at render, like the Export screen's Build button: app.jsx assigns
  // `window.dynamoReadOnly` in its own body, which runs before any child renders,
  // so this is the settled value for the pass — not a stale mirror.
  const readOnly = !!window.dynamoReadOnly;
  const firstSignal = React.useRef(dirtySignal);
  React.useEffect(() => {
    if (dirtySignal === firstSignal.current) return;
    firstSignal.current = dirtySignal;
    setState(s => (s === 'saved' || s === 'error' || s === 'partial' ? 'idle' : s));
  }, [dirtySignal]);
  const run = async () => {
    if (state === 'saving') return;
    // Belt and braces beside the `disabled` prop below. A `disabled` attribute is
    // a presentation choice a later refactor can drop; this is the control
    // (`feedback_verify_the_control_not_just_the_pipeline`).
    if (readOnly) return;
    setState('saving'); setError(null);
    try {
      const save = window.dynamoSaveDraftToServer;
      // Concurrent, not sequential: different resources (the course row and the
      // draft), no ordering dependency between them, and neither may block the other.
      const [pre, r] = await Promise.all([
        alsoSave
          ? Promise.resolve().then(alsoSave).catch(
              e => ({ ok: false, message: (e && e.message) || 'failed' }))
          : Promise.resolve({ ok: true }),
        save ? save() : Promise.resolve({ ok: false, message: 'save unavailable' }),
      ]);
      if (!r || !r.ok) { setState('error'); setError((r && r.message) || 'Save failed'); return; }
      if (!pre || !pre.ok) {
        setState('partial'); setError((pre && pre.message) || 'some details were not updated');
        return;
      }
      setState('saved');
    } catch (e) {
      setState('error'); setError((e && e.message) || 'Save failed');
    }
  };
  return (
    <div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flex: '0 0 auto' }}>
      {state === 'saved' && (
        <span style={{ fontSize: 11.5, color: 'var(--text-faint)', display: 'inline-flex',
          alignItems: 'center', gap: 5 }}><I.Check size={12} />Saved to the server</span>
      )}
      {/* WRAPS. It used to be one clipped line, which is how a 79-character
          refusal reached Omar as "your role is reviewer — you can". The reason to
          give a message room rather than a wider clip: the strings that arrive here
          are not names or ids, where an ellipsis is the right treatment — they are
          the answer to "why is my work not saved", and a truncated reason is
          indistinguishable from a broken screen. `alignItems: 'flex-start'` keeps
          the warning triangle on the first line instead of centring it against a
          two-line block. */}
      {state === 'error' && (
        <span title={error} style={{ display: 'inline-flex', alignItems: 'flex-start', gap: 5,
          fontSize: 11.5, color: 'var(--error-text)', maxWidth: 420, lineHeight: 1.35,
          textAlign: 'left' }}>
          <I.AlertTriangle size={12} style={{ flex: '0 0 auto', marginTop: 1 }} />
          <span>Not saved: {error}</span>
        </span>
      )}
      {/* PARTIAL — the content reached the server, one detail did not. Deliberately
          NOT green and deliberately NOT "Not saved": both would be false. The author
          needs to know their work is safe AND that one field needs another look. */}
      {/* `--warning-text`, the token that EXISTS (index.html:45 light, :95 dark). The
          first version read `--warn-text`, which is defined nowhere, so it fell back
          to `--text-faint` — the same colour as "Saved to the server". A warning
          rendered identically to a success is not a warning
          (`feedback_honest_gates_over_standins`). */}
      {/* Wraps for the same reason as 'error' above — this one names WHICH detail
          did not reach the server, and the name is the whole value of the message. */}
      {state === 'partial' && (
        <span title={error} style={{ display: 'inline-flex', alignItems: 'flex-start', gap: 5,
          fontSize: 11.5, color: 'var(--warning-text)', maxWidth: 420, lineHeight: 1.35,
          textAlign: 'left' }}>
          <I.AlertTriangle size={12} style={{ flex: '0 0 auto', marginTop: 1 }} />
          <span>Saved, except: {error}</span>
        </span>
      )}
      {state !== 'saved' && state !== 'error' && state !== 'partial' && idleLabel && (
        <span style={{ fontSize: 11.5, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
          {idleLabel}
        </span>
      )}
      {/* The read-only case says WHY on hover rather than in a caption beside every
          Save button on five screens — the page already carries the sentence once,
          in `ReadOnlyBanner`, and repeating it per control is the noise Omar removed
          from the preview window (`feedback_a_preview_window_should_not_caption_itself`). */}
      <button className="btn sm" onClick={run} disabled={state === 'saving' || readOnly}
        title={readOnly
          ? 'Your role is reviewer — you can open and read this course, but not save changes'
          : (title || 'Write your changes to the server, so they survive a cleared browser and can be opened on another machine')}>
        {state === 'saving' ? 'Saving…' : 'Save'}
      </button>
    </div>
  );
}

// ── AiBadge ─────────────────────────────────────────────────────────────────
function AiBadge({ size = 'sm', label = 'AI', title }) {
  return (
    <span className="pill ai" title={title || 'AI-generated · click for provenance'}
          style={{ height: size === 'lg' ? 22 : 18, padding: '0 6px', fontSize: 10.5,
                   fontWeight: 600, letterSpacing: '.02em' }}>
      <I.Sparkle size={10} />{label}
    </span>
  );
}

// ── LayoutTypeChip ──────────────────────────────────────────────────────────
function LayoutTypeChip({ type, size = 'md' }) {
  const meta = (window.LAYOUT_TYPES || []).find(t => t.id === type) || { label: type, icon: 'Hash' };
  const Icon = I[meta.icon] || I.Hash;
  return (
    <span className="chip" style={{ height: size === 'lg' ? 26 : 22 }}>
      <Icon size={12} />{type}
    </span>
  );
}

// ★ SurfaceHeader is declared BELOW this line and exported here anyway — function
// declarations hoist, and keeping one export list is worth more than source order.
// Omitting it was the whole bug on the first attempt: nothing type-checks this
// frontend, so `window.SurfaceHeader` was simply `undefined` and all six surfaces
// died with "Element type is invalid". Caught by rendering them, not by reading
// them (`feedback_the_no_build_frontend_fails_silently`).
Object.assign(window, { StatusPill, ValidationPill, SaveIndicator, AiBadge, LayoutTypeChip,
  DraftSaveButton, SurfaceHeader });

// ── SurfaceHeader — ONE page header, for every surface ───────────────────────
//
// Omar, 2026-08-14: *"For style consistency, apply the same Top Header style that
// has been designed on 'Organisations & roles' and 'Content mapping' … also to the
// 'Assessment' and 'Course Settings' and 'Export'. Overall the rule is not to
// create new style for each section, but to use always one to be adapted to the
// others."*
//
// The style he pointed at existed twice already — hand-copied into
// `surface-org-roles.jsx` and `surface-content-mapping.jsx`, with the two copies
// ALREADY diverged: one capped its sentence at `maxWidth: 680` and wrapped onto a
// second line, the other set `whiteSpace: 'nowrap'` and did not. That divergence is
// the thing he noticed. So this is not four screens gaining a style, it is six
// screens losing five copies of one (`feedback_one_rule_one_place`).
//
// The four other surfaces each had their own idea of a header: Assessments a 52px
// bar with a 14px title, Course settings a bare sentence with no title at all,
// Export a 20px `<h1>` inside the scrolling body, Localisation nothing. None of
// them said what screen you were on in the same voice.
//
//   title        the screen's name, exactly as the left navigation says it
//   description  ONE sentence, on ONE line — Omar asked for that specifically.
//                `nowrap` rather than a max-width: a sentence that wraps sits at a
//                different height on every screen, which is what made the two
//                existing copies look like two designs.
//   children     the screen's own actions, right-aligned — buttons, a save door, a
//                tab switcher. Anything, so no surface needs a second header to
//                hold something this one did not anticipate.
function SurfaceHeader({ title, description, children }) {
  return (
    <header style={{
      flex: '0 0 auto',
      padding: '20px 24px 16px', borderBottom: '1px solid var(--border)',
      background: 'var(--surface)', display: 'flex', alignItems: 'flex-end', gap: 14,
    }}>
      <div style={{ flex: 1, minWidth: 0 }}>
        <h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>{title}</h2>
        {description ? (
          <p style={{ margin: '4px 0 0', fontSize: 12.5, color: 'var(--text-muted)',
            whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
            {description}
          </p>
        ) : null}
      </div>
      {children}
    </header>
  );
}
