/* ──────────────────────────────────────────────────────────────────────
   users.jsx — unified Users page
   ──────────────────────────────────────────────────────────────────────
   Track 1+2 of the perms-v2 simplification (see chat 2026-05-04).

   Two tabs: People (the directory) and Roles (the template list).

   One slide-over (UserDrawer) handles BOTH invite-new and edit-existing.
   It groups every access lever into four sections so an admin never has to
   bounce between pages:

     1. Identity        — email + name
     2. Role            — single dropdown listing every visible role
                          (a "role" is a row in capability_templates).
                          Picking one stamps its slug onto users.role and
                          seeds the capability set.
     3. Sites           — multi-select chips with an "All sites" toggle.
                          Hidden for org-wide roles (aquaos_admin, org_admin)
                          since the data model gives them every site implicitly.
     4. Customise caps  — disclosure that's collapsed by default. Open it
                          when you want this user to deviate from the role's
                          defaults. Greyed toggles enforce the funnel
                          (you can't grant caps you don't hold yourself).

   The legacy isPermsV2() flag is gone — this is the only flow.
   ChangeRoleModal is gone — folded into the drawer.
   The standalone #user/:id detail page is gone — drawer replaces it.
   ────────────────────────────────────────────────────────────────────── */

const ROLE_TINT_US = {
  aquaos_admin:    { bg: 'color-mix(in srgb, var(--aq-accent) 18%, transparent)', fg: 'var(--aq-accent)', label: 'Platform admin' },
  admin:           { bg: 'color-mix(in srgb, var(--aq-accent) 18%, transparent)', fg: 'var(--aq-accent)', label: 'Admin' },
  org_admin:       { bg: 'color-mix(in srgb, var(--aq-accent) 18%, transparent)', fg: 'var(--aq-accent)', label: 'Org admin' },
  marketing_admin: { bg: 'rgba(180, 130, 230, 0.14)', fg: '#C99BFF', label: 'Marketing admin' },
  site_manager:    { bg: 'rgba(120, 180, 255, 0.13)', fg: '#7FB7FF', label: 'Site manager' },
  curator:         { bg: 'rgba(120, 200, 130, 0.13)', fg: 'var(--aq-success)', label: 'Curator' },
  content_editor:  { bg: 'rgba(120, 200, 130, 0.13)', fg: 'var(--aq-success)', label: 'Content editor' },
  designer:        { bg: 'rgba(180, 130, 230, 0.14)', fg: '#C99BFF', label: 'Designer' },
  operator:        { bg: 'rgba(220, 200, 130, 0.12)', fg: '#E0C66E', label: 'Operator' },
  viewer:          { bg: 'rgba(255,255,255,0.05)', fg: 'var(--aq-text-dim)', label: 'Viewer' },
};

const STATUS_TINT_US = {
  active:    { bg: 'color-mix(in srgb, var(--aq-success) 14%, transparent)', fg: 'var(--aq-success)', dot: 'var(--aq-success)', label: 'Active' },
  invited:   { bg: 'rgba(255, 200, 120, 0.12)', fg: '#F2C879', dot: '#F2C879', label: 'Invited' },
};

/* Roles that come with implicit access to every site in their scope; the
   site-assignment column is hidden for them in the drawer. */
const ORG_WIDE_ROLE_SLUGS = new Set(['aquaos_admin', 'org_admin', 'admin']);

function US_initials(name) {
  return (name || '').split(' ').map((p) => p[0]).slice(0, 2).join('').toUpperCase() || 'AA';
}

/* Humanise an unknown role slug like "junior_curator" → "Junior curator"
   so org-defined custom roles get a readable badge without us needing to
   lookup their template name on every render. */
function humaniseSlug(slug) {
  if (!slug) return 'Member';
  return slug
    .split('_')
    .filter(Boolean)
    .map((p, i) => i === 0 ? p[0].toUpperCase() + p.slice(1) : p)
    .join(' ');
}

function US_RolePill({ role, label }) {
  const t = ROLE_TINT_US[role];
  /* Apple pass: the family colour now rides on a small dot, not a filled
     chip. `fg` was already the per-family accent; reuse it for the dot and
     let the label sit in muted-white. Unknown slugs (custom org roles) get
     a faint neutral dot + humanised label. */
  const dot = t ? t.fg : 'var(--aq-text-faint)';
  const text = label || (t ? t.label : humaniseSlug(role));
  return (
    <span className="us-rolepill">
      <span className="us-roledot" style={{ background: dot }} />
      {text}
    </span>
  );
}

function US_StatusPill({ status }) {
  const t = STATUS_TINT_US[status] || STATUS_TINT_US.active;
  return (
    <span className="us-statuspill" style={{ background: t.bg, color: t.fg }}>
      <span className="us-statusdot" style={{ background: t.dot }} />
      {t.label}
    </span>
  );
}

function US_Avatar({ initials, size = 32, ring }) {
  return (
    <div
      className={`us-avatar ${ring ? 'is-ring' : ''}`}
      style={{ width: size, height: size, fontSize: size * 0.38 }}
    >{initials}</div>
  );
}

function US_lastActive(iso) {
  if (!iso) return 'Never';
  const ageMs = Date.now() - new Date(iso).getTime();
  const m = Math.round(ageMs / 60000);
  if (m < 1) return 'Active now';
  if (m < 60) return `${m} min ago`;
  const h = Math.round(m / 60);
  if (h < 24) return `${h} h ago`;
  const d = Math.round(h / 24);
  return `${d} d ago`;
}

/* Site chips for the list-view "Sites" column. Org-wide roles render a
   single italic pill since their access is implicit; everyone else gets
   chip(s) for each assigned site (capped at 2 + "+N more"). */
function US_SiteChips({ user, allSites }) {
  const isOrgWide = ORG_WIDE_ROLE_SLUGS.has(user.role);
  const userSites = Array.isArray(user.sites) ? user.sites : [];

  // Platform admins see every org; org-wide roles (org_admin / admin) have
  // implicit access to every site IN THEIR OWN ORG — spelled out so it's not
  // mistaken for platform-wide access across all organisations.
  if (user.role === 'aquaos_admin') {
    return <span style={{ fontSize: 11, color: 'var(--aq-text-faint)', fontStyle: 'italic' }}>Every org</span>;
  }
  if (isOrgWide) {
    // Name the org explicitly so a platform admin can tell WHICH org's sites
    // this covers (and scan/filter by it) — not just "their organisation".
    return (
      <span style={{ fontSize: 11, color: 'var(--aq-text-faint)', fontStyle: 'italic' }}>
        {user.org_name ? `All sites · ${user.org_name}` : 'All sites in their organisation'}
      </span>
    );
  }
  if (userSites.length === 0) {
    return <span style={{ fontSize: 11, color: 'var(--aq-text-faint)' }}>—</span>;
  }
  // Site-scoped roles: name the first site, then summarise the rest as
  // "+N site(s)" so a multi-site manager reads as "Sea Life Sunshine Coast
  // +3 sites" rather than an ambiguous bare count.
  const first = userSites[0];
  const overflow = userSites.length - 1;
  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 6 }}>
      <span
        style={{
          fontSize: 11, padding: '1px 7px', borderRadius: 4,
          background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
          color: 'var(--aq-text-dim)',
        }}
      >{first.name}</span>
      {overflow > 0 && (
        <span style={{ fontSize: 11, color: 'var(--aq-text-faint)' }}>+{overflow} site{overflow === 1 ? '' : 's'}</span>
      )}
    </div>
  );
}

/* Centred modal — used for Roles editor and (per bug 0a69b34b) the
   user invite/edit dialog. Optional footer renders sticky at the
   bottom. ESC + click-outside dismissal. */
