/* app/set-people.jsx — Settings pages: Users + Teams + Security.
   ============================================================================================
   OWNERSHIP: this file and app/set-people.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-people.css under a `.e8-set-people-*` 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 is not)

   THE ROSTER IS DERIVED FROM THE LIVE DATASET, NOT INVENTED. Three sources, first-writer-wins so
   the strongest signal keeps the field:
     1. `E8DATA.reps`      — id, display name, initials, job title, and the `departed` flag that
                             produces the "Deactivated" status pill (Chris Vale).
     2. `e8Personas()`     — the three switchable personas; supplies a role for Dana Whitfield and
                             Renee Castellanos, who are not on the rep roster.
     3. `E8DATA.clients[].csm` — everyone who owns an account. These people have NO stated role
                             anywhere in the dataset, which is exactly why the Roles cell renders
                             the tertiary-ink `Select roles` placeholder for them. The empty state
                             is DERIVED, not staged.

   THE PRISM JOIN. The live app runs on data-prism.js, which renames people through PEOPLE_PAIRS,
   so `clients[].csm` reads "Andrew Lance" where the rep roster says "Jess Alvarez" — the same
   person, twice, under two names. Every name here is canonicalised through
   `E8DATA.prismMeta.personAliasesBack` before it is used as a key, so the roster de-duplicates
   correctly under prism AND under plain demo data. Without it this page would list 5 phantom users.

   "JOINED" IS AN INFERENCE AND THE PAGE SAYS SO. ELEV8 records no account-creation date for
   internal staff. The column shows the earliest month a person is attributable to a placement —
   `engagements[].attribution` (by rep id or by name), `repByCid` ownership, and internal
   `engagements[].approvals` — and renders an em dash when there is no such record. A footnote
   under the table states this in the UI rather than only in this comment.

   WHAT IS THIS PAGE'S OWN STATE. Role assignments, removals, pending invitations, custom roles,
   the MFA switches and teams have no consumer anywhere else in ELEV8, so they are backed by a real
   persisted store (`e8-set-people-v1`) rather than faked. Two of them cross a page boundary for
   real: a role added on Security appears immediately in the Users roles select, and the Security
   card's "N roles configured" is that same list's length. Nothing here writes to E8Store or mutates
   E8DATA — the seed stays immutable and every destructive gesture is reversible.

   ONE SAFETY MODEL FOR ALL THREE PAGES. Removing a person, deleting a team and dropping a team
   member all raise the SHELL's toast with an Undo (`window.e8ShowToast`), which is the same call
   the Domains page makes for the structurally identical gesture — so the wording, the affordance
   and the ⌘Z chord are one implementation rather than three lookalikes. Users additionally keeps a
   persistent banner, because a removal survives a reload and a toast does not; it says "Removed"
   and offers "Undo", matching the control that raised it.
   ============================================================================================ */

const E8SPeople = window.E8Set;

/* ---------- the page store ---------------------------------------------------------------------
   One localStorage key, one custom event, so all three pages stay in sync without either owning
   the other — the same shape app/set-prefs.jsx uses. Every access is guarded: a blocked or full
   localStorage degrades to in-memory state rather than taking the screen down. */
const E8SPP_KEY = 'e8-set-people-v1';
const E8SPP_EVENT = 'e8-set-people-changed';

const E8SPP_DEFAULTS = {
  roles: {},        /* userId -> roleId. '' is an explicit clear, which is why this is keyed and
                       not merged into the derived roster: absent means "use the derived role". */
  removed: [],      /* userIds hidden from the roster; reversible from the banner. */
  invites: [],      /* { id, email, role, at } */
  customRoles: [],  /* { id, label, cat } — added on Security, assignable on Users. */
  rosterOpen: true,
  mfa: false,
  mfaScope: 'all',
  teams: [],        /* { id, name, leadId, memberIds, at } */
};

function e8sppRead() {
  try {
    const raw = window.localStorage.getItem(E8SPP_KEY);
    const parsed = raw ? JSON.parse(raw) : null;
    return parsed && typeof parsed === 'object' ? { ...E8SPP_DEFAULTS, ...parsed } : { ...E8SPP_DEFAULTS };
  } catch (e) {
    return { ...E8SPP_DEFAULTS };
  }
}

