/* app/set-pipeline.jsx — Settings pages: Stages + Labels.
   ============================================================================================
   OWNERSHIP: this file and app/set-pipeline.css are owned by ONE agent. Nothing else in the repo needs
   to change to build these pages — the <script>/<link> tags and the `?v=` bumps are already wired,
   and the router finds a page purely by its key in the map below.

   CONTRACT
     - Read docs/SETTINGS-REFERENCE.md for what each page must contain, then the root CLAUDE.md.
     - Build every page out of `window.E8Set` primitives. Do not hand-roll a row, card or control;
       if a primitive is missing, say so rather than inventing a local one — dimensional drift
       between pages is the single most visible defect on this surface.
     - All CSS goes in app/set-pipeline.css under a `.e8-set-pipeline-*` prefix. Never edit app/set-core.css.
     - ZERO inline `style={{…}}` touching fontSize/color/display/gap/flex/margin/padding: a new
       file's inline-style budget is 0 and one such object fails `npm run lint`.
     - Colours are `--ui-*` tokens or `color-mix()` on them. Text sizes are `--ui-text-*`
       (11.5 / 12.5 / 13.5 / 15 / 18 / 24 / 28 — there is no 14px). Icons are `--ui-icon-*`.
   ============================================================================================

   WHAT IS REAL HERE, AND WHAT IT WRITES

   STAGES is a second editor over the SAME pipeline store the legacy `#/stages` screen owns:
   `window.E8DATA.stagePipelines`, persisted at `e8-stage-pipelines-v1`, with the job funnel
   (`E8DATA.pipelineStages`) rebuilt through `window.e8BuildPipelineStages` and the app told via the
   `e8-stages-changed` window event. Every section on the page is one real pipeline read out of that
   store — nothing here is a fixture. Reordering the Candidate pipeline visibly changes the funnel
   on the jobs list, so a drag on this page is a real edit, not a preview. This page also LISTENS
   for that event, so an edit made on the legacy screen (or in another tab) lands here instead of
   leaving two screens quietly disagreeing.

   The two milestone rows at the foot of each card are this page's own setting (`e8-set-stage-
   milestones-v1`): they map a reporting milestone onto whichever stage a workspace has named for
   it. Their DEFAULTS are derived from the pipeline's own stage keys rather than hardcoded, so a
   renamed or reordered pipeline still resolves, and a milestone pointing at a deleted stage falls
   back instead of silently reading as stage one.

   LABELS is seeded from the taxonomies the product already runs on — `E8NotePolicy.humanTypes()`
   (including its `cat` palette slot, so a note label is the colour the composer already paints it),
   the distinct `kind` values actually present on `E8DATA.tasks`, and `E8RejectPolicy.categories`.
   Usage counts are computed from live records. Edits persist to `e8-set-labels-v1`.

   WHY REORDERING IS NOT A LIBRARY AND NOT A MOCK

   Two input paths, one model. `pointerdown` on the grip caches the row MIDPOINTS once, then every
   `pointermove` counts how many midpoints the pointer has passed and moves the row to that index.
   Caching the geometry is the point: the rows reorder under the cursor, so re-measuring mid-drag
   would chase a target that the last move just displaced. Row heights are uniform (one grammar,
   one padding), so fixed slots are exact. The live array lives in a ref, never in the closure, so
   a stale render cannot rewind a drag. Pointer capture keeps the gesture alive off the row and
   guarantees the `pointerup`.

   The keyboard path is not a courtesy alias: ArrowUp/ArrowDown on the focused grip moves the row
   and commits, and both paths announce through one `aria-live` region. A grip that only works with
   a mouse is the failure the reference spec calls out by name.

   MISSING PRIMITIVE, DECLARED RATHER THAN INVENTED: `E8Set.Row` has no leading-handle slot (its
   `icon` slot is the 36px glyph box, which is a different thing and is all-or-nothing per card),
   and there is no menu primitive. The stage row is therefore composed FROM THE SHARED CLASSES —
   `.e8-set-row`, `.e8-set-row-txt`, `.e8-set-row-label`, `.e8-set-row-desc`, `.e8-set-row-ctl` —
   so its padding, ink and dividers are the same object every other page uses; only the grip, the
   dot and the kebab are local. The kebab menu is local for the same reason and is reported.
   ============================================================================================ */

const E8SPipeline = window.E8Set;

/* ---------- shared vocabulary ---------------------------------------------------------------- */

/* Mirrors STAGE_CAT in app/screens-config.jsx: the same three outcomes, the same words. Colour is
   NOT carried here any more — see e8spStageHue below for why an outcome is the wrong thing to paint
   a stage dot with. */
const E8SP_CAT = {
  open: { label: 'In progress' },
  won: { label: 'Won' },
  lost: { label: 'Lost' }
};
const E8SP_CAT_OPTIONS = [
  { value: 'open', label: 'In progress' },
  { value: 'won', label: 'Won' },
  { value: 'lost', label: 'Lost' }
];
const E8SP_PIPE_ICON = {
  candidate: 'person_search', submission: 'send', renewal: 'autorenew', intake: 'lab_profile'
};

const E8SP_PIPE_KEY = 'e8-stage-pipelines-v1';
const E8SP_MILE_KEY = 'e8-set-stage-milestones-v1';
const E8SP_LABEL_KEY = 'e8-set-labels-v1';