function US_ModalShell({ open, title, onClose, width = 460, children, footer }) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose && onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);
  if (!open) return null;
  return (
    <div
      className="aq-modal-fade"
      onClick={(e) => { if (e.target === e.currentTarget) onClose && onClose(); }}
      style={{
        position: 'fixed', inset: 0, zIndex: 220,
        background: 'rgba(6, 7, 10, 0.78)', backdropFilter: 'blur(6px)',
        display: 'grid', placeItems: 'center', padding: 24,
        animation: 'aqModalFade 0.18s ease-out',
      }}
    >
      <div className="aq-rise" style={{
        width: `min(${width}px, 100%)`,
        background: 'var(--aq-surface)',
        border: '1px solid var(--aq-line)',
        borderRadius: 14, boxShadow: 'var(--aq-shadow-2)',
        display: 'flex', flexDirection: 'column', maxHeight: '82vh', overflow: 'hidden',
      }}>
        <header style={{
          /* Apple pass: no hairline under the title — padding + type size
             do the separating, matching the divider-free footer below. */
          padding: '16px 18px 8px',
          display: 'flex', alignItems: 'center', gap: 10,
        }}>
          <div style={{ flex: 1, fontFamily: 'var(--aq-ff-display)', fontSize: 14.5, color: 'var(--aq-text)', fontWeight: 500 }}>
            {title}
          </div>
          <button className="aq-icon-btn" onClick={onClose}><Icon name="close" size={13} /></button>
        </header>
        <div style={{ overflowY: 'auto', padding: 16, flex: 1 }}>{children}</div>
        {footer && (
          <footer style={{
            /* Apple pass: no top divider — the padding + same surface
               already separate the actions from the body. */
            padding: '6px 18px 16px',
            display: 'flex', justifyContent: 'flex-end', gap: 8,
            background: 'var(--aq-surface)',
          }}>{footer}</footer>
        )}
      </div>
    </div>
  );
}

/* Right-anchored slide-over. Sized for editing one user/role at a time
   without losing the list context behind it. ESC + backdrop click both
   close. */
function US_DrawerShell({ open, title, onClose, width = 560, children, footer }) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') onClose && onClose(); };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open, onClose]);
  if (!open) return null;
  return (
    <div
      onClick={(e) => { if (e.target === e.currentTarget) onClose && onClose(); }}
      style={{
        position: 'fixed', inset: 0, zIndex: 220,
        background: 'rgba(6, 7, 10, 0.55)', backdropFilter: 'blur(4px)',
        display: 'flex', justifyContent: 'flex-end',
        animation: 'aqModalFade 0.18s ease-out',
      }}
    >
      <div className="aq-drawer-slide" style={{
        width: `min(${width}px, 100%)`,
        height: '100vh',
        background: 'var(--aq-surface)',
        borderLeft: '1px solid var(--aq-line)',
        boxShadow: '-12px 0 32px rgba(0,0,0,0.45)',
        display: 'flex', flexDirection: 'column',
      }}>
        <header style={{
          padding: '14px 18px', borderBottom: '1px solid var(--aq-line)',
          display: 'flex', alignItems: 'center', gap: 10,
        }}>
          <div style={{ flex: 1, fontFamily: 'var(--aq-ff-display)', fontSize: 15, color: 'var(--aq-text)', fontWeight: 500 }}>
            {title}
          </div>
          <button className="aq-icon-btn" onClick={onClose}><Icon name="close" size={13} /></button>
        </header>
        <div style={{ flex: 1, overflowY: 'auto', padding: 18 }}>{children}</div>
        {footer && (
          <footer style={{
            padding: '12px 18px', borderTop: '1px solid var(--aq-line)',
            display: 'flex', justifyContent: 'flex-end', gap: 8,
            background: 'var(--aq-surface)',
          }}>{footer}</footer>
        )}
      </div>
    </div>
  );
}

/* ──────────────────────────────────────────────────────────────────────
   UserDrawer — the only access-editor in the app.
   Mode is "invite" (no userId, creates an invitation) OR "edit"
   (userId set, PUTs /api/auth/users/:id). Both share identical UI.
   ────────────────────────────────────────────────────────────────────── */