/* ONLY ANNOUNCE A WRITE THAT LANDED. The event is what makes the three pages agree, and the
   listener in useSetPeople answers it by RE-READING localStorage. Dispatch it after a write that
   threw - a full quota, or Safari private mode - and that re-read returns the value from before the
   edit, which the listener then hands back as state: the control the user just moved snaps back,
   and the store's promise of degrading to in-memory becomes the opposite of what it does. A failed
   write leaves this tab's in-memory state authoritative, which is the degrade; the other pages have
   nothing to sync FROM in that case anyway, because nothing was stored. */
function e8sppWrite(next) {
  let stored = true;
  try { window.localStorage.setItem(E8SPP_KEY, JSON.stringify(next)); } catch (e) { stored = false; /* quota / private mode */ }
  if (stored) window.dispatchEvent(new CustomEvent(E8SPP_EVENT));
  return next;
}

/* The shell's toast, reached exactly the way the other six settings modules reach it. It takes
   (message, action, onAction) and app/main.jsx appends the platform undo chord itself, so passing
   'Undo' here is what renders the "Undo · ⌘Z" affordance the rest of this surface shows. Never
   spell the chord out at a call site — it is ⌘Z on a Mac and Ctrl+Z everywhere else, and only
   main.jsx knows which. */
function e8sppToast(message, action, onAction) {
  if (window.e8ShowToast) window.e8ShowToast(message, action, onAction);
}

/* The write is dispatched synchronously, so the listener below fires inside the same click. It
   compares before setting state — otherwise every patch would cost a second render, and a setState
   raised from inside another component's update is exactly the shape React warns about.

   `patch` takes an object OR an updater `(state) => delta`. The updater form exists for the undo
   callbacks: a toast's Undo runs an arbitrary time after the click that raised it, so a delta built
   from the render-time closure would clobber whatever happened in between. Reading `ref.current`
   at invocation time is what makes an undo composable with the actions that follow it. */
function useSetPeople() {
  const [state, setState] = React.useState(e8sppRead);
  const ref = React.useRef(state);
  const patch = React.useCallback((next) => {
    const delta = typeof next === 'function' ? next(ref.current) : next;
    const merged = { ...ref.current, ...delta };
    ref.current = merged;
    setState(merged);
    e8sppWrite(merged);
  }, []);
  React.useEffect(() => {
    const sync = () => {
      const fresh = e8sppRead();
      if (JSON.stringify(fresh) === JSON.stringify(ref.current)) return;
      ref.current = fresh;
      setState(fresh);
    };
    window.addEventListener(E8SPP_EVENT, sync);
    window.addEventListener('storage', sync);
    return () => { window.removeEventListener(E8SPP_EVENT, sync); window.removeEventListener('storage', sync); };
  }, []);
  return [state, patch];
}

/* ---------- roles ------------------------------------------------------------------------------
   ELEV8 has no permission model, so this is the settings surface's own vocabulary. `cat` indexes
   the shared `--ui-cat-N-*` token pair, which is how a role token gets a colour that inverts in
   dark mode without a single hardcoded value. Security appends to this list; Users renders it. */
const E8SPP_BASE_ROLES = [
  { id: 'admin', label: 'Workspace admin', cat: 1 },
  { id: 'recruiter', label: 'Recruiter', cat: 2 },
  { id: 'am', label: 'Account manager', cat: 3 },
  { id: 'cs', label: 'Client success', cat: 4 },
  { id: 'ops', label: 'Operations', cat: 5 },
  { id: 'readonly', label: 'Read only', cat: 8 },
];
const E8SPP_PERSONA_ROLE = { recruiter: 'recruiter', csm: 'cs', ops: 'ops' };

function e8sppRoleList(state) {
  return E8SPP_BASE_ROLES.concat(state && Array.isArray(state.customRoles) ? state.customRoles : []);
}

function e8sppRoleFromTitle(title) {
  const t = String(title || '').toLowerCase();
  if (!t) return '';
  if (t.indexOf('account manager') >= 0) return 'am';
  if (t.indexOf('recruit') >= 0) return 'recruiter';
  if (t.indexOf('ops') >= 0 || t.indexOf('operations') >= 0) return 'ops';
  if (t.indexOf('success') >= 0 || t.indexOf('sales') >= 0) return 'cs';
  return '';
}

/* ---------- people ------------------------------------------------------------------------------ */

/* Resolve a live (prism-renamed) display name back to the roster name for the same human. Under
   plain demo data the map is absent and this is the identity function. */
function e8sppCanon(name) {
  const meta = (window.E8DATA || {}).prismMeta || {};
  const back = meta.personAliasesBack || {};
  const n = String(name == null ? '' : name).trim();
  return back[n] || n;
}