/* The 8 categorical slots defined in product.css (with a dark-mode override in app.css). Named so
   a colour picker can say what it is picking instead of "colour 4". */
const E8SP_SWATCHES = [
  { cat: 1, name: 'Indigo' }, { cat: 2, name: 'Blue' }, { cat: 3, name: 'Teal' },
  { cat: 4, name: 'Green' }, { cat: 5, name: 'Amber' }, { cat: 6, name: 'Orange' },
  { cat: 7, name: 'Pink' }, { cat: 8, name: 'Grey' }
];

function e8spCat(id) { return E8SP_CAT[id] || E8SP_CAT.open; }

/* ---- what colour a stage dot is, and why it is not the outcome --------------------------------
   Keyed on the outcome, five of the Candidate pipeline's seven dots came out the identical accent,
   because five of its stages are "in progress" — so the dot column said nothing about exactly the
   rows a recruiter works in, and two open stages could not be told apart anywhere the picker shows
   a dot. The outcome is already carried in words by the row's own meta ("2d SLA" / "Won" / "Lost").

   So: an OPEN stage takes a hue from its POSITION among the open stages, cycling the categorical
   slots product.css already defines and app.css already re-derives for dark mode. Position rather
   than name, so a renamed stage keeps its place in the run and a reorder re-colours the pipeline in
   one consistent direction. Green (slot 4) and Grey (slot 8) are skipped: those two ARE what Won and
   Lost mean here, and a third green dot would read as "won" halfway up the funnel.

   The same function feeds the milestone selects, so the dot in "Counts as submitted" is the dot on
   the row it points at — the ornament identifies a stage instead of restating its category. */
const E8SP_OPEN_HUES = [1, 2, 3, 5, 6, 7];
const E8SP_HUE_NONE = { cls: 'is-lost', dot: 'var(--ui-text-tertiary)' };
function e8spStageHue(stages, index) {
  const list = stages || [];
  if (index < 0 || index >= list.length) return E8SP_HUE_NONE;
  const category = (list[index] || {}).category;
  if (category === 'won') return { cls: 'is-won', dot: 'var(--ui-success-dot)' };
  if (category === 'lost') return E8SP_HUE_NONE;
  let rank = 0;
  for (let i = 0; i < index; i += 1) {
    if (((list[i] || {}).category || 'open') === 'open') rank += 1;
  }
  const cat = E8SP_OPEN_HUES[rank % E8SP_OPEN_HUES.length];
  return { cls: 'is-cat-' + cat, dot: 'var(--ui-cat-' + cat + '-text)' };
}
function e8spStageHueByKey(stages, key) {
  return e8spStageHue(stages, (stages || []).findIndex((s) => s.key === key));
}
function e8spReadJSON(key) {
  try { const v = JSON.parse(localStorage.getItem(key)); return v && typeof v === 'object' ? v : null; }
  catch (e) { return null; }
}
function e8spWriteJSON(key, value) {
  try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* private mode: in-memory only */ }
}
function e8spToast(message, action, onAction) {
  if (window.e8ShowToast) window.e8ShowToast(message, action, onAction);
}
function e8spMove(list, from, to) {
  const next = list.slice();
  next.splice(to, 0, next.splice(from, 1)[0]);
  return next;
}
function e8spPlural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }

/* ---------- the pipeline store ----------------------------------------------------------------
   One reader and one writer for the shared store, so the two Stages editors cannot drift. The
   writer does everything the legacy screen's "Save changes" does, in the same order. */
function e8spReadPipes() {
  const list = ((window.E8DATA || {}).stagePipelines) || [];
  try { return JSON.parse(JSON.stringify(list)); } catch (e) { return []; }
}
function e8spApplyPipes(pipes) {
  if (!window.E8DATA) return;
  window.E8DATA.stagePipelines = pipes;
  if (window.e8BuildPipelineStages) window.E8DATA.pipelineStages = window.e8BuildPipelineStages(pipes);
  e8spWriteJSON(E8SP_PIPE_KEY, pipes);
  window.dispatchEvent(new Event('e8-stages-changed'));
}
function e8spSlug(name, taken) {
  const base = String(name || '').toLowerCase().trim()
    .replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'stage';
  const used = {};
  (taken || []).forEach((s) => { used[s.key] = true; });
  if (!used[base]) return base;
  let n = 2;
  while (used[base + '_' + n]) n += 1;
  return base + '_' + n;
}

/* ---------- milestones -------------------------------------------------------------------------
   The reference maps two product milestones onto whatever the workspace called its stages. Ours are
   the two the ATS actually reports on. Defaults are DERIVED: each milestone carries an ordered list
   of patterns and the first one that matches a stage key or name wins, so a pipeline with different
   names still resolves to something sensible instead of to stage one. */