function UserDrawer({ open, mode, userId, onClose, onSaved }) {
  const isInvite = mode === 'invite';

  const [user, setUser] = useState(null);
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');
  const [templateId, setTemplateId] = useState(null);
  const [role, setRole] = useState('operator');
  const [caps, setCaps] = useState([]);
  const [allSites, setAllSites] = useState([]);
  const [siteIds, setSiteIds] = useState([]);
  const [allSitesToggle, setAllSitesToggle] = useState(false);

  const [templates, setTemplates] = useState([]);
  const [showCustom, setShowCustom] = useState(false);
  const [link, setLink] = useState(null);
  const [emailed, setEmailed] = useState(false);
  const [busy, setBusy] = useState(false);
  const [resetBusy, setResetBusy] = useState(false);
  const [err, setErr] = useState(null);

  /* Organisation assignment — platform admins only. A platform admin has
     no org of their own, so when they add an org-scoped user (anything but
     another platform admin) they must say WHICH org it belongs to. Org
     admins skip this entirely: their users inherit their org on the
     server. */
  const meUser = (Auth && Auth.getUser && Auth.getUser()) || {};
  const isPlatformAdminMe = meUser.role === 'aquaos_admin';
  const [organisations, setOrganisations] = useState([]);
  const [organisationId, setOrganisationId] = useState(null);

  /* Load reference data + (if editing) the user. Done together so the
     drawer paints once with everything in place. */
  useEffect(() => {
    if (!open) return;
    let cancelled = false;
    setErr(null); setBusy(false); setLink(null); setEmailed(false); setShowCustom(false);

    const reqs = [
      apiFetch('/api/capabilities/templates').then((r) => r.templates || []).catch(() => []),
      apiFetch('/api/auth/sites').then((r) => r.sites || []).catch(() => []),
      // Org list — platform admins only (endpoint is Slate-gated). Others
      // get an empty list and never see the selector.
      isPlatformAdminMe
        ? apiFetch('/api/auth/platform/organisations').then((r) => r.organisations || []).catch(() => [])
        : Promise.resolve([]),
    ];
    if (!isInvite && userId) {
      reqs.push(apiFetch(`/api/auth/users/${userId}`).then((r) => r.user || r));
    }
    Promise.all(reqs).then((results) => {
      if (cancelled) return;
      const [tplList, siteList, orgList, fetchedUser] = results;
      setTemplates(tplList);
      setAllSites(siteList);
      setOrganisations(orgList);

      if (isInvite) {
        /* Default to Operator template — the lowest-power preset. */
        const def = tplList.find((t) => t.slug === 'operator') || tplList.find((t) => t.is_platform_default) || tplList[0];
        setEmail(''); setName('');
        setUser(null);
        if (def) {
          setTemplateId(def.id);
          setRole(def.slug || 'operator');
          setCaps(Array.isArray(def.capabilities) ? def.capabilities : []);
        }
        setSiteIds([]);
        setAllSitesToggle(false);
        setOrganisationId(null);
      } else if (fetchedUser) {
        setUser(fetchedUser);
        setEmail(fetchedUser.email || '');
        setName(fetchedUser.name || '');
        setRole(fetchedUser.role || 'operator');
        const matchTpl = tplList.find((t) => t.slug && t.slug === fetchedUser.role);
        setTemplateId(matchTpl ? matchTpl.id : null);
        setCaps(Array.isArray(fetchedUser.capabilities) ? fetchedUser.capabilities : []);
        const userSiteIds = (fetchedUser.sites || []).map((s) => s.id);
        setSiteIds(userSiteIds);
        setOrganisationId(fetchedUser.organisation_id || null);
        // "All sites" reflects coverage of the user's OWN org, not the
        // whole platform — match against that org's sites.
        const orgSites = siteList.filter((s) => s.organisation_id === fetchedUser.organisation_id);
        setAllSitesToggle(userSiteIds.length > 0 && userSiteIds.length === orgSites.length);
      }
    }).catch((e) => { if (!cancelled) setErr(e.message); });
    return () => { cancelled = true; };
  }, [open, mode, userId]); // eslint-disable-line

  /* Picking a role/template stamps both role-slug and capability set so
     the user sees the funnel-aware grid update immediately. */
  function applyTemplate(id) {
    const t = templates.find((x) => x.id === id);
    if (!t) return;
    setTemplateId(id);
    setRole(t.slug || 'operator');
    setCaps(Array.isArray(t.capabilities) ? t.capabilities : []);
  }

  function toggleSite(id) {
    setSiteIds((cur) => cur.includes(id) ? cur.filter((s) => s !== id) : [...cur, id]);
  }

  /* Show the org picker only for a platform admin assigning an org-scoped
     role. A platform-admin invitee belongs to no org, so it's hidden then. */
  const showOrg = isPlatformAdminMe && role !== 'aquaos_admin';

  /* Sites a platform admin may pick are scoped to the chosen org; org
     admins already only receive their own org's sites from the API. */
  const visibleSites = isPlatformAdminMe
    ? (organisationId ? allSites.filter((s) => s.organisation_id === organisationId) : [])
    : allSites;

  /* "All sites in this org" expands to every visible site at submit time;
     that way the backend sees an explicit site_ids[] and our chip view in
     the directory says "All sites" because the union matches. */
  function toggleAllSites(on) {
    setAllSitesToggle(on);
    if (on) setSiteIds(visibleSites.map((s) => s.id));
  }

  /* Switching org clears any site selection from the previous org so we
     never submit sites that don't belong to the chosen organisation. */
  function pickOrganisation(id) {
    setOrganisationId(id || null);
    setSiteIds([]);
    setAllSitesToggle(false);
  }

  const showSites = !ORG_WIDE_ROLE_SLUGS.has(role);
  const isSiteManagerWithoutSite = role === 'site_manager' && (!siteIds || siteIds.length === 0);

  async function save() {
    if (isInvite && !email.trim()) { setErr('Email is required.'); return; }
    if (showOrg && !organisationId) { setErr('Select an organisation for this user.'); return; }
    setErr(null); setBusy(true);
    try {
      const finalSiteIds = showSites
        ? (allSitesToggle ? visibleSites.map((s) => s.id) : siteIds)
        : [];
      // Only a platform admin sets the org explicitly. Platform-admin
      // invitees get null (no org); for everyone else the server keeps
      // its own org. Omit the field entirely when we shouldn't touch it.
      const orgField = isPlatformAdminMe
        ? { organisation_id: role === 'aquaos_admin' ? null : organisationId }
        : {};
      if (isInvite) {
        const body = {
          email: email.trim(),
          name: name.trim(),
          role,
          template_id: templateId || undefined,
          capabilities: caps,
          site_ids: finalSiteIds,
          ...orgField,
        };
        const res = await apiFetch('/api/auth/invitations', { method: 'POST', body: JSON.stringify(body) });
        if (res && res.invite_url) setLink(res.invite_url);
        setEmailed(!!(res && res.emailed));
        if (onSaved) onSaved();
      } else {
        const body = {
          name: name.trim() || undefined,
          role,
          capabilities: caps,
          site_ids: finalSiteIds,
          ...orgField,
        };
        await apiFetch(`/api/auth/users/${userId}`, { method: 'PUT', body: JSON.stringify(body) });
        if (onSaved) onSaved();
        onClose && onClose();
      }
    } catch (e) { setErr(e.message); }
    finally { setBusy(false); }
  }

  function copyLink() {
    if (!link) return;
    try { navigator.clipboard.writeText(link); window.toast && window.toast.success('Invitation link copied'); }
    catch (_) { window.prompt('Copy this link:', link); }
  }

  /* Email this (existing) user a tokenised reset link. Mirrors the row-menu
     action; copy-link fallback when SMTP is off. */
  async function sendResetLink() {
    setResetBusy(true);
    try {
      const res = await apiFetch(`/api/auth/users/${userId}/send-password-reset`, { method: 'POST', body: JSON.stringify({}) });
      if (res && res.emailed) {
        window.toast && window.toast.success(`Reset link emailed to ${res.email}`);
      } else if (res && res.reset_url) {
        try {
          await navigator.clipboard.writeText(res.reset_url);
          window.toast && window.toast.info('Email isn’t set up — reset link copied to clipboard');
        } catch (_) {
          window.prompt('Copy this password reset link:', res.reset_url);
        }
      }
    } catch (e) { setErr(e.message); }
    finally { setResetBusy(false); }
  }

  const title = isInvite
    ? 'Invite'
    : `Edit ${(user && (user.name || user.email)) || 'user'}`;

  const footer = link ? (
    <>
      <button className="x-btn ghost" onClick={copyLink}>Copy link</button>
      <button className="x-btn" onClick={onClose}>Done</button>
    </>
  ) : (
    <>
      {/* Edit mode only: email the user a reset link. Sits far-left so it
          reads as a secondary account action, away from Cancel/Save. */}
      {!isInvite && (
        <button
          type="button"
          className="x-btn ghost"
          style={{ marginRight: 'auto' }}
          onClick={sendResetLink}
          disabled={resetBusy}
        >{resetBusy ? 'Sending…' : 'Send password reset'}</button>
      )}
      <button type="button" className="x-btn ghost" onClick={onClose}>Cancel</button>
      <button
        type="button"
        className="x-btn"
        onClick={save}
        disabled={busy || (isInvite && !email.trim())}
      >{busy ? 'Saving…' : (isInvite ? 'Send invite' : 'Save changes')}</button>
    </>
  );

  /* Bug 0a69b34b: user invite/edit moved from a right-anchored drawer
     to the centred modal so it matches the Settings popup pattern. */
  return (
    <US_ModalShell open={open} onClose={onClose} title={title} footer={footer} width={620}>
      {link ? (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div style={{ fontSize: 12.5, color: 'var(--aq-text-muted)' }}>
            {emailed
              ? <>Invitation emailed to <span style={{ color: 'var(--aq-text)' }}>{email}</span>. You can also copy the link below.</>
              : <>Email isn't set up here, so share this link to invite them:</>}
          </div>
          <div style={{
            padding: '8px 10px', borderRadius: 7, fontSize: 11.5,
            fontFamily: 'var(--aq-ff-mono)', wordBreak: 'break-all',
            background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
            color: 'var(--aq-text)',
          }}>{link}</div>
        </div>
      ) : (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 22 }}>
          {/* ── Identity ── */}
          <Section title="Identity">
            <Field label="Email">
              <input
                type="email"
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                placeholder="alex@aquarium.org"
                disabled={!isInvite}
                autoFocus={isInvite}
                style={drawerInputStyle(!isInvite)}
              />
            </Field>
            <Field label={<>Name <span style={{ color: 'var(--aq-text-faint)' }}>(optional)</span></>}>
              <input
                type="text"
                value={name}
                onChange={(e) => setName(e.target.value)}
                placeholder="Alex Murray"
                style={drawerInputStyle(false)}
              />
            </Field>
          </Section>

          {/* ── Role ── per Sarah's feedback e0ccc804: simplified to a
              flat role picker (Platform admin > Org Admin > Site
              Manager > Marketing > Operator). Capability templates
              are still the underlying mechanism — we resolve each
              role to its platform-default template so existing
              capability sets keep working — but the curator just
              sees five role choices. Hierarchy rule: you can only
              assign a role STRICTLY BELOW your own level. */}
          <Section
            title="Role"
            helper="Each role grants a sensible default set of permissions. You can only assign roles at or below your own level."
          >
            {(() => {
              const ROLE_HIERARCHY = [
                { slug: 'aquaos_admin',   label: 'Platform admin',   blurb: 'Sees every organisation and every site.' },
                { slug: 'org_admin',      label: 'Organisation admin', blurb: 'Manages this whole org and every site in it.' },
                { slug: 'site_manager',   label: 'Site manager',     blurb: 'Runs day-to-day on one or more specific sites.' },
                { slug: 'marketing_admin',label: 'Marketing',        blurb: 'Cross-site campaigns and asset library.' },
                { slug: 'operator',       label: 'Operator',         blurb: 'Floor staff: species library + the day\'s displays.' },
              ];
              const meUser = (Auth && Auth.getUser && Auth.getUser()) || {};
              const meIdx = ROLE_HIERARCHY.findIndex((r) => r.slug === meUser.role);
              // Platform admins manage the platform itself, so they CAN
              // appoint other platform admins — they see their own level
              // and everything below (slice from meIdx). Everyone else
              // keeps the strictly-below funnel: an org_admin (idx 1) sees
              // site_manager downwards but can't mint another org_admin.
              // (lower index = higher privilege; operator can't reach this
              // drawer at all.)
              const isPlatformAdmin = meUser.role === 'aquaos_admin';
              const assignable = meIdx < 0
                ? []
                : ROLE_HIERARCHY.slice(isPlatformAdmin ? meIdx : meIdx + 1);
              // Resolve which template matches the currently-selected
              // role so we can pre-tick the right row.
              const currentTemplate = templates.find((t) => t.id === templateId);
              const currentSlug = (currentTemplate && currentTemplate.slug) || role;
              const pickRole = (slug) => {
                // Find the platform-default template for this role —
                // org-specific ones come later if curators want to
                // diverge. Falls back to the first matching template.
                const tpl = templates.find((t) => t.slug === slug && t.organisation_id === null)
                         || templates.find((t) => t.slug === slug);
                if (tpl) applyTemplate(tpl.id);
                else setRole(slug);  // template missing? still update the role slug
              };
              if (templates.length === 0) {
                return <div style={{ color: 'var(--aq-text-faint)', fontSize: 12 }}>Loading roles…</div>;
              }
              if (assignable.length === 0) {
                return <div style={{ color: 'var(--aq-text-faint)', fontSize: 12 }}>
                  You can't assign any roles — your account isn't above any other level.
                </div>;
              }
              return (
                <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                  {assignable.map((r) => {
                    const selected = currentSlug === r.slug;
                    return (
                      <button
                        type="button"
                        key={r.slug}
                        onClick={() => pickRole(r.slug)}
                        onMouseEnter={(e) => { if (!selected) e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; }}
                        onMouseLeave={(e) => { if (!selected) e.currentTarget.style.background = 'transparent'; }}
                        style={{
                          /* Apple pass: iOS grouped-list selection — rows float
                             on the modal surface (no per-row box), the chosen
                             row gets a soft accent fill + a trailing checkmark
                             rather than a leading radio dot. */
                          textAlign: 'left',
                          padding: '10px 12px',
                          borderRadius: 8,
                          background: selected ? 'var(--aq-accent-soft)' : 'transparent',
                          border: '1px solid ' + (selected ? 'var(--aq-accent-line)' : 'transparent'),
                          color: 'var(--aq-text)',
                          cursor: 'pointer', fontFamily: 'inherit',
                          display: 'flex', alignItems: 'center', gap: 10,
                          transition: 'background 0.12s ease',
                        }}
                      >
                        <span style={{ flex: 1, minWidth: 0 }}>
                          <span style={{ fontWeight: 500, fontSize: 13 }}>{r.label}</span>
                          <span style={{ display: 'block', fontSize: 11.5, color: 'var(--aq-text-faint)', marginTop: 2 }}>
                            {r.blurb}
                          </span>
                        </span>
                        {selected && (
                          <span style={{ flexShrink: 0, color: 'var(--aq-accent)', display: 'flex' }}>
                            <Icon name="check" size={15} />
                          </span>
                        )}
                      </button>
                    );
                  })}
                </div>
              );
            })()}
          </Section>

          {/* ── Organisation ── platform-admin only; picks which org an
              org-scoped user belongs to. Hidden for org admins (their own
              org is implicit) and when inviting another platform admin. */}
          {showOrg && (
            <Section title="Organisation" helper="Which organisation this user belongs to. Sites below are scoped to it.">
              <select
                value={organisationId || ''}
                onChange={(e) => pickOrganisation(e.target.value || null)}
                style={drawerInputStyle(false)}
              >
                <option value="">Select an organisation…</option>
                {organisations.map((o) => (
                  <option key={o.id} value={o.id}>{o.name}</option>
                ))}
              </select>
            </Section>
          )}

          {/* ── Sites ── */}
          {showSites ? (
            <Section title="Sites" helper="Where this user can work. Hidden when the role is org-wide.">
              {showOrg && !organisationId ? (
                <div style={{ color: 'var(--aq-text-faint)', fontSize: 12 }}>Select an organisation above to choose sites.</div>
              ) : (
              <>
              <label style={{ display: 'flex', alignItems: 'center', gap: 9, cursor: 'pointer', fontSize: 12.5, color: 'var(--aq-text)' }}>
                {/* Apple pass: native checkbox replaced with a styled box —
                    soft accent fill + line checkmark when on, hairline when off.
                    The real input stays for a11y/keyboard but is visually hidden. */}
                <input
                  type="checkbox"
                  checked={allSitesToggle}
                  onChange={(e) => toggleAllSites(e.target.checked)}
                  style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }}
                />
                <span style={{
                  width: 18, height: 18, borderRadius: 5, flexShrink: 0,
                  display: 'grid', placeItems: 'center',
                  background: allSitesToggle ? 'var(--aq-accent-soft)' : 'transparent',
                  border: '1px solid ' + (allSitesToggle ? 'var(--aq-accent-line)' : 'var(--aq-line)'),
                  color: 'var(--aq-accent)',
                  transition: 'background 0.12s ease, border-color 0.12s ease',
                }}>
                  {allSitesToggle && <Icon name="check" size={12} />}
                </span>
                <span>All sites in this organisation</span>
              </label>
              {!allSitesToggle && (
                <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
                  {visibleSites.length === 0 && (
                    <div style={{ color: 'var(--aq-text-faint)', fontSize: 12 }}>No sites in this organisation yet.</div>
                  )}
                  {visibleSites.map((s) => {
                    const on = siteIds.includes(s.id);
                    return (
                      <button
                        type="button"
                        key={s.id}
                        onClick={() => toggleSite(s.id)}
                        style={{
                          /* Apple pass + pearl fix: was solid-accent fill with
                             hard-coded #fff text — invisible white-on-white once
                             the accent went monochrome. Now fill-based selection:
                             soft accent wash + tinted ring, text stays readable. */
                          padding: '6px 12px', borderRadius: 999, fontSize: 12,
                          background: on ? 'var(--aq-accent-soft)' : 'transparent',
                          border: '1px solid ' + (on ? 'var(--aq-accent-line)' : 'var(--aq-line)'),
                          color: on ? 'var(--aq-text)' : 'var(--aq-text-dim)',
                          cursor: 'pointer', fontFamily: 'inherit',
                          transition: 'background 0.12s ease, border-color 0.12s ease',
                        }}
                      >{s.name}{s.org_name ? ` · ${s.org_name}` : ''}</button>
                    );
                  })}
                </div>
              )}
              {isSiteManagerWithoutSite && (
                <div style={{
                  marginTop: 10, padding: '8px 10px', borderRadius: 6, fontSize: 11.5,
                  background: 'rgba(255, 200, 120, 0.10)', color: '#F2C879',
                }}>Heads up — a Site manager with no sites assigned won't be able to do anything.</div>
              )}
              </>
              )}
            </Section>
          ) : (
            <Section title="Sites">
              <div style={{ fontSize: 12, color: 'var(--aq-text-faint)' }}>
                {role === 'aquaos_admin'
                  ? 'Platform admins reach every organisation and every site.'
                  : 'Org admins automatically reach every site in this organisation.'}
              </div>
            </Section>
          )}

          {/* Customise capabilities — HIDDEN per Sarah's feedback
              e0ccc804 ("roles section over-complicates things and
              requires too much testing for now"). Each role's
              default capability set is applied automatically when
              the role is picked above. Reinstate this block if/when
              fine-grained capability editing becomes a product
              requirement again. */}

          {err && (
            <div style={{
              padding: '8px 10px', borderRadius: 6,
              background: 'color-mix(in srgb, var(--aq-danger) 12%, transparent)',
              color: 'var(--aq-danger)', fontSize: 12,
            }}>{err}</div>
          )}
        </div>
      )}
    </US_ModalShell>
  );
}