function e8sppDomain() {
  const org = ((window.E8DATA || {}).user || {}).org || '';
  const first = String(org).split(/[\s·-]+/)[0] || 'elev8';
  return (first.toLowerCase().replace(/[^a-z0-9]/g, '') || 'elev8') + '.com';
}

/* Addresses are DERIVED from the real name plus the workspace domain in E8DATA.user.org — the
   dataset carries no email for internal staff. Stated in the report and in the page footnote. */
function e8sppEmail(name) {
  const parts = String(name).toLowerCase().replace(/[^a-z0-9\s]/g, '').trim().split(/\s+/).filter(Boolean);
  return (parts.join('.') || 'user') + '@' + e8sppDomain();
}

function e8sppInitialsOf(name) {
  const parts = String(name).trim().split(/\s+/).filter(Boolean);
  const a = (parts[0] || '?').charAt(0);
  const b = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '';
  return (a + b).toUpperCase();
}

function e8sppNameFromEmail(email) {
  const local = String(email || '').split('@')[0] || 'invited';
  return local.split(/[._-]+/).filter(Boolean)
    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
    .join(' ') || local;
}

/* Deterministic 1..8 so a person keeps the same avatar tint across reloads and across pages. */
function e8sppTone(seed) {
  const s = String(seed);
  let h = 0;
  for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) % 100003;
  return (h % 8) + 1;
}

function e8sppStamp(value) {
  const t = Date.parse(String(value == null ? '' : value));
  return Number.isFinite(t) ? t : null;
}

function e8sppMonth(ts) {
  if (!Number.isFinite(ts)) return '—';
  try { return new Date(ts).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); } catch (e) { return '—'; }
}

/* The earliest month this person is attributable to a placement. Three real signals, all off
   window.E8DATA: the commission attribution split, the single-owner map, and the internal
   approver named on the engagement. Returns null when the dataset says nothing about them. */
function e8sppJoinedFor(person) {
  const D = window.E8DATA || {};
  const byCid = D.repByCid || {};
  let best = null;
  (D.engagements || []).forEach((eng) => {
    const t = e8sppStamp(eng.start);
    if (t === null) return;
    let hit = !!(person.repId && byCid[eng.cid] === person.repId);
    (eng.attribution || []).forEach((a) => {
      if (a.id && person.repId && a.id === person.repId) hit = true;
      if (a.name && e8sppCanon(a.name) === person.name) hit = true;
    });
    const ap = eng.approvals || {};
    Object.keys(ap).forEach((k) => {
      const v = ap[k];
      if (v && v.org === 'internal' && e8sppCanon(v.name) === person.name) hit = true;
    });
    if (hit && (best === null || t < best)) best = t;
  });
  return best;
}

function e8sppMeName() {
  const D = window.E8DATA || {};
  const p = typeof window.e8ActivePersona === 'function' ? window.e8ActivePersona() : null;
  return e8sppCanon((p && p.name) || (D.user || {}).name || '');
}

/* First writer wins per FIELD, so the rep roster's initials and job title survive a later, thinner
   mention of the same person as an account owner. */
function e8sppRoster() {
  const D = window.E8DATA || {};
  const order = [];
  const map = new Map();
  const add = (rawName, patch) => {
    const name = e8sppCanon(rawName);
    if (!name || name === 'Unassigned' || name === '—') return;
    let cur = map.get(name);
    if (!cur) { cur = { name: name }; map.set(name, cur); order.push(name); }
    Object.keys(patch || {}).forEach((k) => {
      const v = patch[k];
      if (v === undefined || v === null || v === '') return;
      if (cur[k] === undefined || cur[k] === '') cur[k] = v;
    });
  };

  (D.reps || []).forEach((r) => add(r.name, {
    repId: r.id,
    initials: r.initials,
    title: r.role,
    seatRole: e8sppRoleFromTitle(r.role),
    departed: r.departed ? true : undefined,
    departedOn: r.departedOn,
  }));
  (typeof window.e8Personas === 'function' ? window.e8Personas() : []).forEach((p) => add(p.name, {
    title: p.label,
    seatRole: E8SPP_PERSONA_ROLE[p.role],
  }));
  (D.clients || []).forEach((c) => add(c.csm, {}));

  const me = e8sppMeName();
  const people = order.map((name) => {
    const u = map.get(name);
    const accounts = (D.clients || []).filter((c) => e8sppCanon(c.csm) === name).length;
    return {
      id: u.repId || ('u:' + name.toLowerCase().replace(/[^a-z0-9]+/g, '-')),
      repId: u.repId || '',
      name: name,
      initials: u.initials || e8sppInitialsOf(name),
      title: u.title || (accounts ? 'Account owner' : ''),
      seatRole: u.seatRole || '',
      departed: !!u.departed,
      departedOn: u.departedOn || '',
      email: e8sppEmail(name),
      joined: e8sppJoinedFor({ name: name, repId: u.repId || '' }),
      accounts: accounts,
      isMe: name === me,
    };
  });
  people.sort((a, b) => {
    if (a.isMe !== b.isMe) return a.isMe ? -1 : 1;
    if (a.departed !== b.departed) return a.departed ? 1 : -1;
    return a.name.localeCompare(b.name);
  });
  return people;
}