const E8SP_MILESTONES = [
  {
    id: 'submitted',
    label: 'Counts as submitted',
    desc: (pipe) => 'The stage at which a ' + pipe.object.toLowerCase()
      + ' is treated as sent to the client. Submission volume and time-to-submit are measured from here.',
    /* Deliberately narrow first: an earlier draft matched a bare /client/ and resolved the
       Submission pipeline to "Client review" instead of "Submitted", which is a later moment. */
    patterns: [/client[_ ]?submission|submitted[_ ]?to[_ ]?client/i, /submission|submitted|sent/i, /review/i],
    fallback: (stages) => Math.min(1, stages.length - 1)
  },
  {
    id: 'interview',
    label: 'Counts as interviewing',
    desc: (pipe) => 'The stage that starts the interview clock for this pipeline. Reaching it marks a '
      + pipe.object.toLowerCase() + ' as in play on the pipeline report.',
    /* `approved` outranks `qualif` so the intake pipeline's two milestones land on two different
       stages; qualifying is where "counts as submitted" already falls back to. */
    patterns: [/interview/i, /negotiat/i, /approved/i, /qualif/i],
    fallback: (stages) => {
      const open = stages.filter((s) => s.category === 'open');
      const last = open.length ? open[open.length - 1] : stages[0];
      return stages.indexOf(last);
    }
  }
];
function e8spDefaultMile(pipe, milestone) {
  const stages = pipe.stages || [];
  if (!stages.length) return '';
  for (let i = 0; i < milestone.patterns.length; i += 1) {
    const re = milestone.patterns[i];
    const hit = stages.find((s) => re.test(s.key || '') || re.test(s.name || ''));
    if (hit) return hit.key;
  }
  const idx = Math.max(0, Math.min(stages.length - 1, milestone.fallback(stages)));
  return stages[idx].key;
}
function e8spLoadMiles(pipe) {
  const saved = (e8spReadJSON(E8SP_MILE_KEY) || {})[pipe.id] || {};
  const out = {};
  E8SP_MILESTONES.forEach((m) => { out[m.id] = typeof saved[m.id] === 'string' ? saved[m.id] : ''; });
  return out;
}
/* A milestone pointing at a stage that has since been deleted resolves back to the derived default
   rather than to whatever `<select>` shows first — the two must never disagree. */
function e8spMileValue(pipe, miles, milestone) {
  const stages = pipe.stages || [];
  const saved = miles[milestone.id];
  if (saved && stages.some((s) => s.key === saved)) return saved;
  return e8spDefaultMile(pipe, milestone);
}

/* ---------- the kebab menu ----------------------------------------------------------------------
   `position: fixed` from the button's measured rect, because `.e8-set-card` is `overflow: hidden`
   and an absolutely-positioned menu on the last row of a card would be clipped in half. Fixed also
   means the menu must close when the world moves under it — scroll, resize, outside pointer, Esc —
   and Esc returns focus to the button it came from. */