/* Tiny structural helpers for the drawer body. */
function Section({ title, helper, children }) {
  return (
    <section>
      <div style={{ fontSize: 11, letterSpacing: 0.6, textTransform: 'uppercase', color: 'var(--aq-text-faint)', marginBottom: 4 }}>{title}</div>
      {helper && <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)', marginBottom: 8 }}>{helper}</div>}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>{children}</div>
    </section>
  );
}
function Field({ label, children }) {
  return (
    <label style={{ display: 'block' }}>
      <div style={{ fontSize: 11.5, color: 'var(--aq-text-dim)', marginBottom: 5 }}>{label}</div>
      {children}
    </label>
  );
}
function drawerInputStyle(disabled) {
  return {
    /* Apple pass: recessed filled field, no hairline border — the fill
       alone reads as the input, iOS-style. */
    width: '100%', padding: '9px 11px', fontSize: 13,
    background: disabled ? 'var(--aq-surface)' : 'var(--aq-surface-2)',
    border: '1px solid transparent', borderRadius: 8,
    color: disabled ? 'var(--aq-text-faint)' : 'var(--aq-text)',
    font: 'inherit', outline: 0,
    cursor: disabled ? 'not-allowed' : 'auto',
  };
}

/* ──────────────────────────────────────────────────────────────────────
   Row kebab menu — slimmer now. Edit opens the drawer. The standalone
   Change-role modal is gone.
   ────────────────────────────────────────────────────────────────────── */