/* ---------- A1 · Users -------------------------------------------------------------------------- */

function SetUsersPage() {
  const { Page, Section, Card, CardHead, Row, Select, Input, Btn, Pill, Banner, Icon } = E8SPeople;
  const [state, patch] = useSetPeople();
  const [inviting, setInviting] = React.useState(false);
  const [email, setEmail] = React.useState('');
  const [inviteRole, setInviteRole] = React.useState('');
  const [err, setErr] = React.useState('');
  const roster = React.useMemo(() => e8sppRoster(), []);

  React.useEffect(() => {
    if (!inviting) return;
    const el = document.querySelector('.e8-set-people-invite input');
    if (el) el.focus();
  }, [inviting]);

  const roles = e8sppRoleList(state);
  const roleById = {};
  roles.forEach((r) => { roleById[r.id] = r; });
  const roleOptions = [{ value: '', label: 'Select roles' }]
    .concat(roles.map((r) => ({ value: r.id, label: r.label })));
  const roleDot = (id) => (roleById[id] ? 'var(--ui-cat-' + roleById[id].cat + '-text)' : null);

  const removed = state.removed || [];
  const invites = state.invites || [];
  const pending = invites.map((i) => {
    const name = e8sppNameFromEmail(i.email);
    return {
      id: i.id, name: name, initials: e8sppInitialsOf(name), email: i.email,
      seatRole: i.role || '', invited: true, joined: i.at, title: 'Invitation sent',
      departed: false, isMe: false, accounts: 0,
    };
  });
  const people = roster.concat(pending).filter((u) => removed.indexOf(u.id) < 0);
  const taken = {};
  roster.concat(pending).forEach((u) => { taken[u.email] = true; });

  const roleFor = (u) => (Object.prototype.hasOwnProperty.call(state.roles || {}, u.id)
    ? state.roles[u.id] : (u.seatRole || ''));
  const setRole = (u, value) => patch({ roles: { ...(state.roles || {}), [u.id]: value } });

  /* ONE VERB, ONE SAFETY MODEL. The control says Remove, the toast says removed, the reversal is
     Undo — the same three words the Domains page uses for the structurally identical gesture, and
     the same shared toast. The undo is scoped to THIS person (updater form, so it reads the live
     removed list rather than the one captured when the toast was raised); the banner below is the
     durable record and undoes the lot. */
  const drop = (u) => {
    patch({ removed: removed.concat([u.id]) });
    e8sppToast(u.name + ' removed', 'Undo',
      () => patch((s) => ({ removed: (s.removed || []).filter((id) => id !== u.id) })));
  };

  const send = () => {
    const value = email.trim().toLowerCase();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value)) { setErr('Enter a valid work email address.'); return; }
    if (taken[value]) { setErr('That address already has a seat in this workspace.'); return; }
    patch({ invites: invites.concat([{ id: 'inv:' + Date.now(), email: value, role: inviteRole, at: Date.now() }]) });
    setEmail('');
    setInviteRole('');
    setErr('');
    setInviting(false);
  };

  /* The pill stays one short word so every row is the same height — the date it carries goes in the
     cell's title instead of widening a 116px column. */
  const statusOf = (u) => {
    if (u.invited) return { label: 'Invited', tone: 'warn', hint: 'Invitation sent ' + e8sppMonth(u.joined) };
    if (u.departed) return { label: 'Deactivated', tone: null, hint: u.departedOn ? 'Left ' + u.departedOn : 'No longer with the company' };
    return { label: 'Active', tone: 'ok', hint: 'Signs in and holds a seat' };
  };

  return (
    <Page title="Users"
      action={inviting ? null : (
        <Btn kind="primary" icon="person_add" onClick={() => { setErr(''); setInviting(true); }}>Invite user</Btn>
      )}>
      <Section>
        {/* The banner leads the section deliberately: it is a page-level notice, and putting it
            first lets the two cards below it stay adjacent siblings so set-core's own
            `.e8-set-card + .e8-set-card` spacing rule keeps the rhythm. */}
        {removed.length ? (
          <Banner tone="info" icon="person_remove">
            {'Removed ' + removed.length + (removed.length === 1 ? ' person' : ' people') + ' from this workspace. '}
            <Btn kind="quiet" icon="undo" onClick={() => patch({ removed: [] })}>
              {removed.length === 1 ? 'Undo' : 'Undo all'}
            </Btn>
          </Banner>
        ) : null}

        {inviting ? (
          <Card className="e8-set-people-invite">
            <CardHead icon="mail" title="Invite a teammate"
              desc={'They take a seat on ' + e8sppDomain() + ' as soon as they accept.'}
              action={(
                <span className="e8-set-people-acts">
                  <Btn kind="primary" icon="send" onClick={send}>Send invite</Btn>
                  <Btn onClick={() => { setInviting(false); setErr(''); }}>Cancel</Btn>
                </span>
              )} />
            <Row icon="alternate_email" label="Work email"
              desc="Anyone at your company. External guests are not supported yet."
              control={(
                <Input value={email} onChange={(v) => { setEmail(v); setErr(''); }} size="lg" type="email"
                  placeholder={'name@' + e8sppDomain()} ariaLabel="Work email address" />
              )}>
              {err ? <span className="e8-set-people-err" role="alert">{err}</span> : null}
            </Row>
            <Row icon="badge" label="Role"
              desc="Optional now — you can assign it from the table once they accept."
              control={(
                <Select value={inviteRole} options={roleOptions} onChange={setInviteRole}
                  dot={roleDot(inviteRole)} ariaLabel="Role for the invited teammate" />
              )} />
          </Card>
        ) : null}

        <Card>
          <div className="e8-set-people-tablewrap">
            <table className="e8-set-people-table">
              <thead>
                <tr>
                  <th scope="col" className="e8-set-people-c-name">Name</th>
                  <th scope="col" className="e8-set-people-c-status">Status</th>
                  <th scope="col" className="e8-set-people-c-roles">Roles</th>
                  <th scope="col" className="e8-set-people-c-joined">Joined</th>
                  <th scope="col" className="e8-set-people-c-act"><span className="e8-sr-only">Actions</span></th>
                </tr>
              </thead>
              <tbody>
                <tr className="e8-set-people-grouprow">
                  <td colSpan={5}>
                    <button type="button" className="e8-set-people-grouptog"
                      aria-expanded={state.rosterOpen !== false}
                      onClick={() => patch({ rosterOpen: state.rosterOpen === false })}>
                      <Icon name="expand_more" className="e8-set-people-caret" />
                      <span className="e8-set-people-groupname">Users</span>
                      <Pill>{people.length}</Pill>
                    </button>
                  </td>
                </tr>
                {state.rosterOpen === false ? null : people.map((u) => {
                  const st = statusOf(u);
                  const rid = roleFor(u);
                  return (
                    <tr key={u.id} className="e8-set-people-row">
                      <td className="e8-set-people-c-name">
                        <span className="e8-set-people-person">
                          <span className={'e8-set-people-avatar is-c' + e8sppTone(u.name)} aria-hidden="true">{u.initials}</span>
                          <span className="e8-set-people-ident">
                            <span className="e8-set-people-nm" title={u.title || u.name}>{u.name}</span>
                            <span className="e8-set-people-em" title={u.email}>({u.email})</span>
                            {u.isMe ? <span className="e8-set-people-you">You</span> : null}
                          </span>
                        </span>
                      </td>
                      {/* `.e8-set-people-cellab` carries the column name the <thead> supplies at
                          full width. Below 640px the table restacks into one block per person and
                          the header row goes away, so these become the visible labels; above it
                          they are `display: none` and therefore out of the accessibility tree as
                          well, leaving the real <th> association to do the job. Real text rather
                          than a `::before` on a `data-label`, because generated content is not
                          reliably announced and the stacked layout is the ONLY thing naming these
                          values at 390. */}
                      <td className="e8-set-people-c-status" title={st.hint}>
                        <span className="e8-set-people-cellab">Status</span>
                        <Pill tone={st.tone}>{st.label}</Pill>
                      </td>
                      <td className="e8-set-people-c-roles">
                        <span className="e8-set-people-cellab">Roles</span>
                        <span className={'e8-set-people-roles' + (rid ? '' : ' is-empty')}>
                          <Select value={rid} options={roleOptions} onChange={(v) => setRole(u, v)}
                            dot={roleDot(rid)} ariaLabel={'Role for ' + u.name} />
                        </span>
                      </td>
                      <td className="e8-set-people-c-joined">
                        <span className="e8-set-people-cellab">Joined</span>
                        <span className="e8-set-people-joined"
                          title={u.joined ? 'Earliest placement we can attribute to ' + u.name : 'No attributable placement on record'}>
                          {u.joined ? e8sppMonth(u.joined) : '—'}
                        </span>
                      </td>
                      <td className="e8-set-people-c-act">
                        <span className="e8-set-people-del">
                          <Btn kind="danger" icon="delete" disabled={u.isMe}
                            title={u.isMe ? 'You cannot remove your own seat' : 'Remove ' + u.name}
                            onClick={() => drop(u)}>
                            <span className="e8-sr-only">{'Remove ' + u.name + ' from the workspace'}</span>
                          </Btn>
                        </span>
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        </Card>

        <p className="e8-set-people-note">
          ELEV8 does not record an account-creation date for internal staff, so <strong>Joined</strong> shows
          the earliest month each person can be attributed to a placement and an em dash where there is
          none. Addresses are generated from the workspace domain.
        </p>
      </Section>
    </Page>
  );
}

/* ---------- A2 · Teams --------------------------------------------------------------------------
   The default body is the CENTRED empty state, which is the reference's shape for this page. It
   stops being a mockup the moment you use it: Create team writes a real team, membership is picked
   from the derived roster, and emptying the list brings the centred state back. */

function SetTeamsPage() {
  const { Page, Section, Card, CardHead, Row, Select, Input, Btn, Empty, Icon } = E8SPeople;
  const [state, patch] = useSetPeople();
  const [creating, setCreating] = React.useState(false);
  const [name, setName] = React.useState('');
  const [lead, setLead] = React.useState('');
  const [err, setErr] = React.useState('');
  const roster = React.useMemo(() => e8sppRoster(), []);

  React.useEffect(() => {
    if (!creating) return;
    const el = document.querySelector('.e8-set-people-teamform input');
    if (el) el.focus();
  }, [creating]);

  const active = roster.filter((u) => !u.departed);
  const byId = {};
  roster.forEach((u) => { byId[u.id] = u; });
  const teams = state.teams || [];
  const leadId = lead || (active[0] ? active[0].id : '');
  const leadOptions = active.map((u) => ({ value: u.id, label: u.name }));

  const create = () => {
    const value = name.trim();
    if (!value) { setErr('Give the team a name.'); return; }
    if (teams.some((t) => t.name.toLowerCase() === value.toLowerCase())) { setErr('A team with that name already exists.'); return; }
    patch({ teams: teams.concat([{ id: 'team:' + Date.now(), name: value, leadId: leadId, memberIds: leadId ? [leadId] : [], at: Date.now() }]) });
    setName('');
    setLead('');
    setErr('');
    setCreating(false);
  };
  const update = (team, memberIds) => patch({
    teams: teams.map((t) => (t.id === team.id ? { ...t, memberIds: memberIds } : t)),
  });
  const addMember = (team, id) => {
    if (!id || (team.memberIds || []).indexOf(id) >= 0) return;
    update(team, (team.memberIds || []).concat([id]));
  };

  /* EVERY DESTRUCTIVE GESTURE ON THIS PAGE IS REVERSIBLE THE SAME WAY. Deleting a team used to be
     instant and final while the structurally identical delete on Domains offered Undo — two safety
     models for one gesture inside one beat. Both now raise the SHELL's toast with an Undo, which is
     the same call Domains makes (`e8PlatToast(name + ' removed', 'Undo', …)`), so the affordance,
     the wording and the ⌘Z chord are one implementation rather than three lookalikes.

     UNDO PUTS BACK ONE ROW, IT DOES NOT RESTORE A SNAPSHOT. Both of these used to close over the
     render-time `teams` array and hand it back whole, which is the exact hazard `patch`'s updater
     form exists for: a toast's Undo runs an arbitrary time after the click that raised it. Delete
     team A, then create team B, then press Undo on A — the snapshot predates B, so B disappeared.
     Worse in the other order: remove a member, delete a team, Undo the member removal, and the
     deleted team came back, because the snapshot still contained it. Reading `s` at invocation time
     and splicing the single row back at its original index composes with whatever happened in
     between, and keeps the position that restoring an array was supposed to buy. Each is also a
     no-op if the row is already back, so a double Undo cannot duplicate it. */
  const dropMember = (team, id) => {
    const who = byId[id];
    const at = (team.memberIds || []).indexOf(id);
    update(team, (team.memberIds || []).filter((m) => m !== id));
    e8sppToast(((who && who.name) || 'Member') + ' removed from ' + team.name, 'Undo', () => patch((s) => ({
      teams: (s.teams || []).map((t) => {
        if (t.id !== team.id) return t;
        const ids = (t.memberIds || []).slice();
        if (ids.indexOf(id) >= 0) return t;
        ids.splice(Math.max(0, Math.min(at, ids.length)), 0, id);
        return { ...t, memberIds: ids };
      }),
    })));
  };
  const dropTeam = (team) => {
    const at = teams.findIndex((t) => t.id === team.id);
    patch({ teams: teams.filter((t) => t.id !== team.id) });
    e8sppToast(team.name + ' deleted', 'Undo', () => patch((s) => {
      const list = (s.teams || []).slice();
      if (list.some((t) => t.id === team.id)) return {};
      list.splice(Math.max(0, Math.min(at, list.length)), 0, team);
      return { teams: list };
    }));
  };

  const form = (
    <Card className="e8-set-people-teamform">
      <CardHead icon="diversity_3" title="New team"
        desc="A team is a group of people; targets and reporting roll up to it."
        action={(
          <span className="e8-set-people-acts">
            <Btn kind="primary" icon="check" onClick={create}>Create team</Btn>
            <Btn onClick={() => { setCreating(false); setErr(''); }}>Cancel</Btn>
          </span>
        )} />
      <Row icon="badge" label="Team name"
        desc="Name it after the desk or the pod, not the manager."
        control={(
          <Input value={name} onChange={(v) => { setName(v); setErr(''); }} size="lg"
            placeholder="Memphis delivery pod" ariaLabel="Team name" />
        )}>
        {err ? <span className="e8-set-people-err" role="alert">{err}</span> : null}
      </Row>
      <Row icon="person" label="Team lead"
        desc="The lead joins as the team's first member; add the rest afterwards."
        control={<Select value={leadId} options={leadOptions} onChange={setLead} ariaLabel="Team lead" />} />
    </Card>
  );

  const list = (
    <Card>
      {teams.map((t) => {
        const members = (t.memberIds || []).map((id) => byId[id]).filter(Boolean);
        const leadUser = byId[t.leadId];
        const free = active.filter((u) => (t.memberIds || []).indexOf(u.id) < 0);
        return (
          <Row key={t.id} icon="diversity_3" label={t.name}
            desc={(leadUser ? 'Led by ' + leadUser.name + ' · ' : '')
              + members.length + (members.length === 1 ? ' member' : ' members')}
            control={(
              <>
                <Select value="" onChange={(v) => addMember(t, v)} disabled={!free.length}
                  ariaLabel={'Add someone to ' + t.name}
                  options={[{ value: '', label: free.length ? 'Add member…' : 'Everyone is in' }]
                    .concat(free.map((u) => ({ value: u.id, label: u.name })))} />
                <Btn kind="danger" icon="delete" title={'Delete ' + t.name} onClick={() => dropTeam(t)}>
                  <span className="e8-sr-only">{'Delete the team ' + t.name}</span>
                </Btn>
              </>
            )}>
            {members.length ? (
              <span className="e8-set-people-chips">
                {members.map((m) => (
                  <button key={m.id} type="button" className="e8-set-people-chip"
                    onClick={() => dropMember(t, m.id)}
                    aria-label={'Remove ' + m.name + ' from ' + t.name}>
                    {m.name}
                    <Icon name="close" />
                  </button>
                ))}
              </span>
            ) : null}
          </Row>
        );
      })}
    </Card>
  );

  const empty = (
    <Card className="e8-set-people-emptycard">
      <Empty icon="diversity_3" title="No teams yet"
        desc="Group recruiters, account managers and delivery into teams so targets and reporting roll up the way your desk actually works."
        action={<Btn icon="add" onClick={() => { setErr(''); setCreating(true); }}>Create your first team</Btn>} />
    </Card>
  );

  return (
    <Page title="Teams"
      subtitle="Teams decide how work, targets and reporting group together across the workspace."
      action={creating ? null : (
        <Btn kind="primary" icon="add" onClick={() => { setErr(''); setCreating(true); }}>Create team</Btn>
      )}>
      <Section>
        {creating ? form : null}
        {teams.length ? list : null}
        {!creating && !teams.length ? empty : null}
      </Section>
    </Page>
  );
}

/* ---------- A3 · Security ------------------------------------------------------------------------ */

function SetSecurityPage() {
  const { Page, Section, Card, Row, Select, Input, Btn, Toggle } = E8SPeople;
  const [state, patch] = useSetPeople();
  const [adding, setAdding] = React.useState(false);
  const [roleName, setRoleName] = React.useState('');
  const [err, setErr] = React.useState('');

  React.useEffect(() => {
    if (!adding) return;
    const el = document.querySelector('.e8-set-people-roleform input');
    if (el) el.focus();
  }, [adding]);

  const roles = e8sppRoleList(state);
  const mfa = !!state.mfa;

  const addRole = () => {
    const label = roleName.trim();
    if (!label) { setErr('Give the role a name.'); return; }
    if (roles.some((r) => r.label.toLowerCase() === label.toLowerCase())) { setErr('A role with that name already exists.'); return; }
    const custom = state.customRoles || [];
    patch({
      customRoles: custom.concat([{
        id: 'r:' + label.toLowerCase().replace(/[^a-z0-9]+/g, '-') + ':' + custom.length,
        label: label,
        cat: 6 + (custom.length % 2),
      }]),
    });
    setRoleName('');
    setErr('');
    setAdding(false);
  };

  return (
    <Page title="Security">
      <Section title="Multi-factor authentication"
        desc="Require a second factor when someone signs in to this workspace.">
        <Card>
          <Row icon="phonelink_lock" label="Require multi-factor authentication"
            desc="Members sign in with their password and a code from an authenticator app. Anyone without one is prompted to enrol on their next sign-in."
            control={<Toggle checked={mfa} onChange={(v) => patch({ mfa: v })}
              label="Require multi-factor authentication" />} />
          <Row icon="groups" label="Who it applies to" locked={!mfa}
            desc="Narrow the requirement while you roll it out, then widen it."
            control={<Select value={state.mfaScope || 'all'} disabled={!mfa}
              ariaLabel="Who multi-factor authentication applies to"
              onChange={(v) => patch({ mfaScope: v })}
              options={[
                { value: 'all', label: 'Everyone in the workspace' },
                { value: 'admins', label: 'Admins and operations only' },
                { value: 'external', label: 'Anyone signing in off the office network' },
              ]} />} />
        </Card>
      </Section>

      <Section title="Workspace roles"
        desc="A role decides what a person can see and change. Roles are assigned per person on the Users page.">
        <Card className={adding ? 'e8-set-people-roleform' : ''}>
          {/* Security's ONE action, and it is filled — Users and Teams each carry a single accent
              CTA and a reviewer paging Users → Teams → Security saw the accent vanish on the third.
              It is HIDDEN rather than disabled while the composer is open, which is the pattern the
              other two pages already use, and is what keeps the page from ever showing two filled
              buttons at once (the composer's own "Add role" is the filled one then). */}
          <Row icon="badge" label={roles.length + (roles.length === 1 ? ' role configured' : ' roles configured')}
            desc={roles.map((r) => r.label).join(' · ')}
            control={adding ? null : (
              <Btn kind="primary" icon="add"
                onClick={() => { setErr(''); setAdding(true); }}>New role</Btn>
            )} />
          {adding ? (
            <Row icon="new_label" label="Role name"
              desc="It becomes assignable in the Users table straight away."
              control={(
                <>
                  <Input value={roleName} onChange={(v) => { setRoleName(v); setErr(''); }} size="md"
                    placeholder="Contract administrator" ariaLabel="New role name" />
                  <Btn kind="primary" icon="check" onClick={addRole}>Add role</Btn>
                  <Btn onClick={() => { setAdding(false); setErr(''); }}>Cancel</Btn>
                </>
              )}>
              {err ? <span className="e8-set-people-err" role="alert">{err}</span> : null}
            </Row>
          ) : null}
        </Card>
      </Section>
    </Page>
  );
}

window.E8SetPages = window.E8SetPages || {};
Object.assign(window.E8SetPages, { users: SetUsersPage, teams: SetTeamsPage, security: SetSecurityPage });