function E8SPMenu({ label, items }) {
  const { Icon } = E8SPipeline;
  const [open, setOpen] = React.useState(false);
  const [pos, setPos] = React.useState({ top: 0, left: 0 });
  const btnRef = React.useRef(null);
  const menuRef = React.useRef(null);

  React.useEffect(() => {
    if (!open) return undefined;
    const close = () => setOpen(false);
    const onDown = (e) => {
      if (btnRef.current && btnRef.current.contains(e.target)) return;
      if (menuRef.current && menuRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => {
      if (e.key !== 'Escape') return;
      setOpen(false);
      if (btnRef.current) btnRef.current.focus();
    };
    document.addEventListener('pointerdown', onDown, true);
    document.addEventListener('keydown', onKey);
    window.addEventListener('scroll', close, true);
    window.addEventListener('resize', close);
    return () => {
      document.removeEventListener('pointerdown', onDown, true);
      document.removeEventListener('keydown', onKey);
      window.removeEventListener('scroll', close, true);
      window.removeEventListener('resize', close);
    };
  }, [open]);

  /* Keyboard users land on the first enabled item; without this the menu opens into nothing. */
  React.useEffect(() => {
    if (!open || !menuRef.current) return;
    const first = menuRef.current.querySelector('button:not([disabled])');
    if (first) first.focus();
  }, [open]);

  /* `role="menu"` PROMISES arrow-key navigation to a screen reader. Tab alone would leave the role
     lying about the widget, so the roving focus is implemented rather than the role removed. */
  const onMenuKey = (e) => {
    if (['ArrowDown', 'ArrowUp', 'Home', 'End'].indexOf(e.key) === -1) return;
    const items = Array.prototype.slice.call(
      menuRef.current.querySelectorAll('button:not([disabled])'));
    if (!items.length) return;
    e.preventDefault();
    const at = items.indexOf(document.activeElement);
    let next = 0;
    if (e.key === 'ArrowDown') next = at < 0 ? 0 : (at + 1) % items.length;
    else if (e.key === 'ArrowUp') next = at <= 0 ? items.length - 1 : at - 1;
    else if (e.key === 'End') next = items.length - 1;
    items[next].focus();
  };

  const toggle = () => {
    if (open) { setOpen(false); return; }
    const el = btnRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const height = items.length * 32 + 10;
    const width = 200;
    const below = (window.innerHeight - r.bottom) > (height + 12);
    setPos({
      top: below ? r.bottom + 6 : Math.max(8, r.top - height - 6),
      left: Math.max(8, Math.min(r.right - width, window.innerWidth - width - 8))
    });
    setOpen(true);
  };

  return (
    <span className="e8-set-pipeline-menuwrap">
      <button type="button" ref={btnRef} className="e8-set-pipeline-kebab" aria-label={label}
        aria-haspopup="menu" aria-expanded={open} onClick={toggle}>
        <Icon name="more_vert" />
      </button>
      {open ? (
        <div ref={menuRef} role="menu" aria-label={label} className="e8-set-pipeline-menu"
          onKeyDown={onMenuKey} style={{ top: pos.top + 'px', left: pos.left + 'px' }}>
          {items.map((it) => (
            <button key={it.label} type="button" role="menuitem" disabled={!!it.disabled}
              className={'e8-set-pipeline-menuitem' + (it.danger ? ' is-danger' : '')}
              onClick={() => {
                setOpen(false);
                if (btnRef.current) btnRef.current.focus();
                it.onClick();
              }}>
              <Icon name={it.icon} />{it.label}
            </button>
          ))}
        </div>
      ) : null}
    </span>
  );
}

/* ---------- W2 · Stages ------------------------------------------------------------------------ */

function E8SPStageCard({ pipe, onDraft, onCommit, announce }) {
  const { Card, CardHead, Row, Btn, Select, Input, Icon } = E8SPipeline;
  const stages = pipe.stages || [];
  const [adding, setAdding] = React.useState(false);
  const [name, setName] = React.useState('');
  const [cat, setCat] = React.useState('open');
  const [dragKey, setDragKey] = React.useState(null);
  const [miles, setMiles] = React.useState(() => e8spLoadMiles(pipe));
  const dragRef = React.useRef(null);
  const formRef = React.useRef(null);

  React.useEffect(() => {
    if (!adding || !formRef.current) return;
    const el = formRef.current.querySelector('input');
    if (el) el.focus();
  }, [adding]);

  const say = (stage, index, total) => announce('“' + stage.name + '” moved to position '
    + (index + 1) + ' of ' + total + ' in ' + pipe.label + '.');

  const moveTo = (from, to) => {
    if (to < 0 || to >= stages.length || to === from) return;
    const next = e8spMove(stages, from, to);
    onCommit(next);
    say(stages[from], to, next.length);
  };

  /* ---- pointer drag. Geometry is cached once; the live array lives in the ref. ----
     TWO DECISIONS, ONE OF WHICH IS A BUG FIX AND ONE OF WHICH IS FEEL. Stated apart because they
     are easy to confuse and only the first one is load-bearing.

     (a) THE THRESHOLD IS THE BOUNDARY BETWEEN TWO SLOT CENTRES, NOT A SLOT CENTRE. The obvious
     version counts how many centres the pointer has passed — which makes the grabbed row's OWN
     centre a threshold, so one pixel of travel swaps it with a neighbour and the list twitches
     under the cursor. Measured: grabbing "Client submission" and moving 1px swapped it with
     "Interview" before the user had moved a millimetre. With boundaries, a swap costs half a row
     of travel, which is where a hand expects it. Boundaries come from the MEASURED centres rather
     than an assumed uniform height, because a stage with no description is genuinely shorter.

     (b) `grab` tracks the pointer as an OFFSET from the grabbed row's centre, so the thing tested
     against the boundary is the row's virtual centre rather than the bare pointer. This does not
     prevent a jump — a boundary band is exactly one row tall, so any grab point inside the row
     already resolves to that row's own index — it removes the skew between where you took hold of
     the row and where it decides it has arrived. Grab a row by its top edge without this and every
     swap fires up to half a row early. */
  const onGripDown = (e, index) => {
    if (e.pointerType === 'mouse' && e.button !== 0) return;
    if (stages.length < 2) return;
    const grip = e.currentTarget;
    const card = grip.closest('.e8-set-card');
    const rows = card ? Array.prototype.slice.call(card.querySelectorAll('.e8-set-pipeline-stage')) : [];
    if (rows.length !== stages.length) return;
    const centres = rows.map((r) => {
      const b = r.getBoundingClientRect();
      return b.top + (b.height / 2);
    });
    const bounds = [];
    for (let i = 0; i < centres.length - 1; i += 1) bounds.push((centres[i] + centres[i + 1]) / 2);
    try { grip.setPointerCapture(e.pointerId); } catch (err) { /* capture is an optimisation */ }
    dragRef.current = {
      id: e.pointerId, list: stages.slice(), cur: index, bounds: bounds,
      grab: e.clientY - centres[index], moved: false
    };
    setDragKey(stages[index].key);
    /* preventDefault stops the text selection and the touch scroll; focus is then set by hand so
       the grip a user just grabbed is also the grip the arrow keys will move. */
    e.preventDefault();
    grip.focus();
  };
  const onGripMove = (e) => {
    const d = dragRef.current;
    if (!d || d.id !== e.pointerId) return;
    const y = e.clientY - d.grab;
    let to = 0;
    for (let i = 0; i < d.bounds.length; i += 1) { if (y > d.bounds[i]) to = i + 1; }
    if (to > d.list.length - 1) to = d.list.length - 1;
    if (to === d.cur) return;
    d.list = e8spMove(d.list, d.cur, to);
    d.cur = to;
    d.moved = true;
    onDraft(d.list.slice());
  };
  /* Serves pointerup, pointercancel and lostpointercapture; the ref is nulled first so the second
     event of a normal release is a no-op. */
  const endDrag = (e) => {
    const d = dragRef.current;
    if (!d) return;
    if (e && e.pointerId != null && e.pointerId !== d.id) return;
    dragRef.current = null;
    setDragKey(null);
    if (!d.moved) return;
    onCommit(d.list.slice());
    say(d.list[d.cur], d.cur, d.list.length);
  };

  const removeStage = (index) => {
    const victim = stages[index];
    if (stages.length < 2) { e8spToast('A pipeline needs at least one stage'); return; }
    const prev = stages.slice();
    onCommit(prev.filter((s, i) => i !== index));
    announce('“' + victim.name + '” removed from ' + pipe.label + '.');
    e8spToast('“' + victim.name + '” removed from ' + pipe.label, 'Undo', () => onCommit(prev));
  };

  const cancelAdd = () => { setAdding(false); setName(''); setCat('open'); };
  const submitAdd = () => {
    const clean = name.trim();
    if (!clean) return;
    const stage = {
      name: clean, key: e8spSlug(clean, stages), abbrev: clean.slice(0, 6), category: cat,
      slaDays: cat === 'open' ? 3 : 0, conversion: cat === 'open' ? 50 : null,
      count: 0, automations: [], description: ''
    };
    onCommit(stages.concat([stage]));
    announce('“' + clean + '” added to ' + pipe.label + ' at position ' + (stages.length + 1) + '.');
    e8spToast('“' + clean + '” added to ' + pipe.label);
    cancelAdd();
  };

  const setMile = (milestone, value) => {
    const next = Object.assign({}, miles, { [milestone.id]: value });
    setMiles(next);
    const all = e8spReadJSON(E8SP_MILE_KEY) || {};
    all[pipe.id] = next;
    e8spWriteJSON(E8SP_MILE_KEY, all);
    const stage = stages.find((s) => s.key === value);
    e8spToast(milestone.label + ' → “' + (stage ? stage.name : value) + '” for ' + pipe.label);
  };

  const stageOptions = stages.map((s) => ({ value: s.key, label: s.name }));

  return (
    <Card>
      {/* The description is kept to one short sentence on purpose: `.e8-set-cardhead` does not wrap
          at any width (a core gap, reported), so at 390px every word here is set in a ~132px column
          and the header grows to 171px. Shortening the copy is not the fix — it is what this file
          can do about it without hand-rolling a second copy of a shared primitive. */}
      <CardHead icon={E8SP_PIPE_ICON[pipe.id] || 'account_tree'} title={pipe.object + ' stages'}
        desc="Drag a row by its grip to reorder it, or focus a grip and use the arrow keys."
        action={<Btn icon="add" onClick={() => setAdding(true)}>Add stage</Btn>} />

      {stages.map((s, i) => {
        const hue = e8spStageHue(stages, i);
        return (
          <div key={s.key}
            className={'e8-set-row e8-set-pipeline-stage' + (dragKey === s.key ? ' is-dragging' : '')}>
            <span className="e8-set-pipeline-lead">
              <button type="button" className="e8-set-pipeline-grip"
                aria-label={'Reorder ' + s.name + '. Position ' + (i + 1) + ' of ' + stages.length
                  + '. Press the up or down arrow key to move it.'}
                onPointerDown={(e) => onGripDown(e, i)} onPointerMove={onGripMove}
                onPointerUp={endDrag} onPointerCancel={endDrag} onLostPointerCapture={endDrag}
                onKeyDown={(e) => {
                  if (e.key === 'ArrowUp') { e.preventDefault(); moveTo(i, i - 1); }
                  else if (e.key === 'ArrowDown') { e.preventDefault(); moveTo(i, i + 1); }
                  else if (e.key === 'Home') { e.preventDefault(); moveTo(i, 0); }
                  else if (e.key === 'End') { e.preventDefault(); moveTo(i, stages.length - 1); }
                }}>
                <Icon name="drag_indicator" />
              </button>
              <span className={'e8-set-pipeline-dot ' + hue.cls} aria-hidden="true" />
            </span>
            <div className="e8-set-row-txt">
              <div className="e8-set-row-label">{s.name}</div>
              {s.description ? <div className="e8-set-row-desc">{s.description}</div> : null}
            </div>
            <div className="e8-set-row-ctl">
              <span className="e8-set-row-meta">
                {s.category === 'open' ? s.slaDays + 'd SLA' : e8spCat(s.category).label}
              </span>
              <E8SPMenu label={'Actions for the ' + s.name + ' stage'} items={[
                { icon: 'arrow_upward', label: 'Move up', disabled: i === 0, onClick: () => moveTo(i, i - 1) },
                { icon: 'arrow_downward', label: 'Move down', disabled: i === stages.length - 1, onClick: () => moveTo(i, i + 1) },
                { icon: 'vertical_align_top', label: 'Move to first', disabled: i === 0, onClick: () => moveTo(i, 0) },
                { icon: 'delete', label: 'Delete stage', danger: true, onClick: () => removeStage(i) }
              ]} />
            </div>
          </div>
        );
      })}

      {adding ? (
        <div ref={formRef} className="e8-set-row e8-set-pipeline-form"
          onKeyDown={(e) => {
            /* Enter submits only from the TEXT FIELD. Handling it for the whole block swallows the
               Enter that activates the focused Cancel button — the container's preventDefault is
               what suppresses the button's own click — so keyboard Cancel added a stage. */
            if (e.key === 'Enter' && e.target && e.target.tagName === 'INPUT') { e.preventDefault(); submitAdd(); }
            else if (e.key === 'Escape') { e.preventDefault(); cancelAdd(); }
          }}>
          <Input size="md" value={name} onChange={setName} placeholder="Stage name"
            ariaLabel={'Name of the new ' + pipe.label + ' stage'} />
          {/* No dot on this one. It picks an OUTCOME, and on this page a coloured dot means a
              stage — see e8spStageHue. An accent dot beside "In progress" would be the one place
              the two vocabularies collide. */}
          <Select value={cat} options={E8SP_CAT_OPTIONS} onChange={setCat}
            ariaLabel="Outcome this stage records" />
          <Btn kind="primary" onClick={submitAdd} disabled={!name.trim()}>Add stage</Btn>
          <Btn kind="quiet" onClick={cancelAdd}>Cancel</Btn>
        </div>
      ) : null}

      {/* Ordinary setting rows, in the SAME card as the stage rows above them — which is what the
          reference asks for and what makes their left edge matter: a stage row leads with a grip
          and a dot, these do not, so untouched their labels start 51px apart inside one border.
          `.e8-set-pipeline-milestone` reproduces that gutter as an empty leading flex item built
          from the very declarations that size the grip. Nothing here re-states the number. */}
      {E8SP_MILESTONES.map((m) => {
        const value = e8spMileValue(pipe, miles, m);
        return (
          <Row key={m.id} label={m.label} desc={m.desc(pipe)} className="e8-set-pipeline-milestone"
            control={<Select value={value} options={stageOptions} onChange={(v) => setMile(m, v)}
              dot={e8spStageHueByKey(stages, value).dot}
              ariaLabel={m.label + ' in ' + pipe.label} />} />
        );
      })}
    </Card>
  );
}

function SetStagesPage() {
  const { Page, Section, Card, Empty } = E8SPipeline;
  const [pipes, setPipes] = React.useState(e8spReadPipes);
  const [live, setLive] = React.useState('');

  /* The legacy `#/stages` screen writes the same store. Listening means an edit made there shows up
     here rather than being overwritten by whatever this page last rendered. */
  React.useEffect(() => {
    const fn = () => setPipes(e8spReadPipes());
    window.addEventListener('e8-stages-changed', fn);
    return () => window.removeEventListener('e8-stages-changed', fn);
  }, []);

  const draft = (pipeId, stages) => {
    setPipes((prev) => prev.map((p) => (p.id === pipeId ? Object.assign({}, p, { stages: stages }) : p)));
  };
  const commit = (pipeId, stages) => {
    const next = pipes.map((p) => (p.id === pipeId ? Object.assign({}, p, { stages: stages }) : p));
    setPipes(next);
    e8spApplyPipes(next);
  };

  return (
    <Page title="Stages"
      subtitle="The pipelines every record moves through. Reordering one here changes the funnel for the whole workspace, straight away.">
      {pipes.length ? pipes.map((pipe) => (
        <Section key={pipe.id} title={pipe.label}
          desc={e8spPlural((pipe.stages || []).length, 'stage', 'stages') + ' · applies to every '
            + pipe.object.toLowerCase() + ' record'
            + (pipe.id === 'candidate' ? '. The funnel on the jobs list is built from this pipeline.' : '.')}>
          <E8SPStageCard pipe={pipe} announce={setLive}
            onDraft={(stages) => draft(pipe.id, stages)}
            onCommit={(stages) => commit(pipe.id, stages)} />
        </Section>
      )) : (
        <Card>
          <Empty icon="account_tree" title="No pipelines in this workspace"
            desc="Stage pipelines load with the dataset. Switch the data mode back to demo or scale to configure them." />
        </Card>
      )}
      <p className="e8-set-pipeline-foot">
        Every change on this page saves as you make it — there is no separate save step. Order,
        outcomes and milestones are stored for this browser and applied to the live pipeline.
      </p>
      <div className="e8-sr-only" role="status" aria-live="polite">{live}</div>
    </Page>
  );
}

/* ---------- W3 · Labels ------------------------------------------------------------------------
   Seeded from the taxonomies the app already runs on. A workspace that empties a section gets the
   INLINE empty state (one quiet line in a card that already carries its own "+ Add" action), never
   the centred block — picking the wrong one of those two is a named failure mode in the spec. */

const E8SP_TASK_KIND = {
  match: { name: 'Match review', cat: 1 },
  send: { name: 'Send & reply', cat: 2 },
  alert: { name: 'Escalation', cat: 7 },
  bench: { name: 'Bench & redeploy', cat: 3 },
  note: { name: 'Note follow-up', cat: 5 },
  general: { name: 'General', cat: 8 }
};
const E8SP_REJECT_CAT = { role: 6, outcome: 2, integrity: 7 };

function e8spSeedNoteLabels() {
  const P = window.E8NotePolicy;
  if (!P || typeof P.humanTypes !== 'function') return [];
  return P.humanTypes().map((t) => ({ id: t.id, name: t.label, cat: t.cat || 8 }));
}
function e8spSeedTaskLabels() {
  const tasks = ((window.E8DATA || {}).tasks) || [];
  const seen = [];
  tasks.forEach((t) => {
    const k = t && t.kind;
    if (k && seen.indexOf(k) === -1) seen.push(k);
  });
  return seen.map((k) => {
    const meta = E8SP_TASK_KIND[k] || { name: k.charAt(0).toUpperCase() + k.slice(1), cat: 8 };
    return { id: k, name: meta.name, cat: meta.cat };
  });
}
function e8spSeedRejectLabels() {
  const cats = ((window.E8RejectPolicy || {}).categories) || ((window.E8DATA || {}).rejectCategories) || [];
  return cats.map((c) => ({
    id: c.id, name: c.label, cat: E8SP_REJECT_CAT[c.id] || 8, reasons: (c.reasons || []).length
  }));
}
function e8spSeedLabels() {
  return { notes: e8spSeedNoteLabels(), tasks: e8spSeedTaskLabels(), rejections: e8spSeedRejectLabels() };
}
/* Saved over seed, by id, so a label the product later adds still arrives with its icon and reason
   count while a user's rename, recolour or deletion is preserved. An empty ARRAY is a real state
   (the user removed them all) and is respected; a missing key falls back to the seed. */
function e8spLoadLabels() {
  const seed = e8spSeedLabels();
  const saved = e8spReadJSON(E8SP_LABEL_KEY);
  if (!saved) return seed;
  const out = {};
  Object.keys(seed).forEach((sec) => {
    if (!Array.isArray(saved[sec])) { out[sec] = seed[sec]; return; }
    const byId = {};
    seed[sec].forEach((s) => { byId[s.id] = s; });
    out[sec] = saved[sec]
      .filter((l) => l && l.id)
      .map((l) => Object.assign({}, byId[l.id] || {}, {
        id: String(l.id), name: String(l.name || ''), cat: Number(l.cat) || 8
      }));
  });
  return out;
}

const E8SP_LABEL_SECTIONS = [
  {
    id: 'notes', title: 'Notes', card: 'Note labels', icon: 'sticky_note_2',
    desc: 'What a recruiter can tag a note with. Seeded from the note taxonomy the composer already writes against.',
    cardDesc: 'Offered in the note composer and used to split Notes from Movement on a record.',
    empty: 'No note labels yet — add one and it appears in the note composer.'
  },
  {
    id: 'tasks', title: 'Tasks', card: 'Task labels', icon: 'task_alt',
    desc: 'How work on the queue is classified. Seeded from the kinds of task actually on the board today.',
    cardDesc: 'Used to group the Today queue and to route a task to the right surface.',
    empty: 'No task labels yet — add one to start grouping the queue.'
  },
  {
    id: 'rejections', title: 'Rejections', card: 'Rejection labels', icon: 'do_not_disturb_on',
    desc: 'Why a candidate was passed on. Seeded from the rejection policy every reject flow records against.',
    cardDesc: 'Chosen when a candidate is rejected; identity concerns stay restricted from search.',
    empty: 'No rejection labels yet — add one so a rejection can be recorded with a reason.'
  }
];

function e8spLabelCounts() {
  const D = window.E8DATA || {};
  const P = window.E8NotePolicy;
  const notes = {};
  if (P && typeof P.normalize === 'function') {
    (D.notes || []).forEach((n) => {
      const id = P.normalize(n);
      notes[id] = (notes[id] || 0) + 1;
    });
  }
  const tasks = {};
  (D.tasks || []).forEach((t) => {
    if (!t || !t.kind || t.state !== 'open') return;
    tasks[t.kind] = (tasks[t.kind] || 0) + 1;
  });
  const rejected = (D.jobs || []).reduce((n, j) => n + (((j || {}).pipeline || {}).rejection || 0), 0);
  return { notes: notes, tasks: tasks, rejected: rejected };
}

function SetLabelsPage() {
  const { Page, Section, Card, CardHead, Row, Btn, Input, Empty } = E8SPipeline;
  const [sets, setSets] = React.useState(e8spLoadLabels);
  const [composer, setComposer] = React.useState(null);
  const [live, setLive] = React.useState('');
  const formRef = React.useRef(null);
  const counts = React.useMemo(e8spLabelCounts, []);

  React.useEffect(() => {
    if (!composer || !formRef.current) return;
    const el = formRef.current.querySelector('input');
    if (el) el.focus();
  }, [composer && composer.sec, composer && composer.id]);

  const commit = (next) => { setSets(next); e8spWriteJSON(E8SP_LABEL_KEY, next); };
  const replace = (sec, list) => commit(Object.assign({}, sets, { [sec]: list }));

  const openAdd = (sec) => setComposer({ sec: sec, id: null, name: '', cat: 2 });
  const openEdit = (sec, label) => setComposer({ sec: sec, id: label.id, name: label.name, cat: label.cat });
  const closeComposer = () => setComposer(null);

  const submitComposer = () => {
    if (!composer) return;
    const clean = composer.name.trim();
    if (!clean) return;
    const list = sets[composer.sec] || [];
    if (composer.id) {
      replace(composer.sec, list.map((l) => (l.id === composer.id
        ? Object.assign({}, l, { name: clean, cat: composer.cat }) : l)));
      setLive('Label saved as “' + clean + '”.');
      e8spToast('Label saved as “' + clean + '”');
    } else {
      const id = 'lb-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
      replace(composer.sec, list.concat([{ id: id, name: clean, cat: composer.cat }]));
      setLive('“' + clean + '” added.');
      e8spToast('“' + clean + '” added');
    }
    closeComposer();
  };
  const removeLabel = (sec, label) => {
    const prev = sets[sec] || [];
    replace(sec, prev.filter((l) => l.id !== label.id));
    setLive('“' + label.name + '” removed.');
    e8spToast('“' + label.name + '” removed', 'Undo', () => replace(sec, prev));
  };

  /* USAGE IS META, NOT A DESCRIPTION. It was a full sentence in the row's description slot, which
     put "Not on a note yet." on eight of the nine Note rows — a whole column of identical text
     doing nothing but making every row two lines tall. The count is a number, so it belongs where
     Stages already puts "2d SLA": right-aligned secondary meta, sized to itself.
     Zero usage returns '' and the row is a clean single line. That is the information — a label
     with no count beside it is one nothing is using, said by absence instead of by nine repeats of
     the same sentence. `title` carries the long form for a hover, where a sentence is affordable. */
  const usage = (sec, label) => {
    if (sec === 'notes') {
      const n = counts.notes[label.id] || 0;
      return n
        ? { meta: e8spPlural(n, 'note', 'notes'), title: 'On ' + e8spPlural(n, 'note', 'notes') + ' in this workspace.' }
        : { meta: '', title: 'Not on a note yet.' };
    }
    if (sec === 'tasks') {
      const n = counts.tasks[label.id] || 0;
      return n
        ? { meta: e8spPlural(n, 'open task', 'open tasks'), title: 'On ' + e8spPlural(n, 'open task', 'open tasks') + ' right now.' }
        : { meta: '', title: 'No open task carries it.' };
    }
    return label.reasons
      ? { meta: e8spPlural(label.reasons, 'reason', 'reasons'), title: e8spPlural(label.reasons, 'reason rolls', 'reasons roll') + ' up to this label.' }
      : { meta: '', title: 'No reasons mapped to this label yet.' };
  };

  const renderComposer = () => (
    <div key="composer" ref={formRef} className="e8-set-row e8-set-pipeline-form"
      onKeyDown={(e) => {
        /* Text field only — see the note on the Stages composer: a blanket Enter handler eats the
           Enter that activates Cancel, and the colour swatches are buttons too. */
        if (e.key === 'Enter' && e.target && e.target.tagName === 'INPUT') { e.preventDefault(); submitComposer(); }
        else if (e.key === 'Escape') { e.preventDefault(); closeComposer(); }
      }}>
      <Input size="md" value={composer.name} placeholder="Label name"
        ariaLabel={composer.id ? 'Rename this label' : 'Name of the new label'}
        onChange={(v) => setComposer(Object.assign({}, composer, { name: v }))} />
      <span className="e8-set-pipeline-swatchrow" role="group" aria-label="Label colour">
        {E8SP_SWATCHES.map((s) => (
          <button key={s.cat} type="button" aria-pressed={composer.cat === s.cat} aria-label={s.name}
            className={'e8-set-pipeline-swatchbtn is-cat-' + s.cat + (composer.cat === s.cat ? ' is-on' : '')}
            onClick={() => setComposer(Object.assign({}, composer, { cat: s.cat }))} />
        ))}
      </span>
      <Btn kind="primary" onClick={submitComposer} disabled={!composer.name.trim()}>
        {composer.id ? 'Save label' : 'Add label'}
      </Btn>
      <Btn kind="quiet" onClick={closeComposer}>Cancel</Btn>
    </div>
  );

  return (
    <Page title="Labels">
      {E8SP_LABEL_SECTIONS.map((sec) => {
        const list = sets[sec.id] || [];
        const composing = !!composer && composer.sec === sec.id;
        /* One live sentence per section where the dataset can supply one, on the set-notify
           pattern: it degrades to the static copy rather than to "0 candidates". */
        const liveNote = sec.id === 'rejections' && counts.rejected
          ? ' ' + e8spPlural(counts.rejected, 'candidate sits', 'candidates sit') + ' in a rejection stage across the job board.'
          : '';
        return (
          <Section key={sec.id} title={sec.title} desc={sec.desc + liveNote}>
            <Card>
              <CardHead icon={sec.icon} title={sec.card} desc={sec.cardDesc}
                action={<Btn icon="add" onClick={() => openAdd(sec.id)}>Add label</Btn>} />
              {list.map((l) => {
                if (composing && composer.id === l.id) return renderComposer();
                const use = usage(sec.id, l);
                return (
                  <Row key={l.id} className="e8-set-pipeline-labelrow"
                    label={(
                      <span className="e8-set-pipeline-labelname" title={use.title}>
                        <span className={'e8-set-pipeline-swatch is-cat-' + l.cat} aria-hidden="true" />
                        {l.name}
                      </span>
                    )}
                    control={(
                      <>
                        {use.meta ? <span className="e8-set-row-meta">{use.meta}</span> : null}
                        <E8SPMenu label={'Actions for the ' + l.name + ' label'} items={[
                          { icon: 'edit', label: 'Rename or recolour', onClick: () => openEdit(sec.id, l) },
                          { icon: 'delete', label: 'Remove label', danger: true, onClick: () => removeLabel(sec.id, l) }
                        ]} />
                      </>
                    )} />
                );
              })}
              {composing && !composer.id ? renderComposer() : null}
              {!list.length && !composing ? <Empty inline title={sec.empty} /> : null}
            </Card>
          </Section>
        );
      })}
      <p className="e8-set-pipeline-foot">
        Labels are saved for this workspace on this browser. Removing one leaves the records that
        already carry it untouched — it stops being offered, it is not erased from history.
      </p>
      <div className="e8-sr-only" role="status" aria-live="polite">{live}</div>
    </Page>
  );
}

window.E8SetPages = window.E8SetPages || {};
Object.assign(window.E8SetPages, { stages: SetStagesPage, labels: SetLabelsPage });