function UserRowMenu({ user, me, onChanged, onEdit }) {
  const [open, setOpen] = useState(false);
  const [busy, setBusy] = useState(null);
  const [anchor, setAnchor] = useState(null); // { right, top } in viewport coords
  const triggerRef = useRef(null);
  const menuRef = useRef(null);

  /* Recompute the anchor whenever we open or the viewport changes. We
     pin the menu's right edge to the trigger's right edge so it grows
     leftwards (matches the visual rhythm of every other 3-dots menu in
     the CMS) and never hits the right viewport edge — which is what was
     clipping the previous absolute-positioned variant inside the table's
     overflow:hidden container.

     Using position:fixed lifts the menu out of any clipping ancestor,
     same approach the maintenance lightbox uses. */
  useEffect(() => {
    if (!open || !triggerRef.current) return;
    const place = () => {
      const r = triggerRef.current.getBoundingClientRect();
      setAnchor({
        right: Math.max(8, window.innerWidth - r.right),
        top: r.bottom + 4,
      });
    };
    place();
    window.addEventListener('resize', place);
    window.addEventListener('scroll', place, true); // capture phase catches table scrolls
    return () => {
      window.removeEventListener('resize', place);
      window.removeEventListener('scroll', place, true);
    };
  }, [open]);

  useEffect(() => {
    if (!open) return;
    const onDoc = (e) => {
      if (triggerRef.current && triggerRef.current.contains(e.target)) return;
      if (menuRef.current && menuRef.current.contains(e.target)) return;
      setOpen(false);
    };
    const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onDoc);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);

  async function call(label, fn) {
    setBusy(label);
    try { await fn(); if (onChanged) onChanged(); }
    catch (err) { window.alert(`${label} failed: ${err.message}`); }
    finally { setBusy(null); setOpen(false); }
  }
  function softDelete() {
    if (!window.confirm(`Delete user "${user.name || user.email}"? This is reversible by an admin.`)) return;
    return call('Delete', () => apiFetch(`/api/auth/users/${user.id}`, { method: 'DELETE' }));
  }
  // Invitation rows carry an INVITATION id, not a user id — so the user
  // delete endpoint 404s on them. Revoke hits the right endpoint.
  function revokeInvite() {
    if (!window.confirm(`Revoke the pending invitation for ${user.email}?`)) return;
    return call('Revoke', () => apiFetch(`/api/auth/invitations/${user.id}`, { method: 'DELETE' }));
  }
  /* Email the user a tokenised reset link. When SMTP isn't configured the
     backend still returns the link, so we fall back to copying it — same
     copy-link safety net the invite flow uses. */
  function sendReset() {
    return call('Reset link', async () => {
      const res = await apiFetch(`/api/auth/users/${user.id}/send-password-reset`, { method: 'POST', body: JSON.stringify({}) });
      if (res && res.emailed) {
        window.toast && window.toast.success(`Password reset link emailed to ${res.email}`);
      } else if (res && res.reset_url) {
        try {
          await navigator.clipboard.writeText(res.reset_url);
          window.toast && window.toast.info('Email isn’t set up — reset link copied to clipboard');
        } catch (_) {
          window.prompt('Copy this password reset link:', res.reset_url);
        }
      }
    });
  }

  const isMe = me && user.id === me.id;
  return (
    <>
      <button
        ref={triggerRef}
        className="aq-icon-btn"
        onClick={() => setOpen((v) => !v)}
        disabled={busy != null}
        aria-label="User actions"
      ><Icon name="more" size={13} /></button>
      {open && anchor && ReactDOM.createPortal((
        <div
          ref={menuRef}
          style={{
            position: 'fixed',
            right: anchor.right, top: anchor.top,
            minWidth: 180, padding: 4, zIndex: 9000,
            background: 'var(--aq-surface)',
            border: '1px solid var(--aq-line)', borderRadius: 7,
            boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
            display: 'flex', flexDirection: 'column', gap: 1,
          }}
        >
          {user.is_invitation ? (
            <>
              <UsMenuItem icon="mail" onClick={() => { setOpen(false); onEdit && onEdit(user); }}>
                Manage invite…
              </UsMenuItem>
              <UsMenuItem icon="close" tone="danger" onClick={revokeInvite}>
                {busy === 'Revoke' ? 'Revoking…' : 'Revoke invite'}
              </UsMenuItem>
            </>
          ) : (
            <>
              <UsMenuItem icon="settings" onClick={() => { setOpen(false); onEdit && onEdit(user); }} disabled={isMe}>
                Edit access…
              </UsMenuItem>
              <UsMenuItem icon="key" onClick={sendReset}>
                {busy === 'Reset link' ? 'Sending…' : 'Send password reset'}
              </UsMenuItem>
              <UsMenuItem icon="close" tone="danger" onClick={softDelete} disabled={isMe}>
                {busy === 'Delete' ? 'Deleting…' : 'Delete user'}
              </UsMenuItem>
            </>
          )}
        </div>
      ), document.body)}
    </>
  );
}

function UsMenuItem({ icon, children, onClick, tone, disabled }) {
  return (
    <button
      onClick={(e) => { e.stopPropagation(); if (!disabled && onClick) onClick(e); }}
      disabled={disabled}
      style={{
        display: 'flex', alignItems: 'center', gap: 8,
        padding: '7px 10px', fontSize: 12.5,
        background: 'transparent', border: 0,
        color: disabled ? 'var(--aq-text-faint)'
          : (tone === 'danger' ? 'var(--aq-danger)' : 'var(--aq-text)'),
        textAlign: 'left', cursor: disabled ? 'not-allowed' : 'pointer',
        borderRadius: 5, fontFamily: 'inherit',
      }}
      onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.background = 'var(--aq-surface-2)'; }}
      onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
    >
      {icon && <Icon name={icon} size={12} />}
      <span>{children}</span>
    </button>
  );
}

/* ──────────────────────────────────────────────────────────────────────
   The page.
   Two tabs — People (list + drawer) and Roles (CapabilityTemplatesScreen).
   The Roles tab embeds the existing role/template manager so we drop the
   separate sidebar entry without losing functionality.
   ────────────────────────────────────────────────────────────────────── */
function UsersScreen() {
  const [tab, setTab] = useState(() => {
    /* Allow #users?tab=roles deep-link from the legacy sidebar entry. */
    const m = (window.location.hash || '').match(/[?&]tab=([^&]+)/);
    return (m && m[1] === 'roles') ? 'roles' : 'people';
  });
  const [users, setUsers] = useState([]);
  const [allSites, setAllSites] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [query, setQuery] = useState('');
  const [roleFilter, setRoleFilter] = useState('All');
  const [orgFilter, setOrgFilter] = useState('All');
  const [reload, setReload] = useState(0);

  /* Drawer state — { mode, userId | null }. null when closed. */
  const [drawer, setDrawer] = useState(null);
  /* Invitation modal state — opened when admin clicks a synthetic
     "pending" row (an invitation merged into the users list). Holds
     the full invitation object. Feedback id 00f994c5 (Sarah Chen):
     "need somewhere to manage active invitation for users, maybe
     they appear in the users list as pending so i can go into their
     profile and edit anything, delete the invite etc." */
  const [pendingInvite, setPendingInvite] = useState(null);

  useEffect(() => {
    if (!Auth.canManageUsers()) {
      setError('Users administration requires the admin role.');
      setLoading(false);
      return;
    }
    let cancelled = false;
    setLoading(true);
    Promise.all([
      apiFetch('/api/auth/users'),
      apiFetch('/api/auth/sites').catch(() => ({ sites: [] })),
      apiFetch('/api/auth/invitations').catch(() => ({ invitations: [] })),
    ])
      .then(([usersRes, sitesRes, inviteRes]) => {
        if (cancelled) return;
        const realUsers = usersRes.users || [];
        // Materialise pending invitations as synthetic rows so they
        // render alongside real users. The row's id is the invitation
        // id (not a user id) — UserRow's onClick uses our onEdit
        // callback which branches on is_invitation. Feedback 00f994c5.
        const pendingInvites = (inviteRes.invitations || [])
          .filter((iv) => iv.status === 'pending')
          .map((iv) => ({
            id: iv.id,
            email: iv.email,
            name: iv.name || iv.email,
            role: iv.role,
            is_active: false,
            is_invitation: true,
            invitation: iv,
            created_at: iv.created_at,
          }));
        setUsers([...pendingInvites, ...realUsers]);
        setAllSites(sitesRes.sites || []);
        setLoading(false);
      })
      .catch((err) => {
        if (cancelled) return;
        setError(err.message);
        setLoading(false);
      });
    return () => { cancelled = true; };
  }, [reload]);

  function refresh() { setReload((n) => n + 1); }

  /* Distinct orgs present in the list, for the org filter. Only platform
     admins see more than one org, so the dropdown is hidden otherwise. */
  const orgOptions = useMemo(() => {
    return Array.from(new Set(
      users.map((u) => u.org_name).filter(Boolean)
    )).sort((a, b) => a.localeCompare(b));
  }, [users]);

  const filtered = useMemo(() => {
    return users.filter((u) => {
      if (roleFilter !== 'All' && u.role !== roleFilter) return false;
      if (orgFilter !== 'All' && (u.org_name || '') !== orgFilter) return false;
      if (query) {
        const q = query.toLowerCase();
        if (!(u.name || '').toLowerCase().includes(q) && !(u.email || '').toLowerCase().includes(q)) return false;
      }
      return true;
    });
  }, [users, roleFilter, orgFilter, query]);

  const counts = {
    total: users.length,
  };

  const me = Auth.getUser();

  return (
    <div className="us-content">
      {/* Pared-down header (May 2026, Oli). Linear-style: just the
          title + actions. Breadcrumb, subtitle, single-tab strip,
          and metric cards all removed — each was decoration or
          redundancy that the table itself already conveys. */}
      <header className="us-header">
        <h1 style={{
          fontFamily: 'var(--aq-ff-display)', fontSize: 26, fontWeight: 500,
          letterSpacing: '-0.012em', color: 'var(--aq-text)', margin: 0,
        }}>Users</h1>
        <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
          {/* Audit demoted to an icon-only ghost button. The label
              moves to a tooltip; the button still routes to #audit.
              Reduces visual competition with the primary CTA below. */}
          <button
            className="aq-icon-btn"
            onClick={() => { window.location.hash = '#audit'; }}
            title="Audit log"
            aria-label="Audit log"
          >
            <Icon name="filter" size={15} />
          </button>
          {Auth.canManageUsers() && (
            <button
              className="aq-icon-btn"
              onClick={() => setDrawer({ mode: 'invite' })}
              title="Invite user"
              aria-label="Invite user"
            >
              <Icon name="plus" size={15} />
            </button>
          )}
        </div>
      </header>

      {false ? (
        <div />
      ) : (
        <PeopleTab
          users={users}
          filtered={filtered}
          allSites={allSites}
          query={query} setQuery={setQuery}
          roleFilter={roleFilter} setRoleFilter={setRoleFilter}
          orgFilter={orgFilter} setOrgFilter={setOrgFilter} orgOptions={orgOptions}
          loading={loading} error={error}
          counts={counts} me={me}
          onEdit={(u) => {
            if (u.is_invitation) {
              setPendingInvite(u.invitation);
            } else {
              setDrawer({ mode: 'edit', userId: u.id });
            }
          }}
          onChanged={refresh}
        />
      )}

      <UserDrawer
        open={!!drawer}
        mode={drawer && drawer.mode}
        userId={drawer && drawer.userId}
        onClose={() => setDrawer(null)}
        onSaved={() => refresh()}
      />
      <PendingInviteModal
        invite={pendingInvite}
        onClose={() => setPendingInvite(null)}
        onChanged={() => { setPendingInvite(null); refresh(); }}
      />
    </div>
  );
}

/* ══════════════════════════════════════════════════════════════════
   PendingInviteModal — manage an outstanding invitation.
   ──────────────────────────────────────────────────────────────────
   Opened when an admin clicks a "pending" row in the users list
   (synthetic rows materialised from /api/auth/invitations). Shows
   the invitation's email/role/expiry plus three actions:

     • Copy invite link — text the invitee a fresh URL if their
       inbox ate the original.
     • Resend            — revoke the current invite and create a new
                            one (same email + role), so the expiry
                            clock restarts.
     • Revoke            — DELETE the invitation; row disappears
                            from the users list on next refresh.

   Backend already wired (POST/GET/DELETE /api/auth/invitations).
   Design doc: feedback 00f994c5 (Sarah Chen).
   ══════════════════════════════════════════════════════════════════ */
function PendingInviteModal({ invite, onClose, onChanged }) {
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);
  const [link, setLink] = useState(null);

  useEffect(() => {
    if (!invite) { setLink(null); setErr(null); return; }
    // Build the invite URL from the token. Same format the server
    // returns from POST /invitations (preserved so a curator can copy
    // the link without a separate "send invite" round-trip).
    const baseUrl = window.location.origin;
    setLink(`${baseUrl}/cms#/accept-invite/${invite.token || ''}`);
  }, [invite && invite.id]);

  if (!invite) return null;

  async function revoke() {
    if (!window.confirm(`Revoke the invitation for ${invite.email}?\n\nThey won't be able to accept it. You can send a new invite afterwards if needed.`)) return;
    setBusy(true); setErr(null);
    try {
      await apiFetch(`/api/auth/invitations/${invite.id}`, { method: 'DELETE' });
      if (window.toast) window.toast(`Invitation for ${invite.email} revoked`);
      onChanged && onChanged();
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(false);
    }
  }

  async function resend() {
    // "Resend" = revoke existing + create a fresh invitation. Same
    // email + role + site_ids; new token + expiry.
    setBusy(true); setErr(null);
    try {
      await apiFetch(`/api/auth/invitations/${invite.id}`, { method: 'DELETE' });
      const body = {
        email: invite.email,
        name: invite.name || null,
        role: invite.role,
      };
      if (invite.site_ids) {
        try {
          const parsed = typeof invite.site_ids === 'string'
            ? JSON.parse(invite.site_ids)
            : invite.site_ids;
          if (Array.isArray(parsed)) body.site_ids = parsed;
        } catch (_) {/* ignore — server falls back to no sites */}
      }
      const fresh = await apiFetch('/api/auth/invitations', {
        method: 'POST',
        body: JSON.stringify(body),
      });
      if (window.toast) window.toast(`Fresh invitation sent to ${invite.email}`);
      // Update the visible link so the curator can copy the new one.
      const baseUrl = window.location.origin;
      setLink(`${baseUrl}/cms#/accept-invite/${fresh.token}`);
      onChanged && onChanged();
    } catch (e) {
      setErr(e.message);
      setBusy(false);
    }
  }

  function copyLink() {
    if (!link) return;
    try { navigator.clipboard.writeText(link); }
    catch (_) {
      const ta = document.createElement('textarea');
      ta.value = link; document.body.appendChild(ta); ta.select();
      try { document.execCommand('copy'); } catch (_) {}
      document.body.removeChild(ta);
    }
    if (window.toast) window.toast('Invite link copied');
  }

  function fmtExpiry(iso) {
    if (!iso) return '—';
    const ms = new Date(iso).getTime() - Date.now();
    if (ms <= 0) return 'expired';
    const days = Math.floor(ms / 86400000);
    const hrs  = Math.floor((ms % 86400000) / 3600000);
    if (days > 0) return `in ${days}d ${hrs}h`;
    return `in ${hrs}h`;
  }

  return (
    <div
      onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose && onClose(); }}
      style={{
        position: 'fixed', inset: 0, zIndex: 1000,
        background: 'rgba(6,7,10,0.78)', backdropFilter: 'blur(6px)',
        display: 'grid', placeItems: 'center', padding: 24,
      }}
    >
      <div style={{
        width: 'min(480px, 100%)',
        background: 'var(--aq-surface)',
        border: '1px solid var(--aq-line)',
        borderRadius: 14, boxShadow: 'var(--aq-shadow-2)',
        display: 'flex', flexDirection: 'column', overflow: 'hidden',
      }}>
        <header style={{
          padding: '14px 18px', borderBottom: '1px solid var(--aq-line)',
          display: 'flex', alignItems: 'center', gap: 10,
        }}>
          <div style={{
            width: 28, height: 28, borderRadius: 8,
            background: 'rgba(242, 200, 121, 0.16)', color: '#F2C879',
            display: 'grid', placeItems: 'center',
          }}><Icon name="mail" size={14} /></div>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontFamily: 'var(--aq-ff-display)', fontSize: 14.5, fontWeight: 500, color: 'var(--aq-text)' }}>
              Pending invitation
            </div>
            <div style={{ fontSize: 11.5, color: 'var(--aq-text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
              {invite.email}
            </div>
          </div>
          <button type="button" className="aq-icon-btn" onClick={onClose} disabled={busy}>
            <Icon name="close" size={13} />
          </button>
        </header>

        <div style={{ padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div style={{
            display: 'grid', gridTemplateColumns: '110px 1fr', rowGap: 8, columnGap: 12,
            fontSize: 12.5, color: 'var(--aq-text-dim)',
          }}>
            <div style={{ color: 'var(--aq-text-faint)' }}>Role</div>
            <div><US_RolePill role={invite.role} /></div>
            <div style={{ color: 'var(--aq-text-faint)' }}>Invited by</div>
            <div>{invite.invited_by_name || invite.invited_by_email || '—'}</div>
            <div style={{ color: 'var(--aq-text-faint)' }}>Sent</div>
            <div>{invite.created_at ? new Date(invite.created_at).toLocaleString() : '—'}</div>
            <div style={{ color: 'var(--aq-text-faint)' }}>Expires</div>
            <div>{fmtExpiry(invite.expires_at)}</div>
          </div>

          <div style={{
            padding: 10, borderRadius: 8,
            background: 'var(--aq-surface-2)', border: '1px solid var(--aq-line)',
          }}>
            <div style={{ fontSize: 10.5, color: 'var(--aq-text-faint)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 }}>
              Invite link
            </div>
            <div style={{
              fontSize: 11, fontFamily: 'var(--aq-ff-mono)', color: 'var(--aq-text)',
              wordBreak: 'break-all', lineHeight: 1.5,
            }}>{link || '—'}</div>
          </div>

          {err && (
            <div style={{
              padding: '8px 10px', borderRadius: 6,
              background: 'color-mix(in srgb, var(--aq-danger) 14%, transparent)',
              color: 'var(--aq-danger)', fontSize: 12,
            }}>{err}</div>
          )}
        </div>

        <footer style={{
          padding: '10px 16px', borderTop: '1px solid var(--aq-line)',
          background: 'var(--aq-surface-2)',
          display: 'flex', gap: 8, alignItems: 'center',
        }}>
          <button
            type="button"
            onClick={revoke}
            disabled={busy}
            style={{
              padding: '6px 12px', fontSize: 12, fontFamily: 'inherit',
              background: 'transparent', color: 'var(--aq-danger)',
              border: '1px solid color-mix(in srgb, var(--aq-danger) 30%, transparent)',
              borderRadius: 6, cursor: 'pointer',
            }}
          >Revoke</button>
          <div style={{ flex: 1 }} />
          <button type="button" className="x-btn ghost" onClick={copyLink} disabled={busy || !link}>
            Copy link
          </button>
          <button type="button" className="x-btn" onClick={resend} disabled={busy}>
            {busy ? 'Sending…' : 'Resend invite'}
          </button>
        </footer>
      </div>
    </div>
  );
}

function PeopleTab({ users, filtered, allSites, query, setQuery, roleFilter, setRoleFilter, orgFilter, setOrgFilter, orgOptions, loading, error, counts, me, onEdit, onChanged }) {
  // Click-to-sort. Default: name ascending. Re-clicking the same
  // column toggles direction; clicking a different column resets to
  // ascending. Sortable headers: User (name), Role, Last active.
  // Sites + the kebab column aren't sortable (multi-value, no nat-ord).
  const [sortBy, setSortBy] = useState('name');
  const [sortDir, setSortDir] = useState('asc');
  // Apple pass: search is recessed — borderless at rest, faint fill +
  // hairline only on focus (same treatment as the Global Species search).
  const [searchFocused, setSearchFocused] = useState(false);
  function toggleSort(col) {
    if (sortBy === col) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
    else { setSortBy(col); setSortDir('asc'); }
  }
  const sorted = useMemo(() => {
    const rows = [...filtered];
    const dir = sortDir === 'asc' ? 1 : -1;
    rows.sort((a, b) => {
      let av, bv;
      switch (sortBy) {
        case 'role': av = (a.role || ''); bv = (b.role || ''); break;
        case 'last_active': av = a.last_login_at || 0; bv = b.last_login_at || 0; break;
        case 'name': default: av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase();
      }
      return av < bv ? -dir : av > bv ? dir : 0;
    });
    return rows;
  }, [filtered, sortBy, sortDir]);

  // Linear-style: minimal chrome. Search input is naked (no panel
  // wrapper); count + sort labels sit inline to its right.
  return (
    <>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 12,
        margin: '4px 0 2px',
      }}>
        <div style={{
          flex: '1 1 320px', display: 'flex', alignItems: 'center', gap: 8,
          padding: '8px 10px',
          background: searchFocused ? 'var(--aq-surface)' : 'transparent',
          border: `1px solid ${searchFocused ? 'var(--aq-line)' : 'transparent'}`,
          borderRadius: 7, minWidth: 240, maxWidth: 420,
          transition: 'background 120ms ease, border-color 120ms ease',
        }}>
          <Icon name="search" size={13} />
          <input
            type="text"
            placeholder="Search by name or email…"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            onFocus={() => setSearchFocused(true)}
            onBlur={() => setSearchFocused(false)}
            style={{
              flex: 1, background: 'transparent', border: 0, outline: 0,
              color: 'var(--aq-text)', font: 'inherit', fontSize: 12.5,
            }}
          />
        </div>
        <div style={{ flex: 1 }} />
        {/* Org filter — only meaningful for platform admins, who are the
            only ones who see users from more than one org. */}
        {(orgOptions || []).length > 1 && (
          <select
            value={orgFilter}
            onChange={(e) => setOrgFilter(e.target.value)}
            title="Filter by organisation"
            style={{
              padding: '7px 10px', fontSize: 12, borderRadius: 7,
              background: orgFilter === 'All' ? 'transparent' : 'var(--aq-surface-2)',
              border: '1px solid var(--aq-line)',
              color: orgFilter === 'All' ? 'var(--aq-text-dim)' : 'var(--aq-text)',
              font: 'inherit', outline: 0, cursor: 'pointer',
            }}
          >
            <option value="All">All organisations</option>
            {orgOptions.map((name) => (
              <option key={name} value={name}>{name}</option>
            ))}
          </select>
        )}
        <span style={{
          fontFamily: 'var(--aq-ff-mono)', fontSize: 10.5,
          letterSpacing: '0.06em', textTransform: 'uppercase',
          color: 'var(--aq-text-faint)',
        }}>{filtered.length} of {counts.total}</span>
      </div>

      {loading && (
        <div style={{ padding: 60, textAlign: 'center', color: 'var(--aq-text-faint)' }}>
          Loading users…
        </div>
      )}
      {error && (
        <div className="x-err-stage" style={{ minHeight: 280 }}>
          <div className="x-err-card">
            <div className="x-err-code">Access required</div>
            <div className="x-err-glyph is-warn"><Icon name="alert" size={28} /></div>
            <h1 className="x-err-headline">Admin only</h1>
            <p className="x-err-body">{error}</p>
          </div>
        </div>
      )}

      {!loading && !error && (
        <div style={{ marginTop: 4 }}>
          {/* Header row — single hairline under it anchors the table;
              no outer card chrome. Headers are clickable for sort. */}
          <div className="us-grid-head" style={{
            display: 'grid',
            gridTemplateColumns: '1.6fr 0.9fr 1.1fr 0.9fr 32px',
            gap: 14,
            padding: '12px 4px',
            borderBottom: '1px solid var(--aq-line)',
            fontFamily: 'var(--aq-ff-mono)',
            fontSize: 9.5, letterSpacing: '0.08em', textTransform: 'uppercase',
            color: 'var(--aq-text-faint)',
          }}>
            <SortableHeader label="User"        col="name"        sortBy={sortBy} sortDir={sortDir} onClick={toggleSort} />
            <SortableHeader label="Role"        col="role"        sortBy={sortBy} sortDir={sortDir} onClick={toggleSort} />
            <div>Sites</div>
            <SortableHeader label="Last active" col="last_active" sortBy={sortBy} sortDir={sortDir} onClick={toggleSort} />
            <div></div>
          </div>
          {/* Rows — no dividers between rows; hover wash is what
              defines a row visually. Kebab menu appears on row hover
              only via the 'us-row' class + CSS in styles.css. */}
          {sorted.map((u) => {
            return (
            <div
              key={u.id}
              className="us-row us-grid-row"
              onClick={() => onEdit && onEdit(u)}
              style={{
                display: 'grid',
                gridTemplateColumns: '1.6fr 0.9fr 1.1fr 0.9fr 32px',
                gap: 14,
                padding: '14px 4px',
                alignItems: 'center',
                fontSize: 12.5,
                cursor: 'pointer',
                borderRadius: 6,
                transition: 'background 0.12s ease',
              }}
              onMouseEnter={(e) => { e.currentTarget.style.background = 'color-mix(in srgb, var(--aq-surface-2) 60%, transparent)'; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
            >
              <div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
                <US_Avatar initials={US_initials(u.name)} size={32} ring={u.id === me?.id} />
                <div style={{ minWidth: 0 }}>
                  <div style={{
                    color: 'var(--aq-text)',
                    fontWeight: 500,
                    display: 'flex', alignItems: 'center', gap: 6,
                  }}>
                    {u.name}
                    {u.id === me?.id && (
                      <span style={{
                        fontFamily: 'var(--aq-ff-mono)', fontSize: 9.5,
                        padding: '1px 5px', borderRadius: 3,
                        background: 'var(--aq-accent-soft)', color: 'var(--aq-accent)',
                        letterSpacing: '0.04em',
                      }}>YOU</span>
                    )}
                    {u.is_invitation && (
                      <span style={{
                        fontFamily: 'var(--aq-ff-mono)', fontSize: 9.5,
                        padding: '1px 5px', borderRadius: 3,
                        background: 'rgba(242, 200, 121, 0.16)', color: '#F2C879',
                        letterSpacing: '0.04em',
                      }}>INVITE</span>
                    )}
                  </div>
                  <div style={{ color: 'var(--aq-text-faint)', fontSize: 11.5 }}>{u.email}</div>
                </div>
              </div>
              <div><US_RolePill role={u.role} /></div>
              <div><US_SiteChips user={u} allSites={allSites} /></div>
              <div style={{ color: 'var(--aq-text-dim)', fontFamily: 'var(--aq-ff-mono)', fontSize: 11 }}>
                {US_lastActive(u.last_login_at)}
              </div>
              <div onClick={(e) => e.stopPropagation()} className="us-row-kebab">
                <UserRowMenu user={u} me={me} onChanged={onChanged} onEdit={onEdit} />
              </div>
            </div>
            );
          })}
          {sorted.length === 0 && (
            <div style={{ padding: 60, textAlign: 'center', color: 'var(--aq-text-faint)' }}>
              No users match those filters.
            </div>
          )}
        </div>
      )}
    </>
  );
}

// Sortable column header — click to sort, click again to toggle dir,
// arrow indicator shows current sort column + direction.
function SortableHeader({ label, col, sortBy, sortDir, onClick }) {
  const active = sortBy === col;
  return (
    <div
      onClick={() => onClick(col)}
      style={{
        cursor: 'pointer',
        userSelect: 'none',
        display: 'inline-flex', alignItems: 'center', gap: 4,
        color: active ? 'var(--aq-text-dim)' : 'var(--aq-text-faint)',
        transition: 'color 0.12s ease',
      }}
      onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--aq-text-dim)'; }}
      onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = 'var(--aq-text-faint)'; }}
    >
      <span>{label}</span>
      {active && (
        <span style={{ fontSize: 8 }}>{sortDir === 'asc' ? '▲' : '▼'}</span>
      )}
    </div>
  );
}

window.UsersScreen = UsersScreen;
