// Team Sync — shared widgets (sections, modals, selection, skill rows). Split out of team_sync.jsx.
const { useState: useTwS, useEffect: useTwE, useRef: useTwR } = React;

// ── shared bits ──────────────────────────────────────────────────────────────
function TsSection({ title, actions, lead, children, collapsible, count, defaultCollapsed }) {
  const [collapsed, setCollapsed] = useTwS(!!defaultCollapsed);
  const titleStyle = { textTransform: 'uppercase', letterSpacing: '0.04em', fontWeight: 600 };
  // Only the chevron + title toggle; `lead` (select-all) and `actions` stay
  // independently clickable so collapsing never fights selection/buttons.
  const heading = collapsible ? (
    <div onClick={() => setCollapsed(c => !c)}
      style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', userSelect: 'none' }}>
      <span className="text-tertiary" style={{ fontSize: 10, width: 9, display: 'inline-block' }}>{collapsed ? '▶' : '▼'}</span>
      <span className="t-micro text-tertiary" style={titleStyle}>{title}</span>
      {count != null && <span className="t-micro text-tertiary" style={{ opacity: 0.65 }}>· {count}</span>}
    </div>
  ) : (
    <span className="t-micro text-tertiary" style={titleStyle}>{title}</span>
  );
  return (
    <div className="card" style={{ padding: 16, marginBottom: 16 }}>
      <div style={{ display: 'flex', alignItems: 'center', marginBottom: collapsed ? 0 : 12, gap: 8 }}>
        {lead}
        {heading}
        <div style={{ flex: 1 }} />
        {actions}
      </div>
      {!collapsed && children}
    </div>
  );
}

function TsFlash({ flash }) {
  if (!flash) return null;
  const danger = flash.kind === 'error';
  // Fixed top-right toast: always visible regardless of how far the skill list
  // is scrolled. Solid background so it stays readable over page content.
  // Portaled to <body> so it escapes any ancestor stacking context — the sticky
  // right rail and the sticky device panel each form a stacking context that
  // would otherwise trap this fixed toast beneath the page header and hide it
  // (only toasts raised from the right rail were affected, hence portaling all).
  const node = (
    <div className="t-body" style={{
      position: 'fixed', top: 16, right: 16, zIndex: 300, maxWidth: 380,
      fontSize: 12.5, padding: '10px 14px', borderRadius: 10,
      background: 'var(--surface-raised, #fff)',
      color: danger ? 'var(--danger, #DC2626)' : 'var(--success, #16A34A)',
      border: `1px solid ${danger ? 'rgba(220,38,38,.35)' : 'rgba(22,163,74,.35)'}`,
      borderLeft: `3px solid ${danger ? 'var(--danger, #DC2626)' : 'var(--success, #16A34A)'}`,
      boxShadow: 'var(--shadow-2, 0 6px 20px rgba(0,0,0,.15))',
      wordBreak: 'break-word',
    }}>{flash.text}</div>
  );
  return (typeof ReactDOM !== 'undefined' && ReactDOM.createPortal)
    ? ReactDOM.createPortal(node, document.body)
    : node;
}

function useFlash() {
  const [flash, setFlash] = useTwS(null);
  const timer = useTwR(null);
  const show = (text, kind = 'ok') => {
    setFlash({ text, kind });
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => setFlash(null), 4000);
  };
  return [flash, show];
}

const fieldStyle = {
  height: 34, padding: '0 10px', fontSize: 13, width: '100%',
  border: '1px solid var(--border)', borderRadius: 8,
  background: 'var(--surface)', color: 'var(--text-primary)', outline: 'none',
};

// Two-column grid for skill lists (fills the panel width instead of leaving the
// right half blank). minmax(0,1fr) lets columns shrink so long names truncate
// rather than overflow. Row spacing comes from SkillRow's own marginBottom.
const skillGridStyle = {
  display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', columnGap: 10,
};
// Skill name: single line, ellipsis when it overflows its (possibly narrow) cell.
const skillNameStyle = {
  fontSize: 13, fontWeight: 500, display: 'block',
  overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
};

function TsField({ label, children }) {
  return (
    <label style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1, minWidth: 140 }}>
      <span className="t-micro text-tertiary">{label}</span>
      {children}
    </label>
  );
}

// Prompt detail modal — wide + scrollable for long prompt content, with Copy.
function PromptModal({ item, onClose }) {
  if (!item) return null;
  const copy = () => { try { navigator.clipboard.writeText(item.content || ''); } catch (e) { /* clipboard blocked */ } };
  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.4)', zIndex: 140,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: 640, maxWidth: '100%', maxHeight: '80vh', display: 'flex', flexDirection: 'column',
        background: 'var(--surface-raised)', border: '1px solid var(--border)', borderRadius: 14,
        boxShadow: 'var(--shadow-2)', padding: 20,
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
          <span style={{ fontSize: 15, fontWeight: 600, flex: 1, minWidth: 0, wordBreak: 'break-word' }}>{item.name}</span>
          <button className="btn sm" onClick={copy}>⧉ Copy</button>
          <button className="btn sm ghost" onClick={onClose} style={{ padding: 4 }}>{typeof Icons !== 'undefined' && Icons.X ? <Icons.X size={13} /> : '✕'}</button>
        </div>
        <div className="text-secondary t-small" style={{ marginBottom: 12 }}>{item.description || 'No description provided'}</div>
        <div style={{ flex: 1, minHeight: 0, overflow: 'auto', border: '1px solid var(--border-subtle)', borderRadius: 8, padding: 12, background: 'var(--surface)' }}>
          <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'var(--font-mono, monospace)', fontSize: 12.5, lineHeight: 1.5 }}>{item.content}</pre>
        </div>
      </div>
    </div>
  );
}

// ── org workspace context ───────────────────────────────────────────────────
// A personal user always has a "Personal" workspace addressed by their own
// account id (where My Cloud skills + their own devices/Local live, no Team),
// plus one workspace per org they belong to. The backend treats the user's own
// account id in the {org_id} slot as that personal workspace.
function useOrgContext(mode) {
  const personal = mode !== 'org';
  const selfId = (typeof parcleAccount === 'function' && parcleAccount()) ? parcleAccount().id : null;
  const [orgs, setOrgs] = useTwS(mode === 'org' ? [] : null); // null = loading (personal)
  const [orgId, setOrgIdState] = useTwS(null);
  const [error, setError] = useTwS(null);

  useTwE(() => {
    let cancelled = false;
    if (mode === 'org') {
      const acct = typeof parcleAccount === 'function' ? parcleAccount() : null;
      setOrgs([]);
      setOrgIdState(acct ? acct.id : 'org_mock_1');
      return;
    }
    setOrgs(null); setError(null);
    tsListMyOrgs().then(list => {
      if (cancelled) return;
      setOrgs(list);
      const stored = tsSelectedOrg();
      const validIds = new Set([selfId, ...list.map(o => o.id)].filter(Boolean));
      // Default to the first org (preserves existing members' view of Team);
      // a user with no org lands in their Personal workspace.
      const pick = (stored && validIds.has(stored)) ? stored : (list[0] ? list[0].id : selfId);
      setOrgIdState(pick);
      if (pick) tsSetSelectedOrg(pick);
    }).catch(e => { if (!cancelled) setError(e.message || 'Failed to load organizations'); });
    return () => { cancelled = true; };
  }, [mode]);

  const setOrgId = (id) => { setOrgIdState(id); tsSetSelectedOrg(id); };
  const workspaces = personal && selfId
    ? [{ id: selfId, name: 'Personal', personal: true }, ...(orgs || [])]
    : (orgs || []);
  const isPersonalWorkspace = personal && !!selfId && orgId === selfId;
  return { orgs, workspaces, orgId, setOrgId, error, isPersonalWorkspace };
}

// ── selection helpers ────────────────────────────────────────────────────────
function useSel() {
  const [ids, setIds] = useTwS(() => new Set());
  const toggle = (id) => setIds(p => { const n = new Set(p); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const clear = () => setIds(new Set());
  const addAll = (arr) => setIds(p => { const n = new Set(p); arr.forEach(x => n.add(x)); return n; });
  const removeAll = (arr) => setIds(p => { const n = new Set(p); arr.forEach(x => n.delete(x)); return n; });
  return { ids, toggle, clear, addAll, removeAll };
}

function Chk({ checked, onChange }) {
  return (
    <input type="checkbox" checked={checked} onChange={onChange} onClick={e => e.stopPropagation()}
      style={{ width: 15, height: 15, flexShrink: 0, cursor: 'pointer', accentColor: 'var(--accent)' }} />
  );
}

// Tri-state "select all" checkbox for a group of ids (empty / all / some).
function SelectAllChk({ ids, sel }) {
  const ref = useTwR(null);
  const total = ids.length;
  const n = ids.filter(id => sel.ids.has(id)).length;
  const checked = total > 0 && n === total;
  const indeterminate = n > 0 && n < total;
  useTwE(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate, total]);
  const toggle = () => { if (checked) sel.removeAll(ids); else sel.addAll(ids); };
  return (
    <input ref={ref} type="checkbox" disabled={total === 0} checked={checked} onChange={toggle}
      onClick={e => e.stopPropagation()} title="Select all"
      style={{ width: 15, height: 15, flexShrink: 0, cursor: total ? 'pointer' : 'default', accentColor: 'var(--accent)' }} />
  );
}

// Selectable skill row — checkbox selects (batch), clicking the body views details.
function SkillRow({ checked, onToggle, onView, children, oversized, sizeBytes }) {
  return (
    <div onClick={() => onView && onView()} style={{
      display: 'flex', alignItems: 'center', gap: 10, padding: '9px 11px',
      border: `1px solid ${checked ? 'var(--accent)' : 'var(--border-subtle)'}`, borderRadius: 8, marginBottom: 6,
      background: checked ? 'var(--accent-muted)' : 'var(--surface)', cursor: 'pointer',
      opacity: oversized ? 0.6 : 1,
    }}>
      {oversized
        ? <span title="Too large to upload" style={{ width: 16, textAlign: 'center', flexShrink: 0 }}>⚠</span>
        : <Chk checked={checked} onChange={onToggle} />}
      <div style={{ flex: 1, minWidth: 0 }}>{children}</div>
      {oversized && (
        <span className="t-micro text-tertiary" style={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
          Too large to upload{sizeBytes ? ` · ${_fmtBytes(sizeBytes)}` : ''}
        </span>
      )}
    </div>
  );
}

function _fmtBytes(n) {
  if (!n) return '';
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  let i = 0, v = n;
  while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
  return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
}

// Detail card shown in the right rail when a skill row is clicked.
// Long descriptions are truncated to a few lines with a hard cutoff (no fade)
// and a Show more/less toggle, so they can't push the rest of the panel out of
// view.
function DescriptionBlock({ text }) {
  const [open, setOpen] = useTwS(false);
  const needsClamp = (text || '').length > 220 || (text || '').split('\n').length > 6;
  const clampStyle = open ? {} : {
    display: '-webkit-box', WebkitLineClamp: 6, WebkitBoxOrient: 'vertical', overflow: 'hidden',
  };
  return (
    <div>
      <div className="t-small" style={{ wordBreak: 'break-word', whiteSpace: 'pre-wrap', ...clampStyle }}>{text}</div>
      {needsClamp && (
        <button className="btn sm ghost" style={{ padding: '2px 0', marginTop: 2 }} onClick={() => setOpen(o => !o)}>
          {open ? 'Show less' : 'Show more'}
        </button>
      )}
    </div>
  );
}

// On-demand modal showing the full SKILL.md text for a skill bundle.
function SkillMdModal({ orgId, bundleId, name, onClose }) {
  const [state, setState] = useTwS({ status: 'loading', content: '', files: [] });
  useTwE(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await tsGetSkillFiles(orgId, bundleId);
        if (cancelled) return;
        setState({ status: 'done', content: res.skill_md || '', files: res.files || [] });
      } catch (e) { if (!cancelled) setState({ status: 'error', content: '', files: [] }); }
    })();
    return () => { cancelled = true; };
  }, [orgId, bundleId]);
  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.4)', zIndex: 140,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: 760, maxWidth: '100%', maxHeight: '85vh', display: 'flex', flexDirection: 'column',
        background: 'var(--surface-raised)', border: '1px solid var(--border)', borderRadius: 14,
        boxShadow: 'var(--shadow-2)', padding: 18,
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
          <span style={{ fontSize: 14, fontWeight: 600, flex: 1, minWidth: 0, wordBreak: 'break-word' }}>{name} · SKILL.md</span>
          <button className="btn sm ghost" onClick={onClose} style={{ padding: 4 }}>{typeof Icons !== 'undefined' && Icons.X ? <Icons.X size={13} /> : '✕'}</button>
        </div>
        {state.status === 'loading' && <div className="text-tertiary t-small">Loading…</div>}
        {state.status === 'error' && <div className="text-tertiary t-small">Could not load SKILL.md.</div>}
        {state.status === 'done' && (
          state.content
            ? <pre className="mono-sm mono" style={{
                margin: 0, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word',
                background: 'var(--surface)', border: '1px solid var(--border-subtle)', borderRadius: 8,
                padding: 12, lineHeight: 1.5, flex: 1,
              }}>{state.content}</pre>
            : <div className="text-tertiary t-small">No SKILL.md found in this skill.</div>
        )}
      </div>
    </div>
  );
}

// One note editor (personal or team). Owns its own draft/status; calls onSave
// (which talks to the backend) and surfaces the saved/failed state inline.
function NoteEditor({ label, hint, initial, idleText, onSave }) {
  const [value, setValue] = useTwS(initial || '');
  const [status, setStatus] = useTwS('idle');
  const save = async () => {
    setStatus('saving');
    try { await onSave(value); setStatus('saved'); }
    catch (e) { setStatus('error'); }
  };
  const statusText = status === 'saving' ? 'Saving…'
    : status === 'saved' ? 'Saved'
    : status === 'error' ? 'Save failed'
    : (idleText || '');
  return (
    <div style={{ marginTop: 4, borderTop: '1px solid var(--border-subtle)', paddingTop: 12 }}>
      <div className="t-micro text-tertiary" style={{ marginBottom: 4 }}>{label}</div>
      <textarea value={value} onChange={e => { setValue(e.target.value); setStatus('idle'); }}
        placeholder={hint}
        style={{ ...fieldStyle, height: 70, padding: 8, lineHeight: 1.5, width: '100%', resize: 'vertical' }} />
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
        <button className="btn sm accent" disabled={status === 'saving'} onClick={save}>Save</button>
        <span className="mono-sm mono text-tertiary" style={{ fontSize: 11 }}>{statusText}</span>
      </div>
    </div>
  );
}

// Detail rail for a normalized skill object (see detailFrom* in SkillsPanel):
//   { kind, name, description, bundle_id, cloud_skill_id, team_skill_id,
//     user_note, team_note, team_note_updated_by/at, source_key/path/sha256, metadata }
// Two notes, both keyed to the content bundle:
//   - personal (user_note) — only you; editable when you own a cloud copy.
//   - team (team_note) — shared; editable when the skill is in the team library.
// The backend links them while equal and reports `skipped_notes` for divergent
// copies, which we surface as a toast.
function SkillDetail({ item, onClose, orgId, onSaved }) {
  const [mdOpen, setMdOpen] = useTwS(false);
  const [flash, showFlash] = useFlash();
  // Bumped after a save to remount the note editors so a linked counterpart
  // note (moved server-side) shows its new value immediately.
  const [rev, setRev] = useTwS(0);
  if (!item) return null;
  const isInventory = item.kind === 'inventory';
  // Location:
  //  - local skill (inventory): bound to a device → just its on-disk Path.
  //  - cloud skill (My Cloud): bound to the user → where it lives across
  //    devices, shown as machine:path (one per device that has the bundle).
  const instances = item.instances || [];

  const announce = (res) => {
    const n = (res && res.skipped_notes && res.skipped_notes.length) || 0;
    showFlash(n ? `已保存。有 ${n} 处笔记内容不同,未联动更新。` : '已保存');
  };
  const savePersonal = async (value) => {
    const oldP = item.user_note || '', oldT = item.team_note || '';
    const res = await tsSavePersonalNote(orgId, item.cloud_skill_id, value);
    item.user_note = value;
    // link-while-equal: if the team note matched the old personal value, the
    // backend moved it too — reflect that in the open panel right away.
    if (item.team_skill_id && oldT === oldP) item.team_note = value;
    announce(res);
    setRev(r => r + 1);          // remount editors → show the synced value now
    if (onSaved) onSaved();      // re-pull lists from the backend for reopen consistency
  };
  const saveTeam = async (value) => {
    const oldP = item.user_note || '', oldT = item.team_note || '';
    const res = await tsSaveTeamNote(orgId, item.team_skill_id, value);
    item.team_note = value;
    if (item.cloud_skill_id && oldP === oldT) item.user_note = value;
    announce(res);
    setRev(r => r + 1);
    if (onSaved) onSaved();
  };
  const teamIdle = item.team_note_updated_at
    ? `Updated by ${item.team_note_updated_by || 'a teammate'} · ${new Date(item.team_note_updated_at).toLocaleString()}`
    : '';

  return (
    <div className="card" style={{ padding: 16, marginTop: 16 }}>
      <TsFlash flash={flash} />
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
        <span style={{ fontSize: 14, fontWeight: 600, flex: 1, minWidth: 0, wordBreak: 'break-word' }}>{item.name}</span>
        <button className="btn sm ghost" onClick={onClose} style={{ padding: 4 }}>{typeof Icons !== 'undefined' && Icons.X ? <Icons.X size={13} /> : '✕'}</button>
      </div>
      {item.bundle_id && (
        <div style={{ marginBottom: 10 }}>
          <button className="btn sm ghost" style={{ padding: '2px 0' }} onClick={() => setMdOpen(true)}>View SKILL.md</button>
        </div>
      )}
      <div style={{ marginBottom: 10 }}>
        <div className="t-micro text-tertiary" style={{ marginBottom: 2 }}>Description</div>
        <DescriptionBlock text={item.description || 'No description provided'} />
      </div>
      {isInventory && item.path && (
        <div style={{ marginBottom: 10 }}>
          <div className="t-micro text-tertiary" style={{ marginBottom: 2 }}>Path</div>
          <div className="mono-sm mono" style={{ wordBreak: 'break-word' }}>{item.path}</div>
        </div>
      )}
      {!isInventory && instances.length > 0 && (
        <div style={{ marginBottom: 10 }}>
          <div className="t-micro text-tertiary" style={{ marginBottom: 2 }}>Source</div>
          {instances.map((ins, i) => (
            <div key={i} className="mono-sm mono" style={{ wordBreak: 'break-word', marginBottom: 2 }}>
              {ins.machine_name}: {ins.absolute_path}
            </div>
          ))}
        </div>
      )}
      {item.cloud_skill_id && (
        <NoteEditor key={`p-${item.cloud_skill_id}-${rev}`} label="My note (private)"
          hint="Private note — only you can see this"
          initial={item.user_note || ''} onSave={savePersonal} />
      )}
      {item.team_skill_id && (
        <NoteEditor key={`t-${item.team_skill_id}-${rev}`} label="Team note (shared)"
          hint="Shared with everyone in your team"
          initial={item.team_note || ''} idleText={teamIdle} onSave={saveTeam} />
      )}
      {mdOpen && item.bundle_id && (
        <SkillMdModal orgId={orgId} bundleId={item.bundle_id} name={item.name} onClose={() => setMdOpen(false)} />
      )}
    </div>
  );
}

// Selectable agent pill — selected agents are the install targets.
function AgentChip({ agent, count, selected, onToggle }) {
  return (
    <button onClick={onToggle} disabled={!onToggle} style={{
      display: 'inline-flex', alignItems: 'center', gap: 7, height: 32, padding: '0 12px', borderRadius: 999,
      border: `1px solid ${selected ? 'var(--accent)' : 'var(--border)'}`,
      background: selected ? 'var(--accent-muted)' : 'var(--surface)',
      color: selected ? 'var(--accent-text)' : 'var(--text-primary)', fontSize: 13, fontWeight: 500,
      cursor: onToggle ? 'pointer' : 'default', opacity: 1,
    }}>
      {typeof StatusDot !== 'undefined' && <StatusDot kind={agent.status === 'online' ? 'live' : 'idle'} size={7} />}
      <span>{agent.agent_type}</span>
      {agent.label && agent.label !== 'default' && <span className="mono-sm mono" style={{ opacity: 0.7 }}>{agent.label}</span>}
      <span className="mono-sm mono" style={{ opacity: 0.55 }}>· {count}</span>
    </button>
  );
}

// ── confirm dialog ───────────────────────────────────────────────────────────
function useConfirm() {
  const [req, setReq] = useTwS(null);   // { message, confirmLabel, onConfirm }
  const ask = (message, confirmLabel, onConfirm) => setReq({ message, confirmLabel, onConfirm });
  const close = () => setReq(null);
  return { req, ask, close };
}

function ConfirmDialog({ req, onClose }) {
  if (!req) return null;
  return (
    <div onClick={onClose} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.4)', zIndex: 130,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: 400, maxWidth: '100%', background: 'var(--surface-raised)',
        border: '1px solid var(--border)', borderRadius: 14, boxShadow: 'var(--shadow-2)', padding: 20,
      }}>
        <h3 className="t-h3" style={{ margin: '0 0 8px' }}>Are you sure?</h3>
        <div className="text-secondary t-small" style={{ marginBottom: 16 }}>{req.message}</div>
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <button className="btn" onClick={onClose}>Cancel</button>
          <button className="btn danger" onClick={() => { const fn = req.onConfirm; onClose(); if (fn) fn(); }}>{req.confirmLabel || 'Confirm'}</button>
        </div>
      </div>
    </div>
  );
}

// Agent picker shown when installing skills. Loads every agent across the
// user's devices and pre-selects ALL of them, so the common case is one click.
function InstallAgentsDialog({ orgId, devices, count, onCancel, onConfirm }) {
  const [targets, setTargets] = useTwS(null);   // null = loading
  const [sel, setSel] = useTwS(() => new Set());
  const [busy, setBusy] = useTwS(false);
  useTwE(() => {
    let cancelled = false;
    (async () => {
      const all = [];
      for (const d of devices) {
        try {
          const ags = await tsListAgents(orgId, d.id);
          (ags || []).forEach(a => all.push({ device_id: d.id, device_name: d.display_name, agent_id: a.id, agent_type: a.agent_type, label: a.label }));
        } catch (e) { /* skip a device we can't read */ }
      }
      if (cancelled) return;
      setTargets(all);
      setSel(new Set(all.map(t => t.agent_id)));   // default: all agents selected
    })();
    return () => { cancelled = true; };
  }, []);
  const toggle = (id) => setSel(p => { const n = new Set(p); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const chosen = (targets || []).filter(t => sel.has(t.agent_id)).map(t => ({ device_id: t.device_id, agent_id: t.agent_id }));
  const byDevice = {};
  (targets || []).forEach(t => { (byDevice[t.device_id] = byDevice[t.device_id] || { name: t.device_name, items: [] }).items.push(t); });
  return (
    <div onClick={onCancel} style={{
      position: 'fixed', inset: 0, background: 'rgba(0,0,0,.4)', zIndex: 130,
      display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
    }}>
      <div onClick={e => e.stopPropagation()} style={{
        width: 420, maxWidth: '100%', background: 'var(--surface-raised)',
        border: '1px solid var(--border)', borderRadius: 14, boxShadow: 'var(--shadow-2)', padding: 20,
      }}>
        <h3 className="t-h3" style={{ margin: '0 0 4px' }}>Install {count} skill(s) to</h3>
        <div className="text-secondary t-small" style={{ marginBottom: 12 }}>All agents are selected by default — uncheck any you want to skip.</div>
        {targets === null && <div className="skeleton" style={{ height: 80 }} />}
        {targets && targets.length === 0 && <div className="text-tertiary t-small" style={{ marginBottom: 12 }}>No agents available — enroll a device first.</div>}
        {targets && targets.length > 0 && (
          <div style={{ maxHeight: '50vh', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 14 }}>
            {Object.keys(byDevice).map(did => (
              <div key={did}>
                <div className="t-micro text-tertiary" style={{ marginBottom: 4 }}>{byDevice[did].name}</div>
                {byDevice[did].items.map(t => (
                  <label key={t.agent_id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
                    <Chk checked={sel.has(t.agent_id)} onChange={() => toggle(t.agent_id)} />
                    <span style={{ fontSize: 13 }}>{t.agent_type}{t.label && t.label !== 'default' ? ` · ${t.label}` : ''}</span>
                  </label>
                ))}
              </div>
            ))}
          </div>
        )}
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <button className="btn" onClick={onCancel}>Cancel</button>
          <button className="btn accent" disabled={busy} onClick={async () => { setBusy(true); try { await onConfirm(chosen); } finally { setBusy(false); } }}>
            Install ({chosen.length})
          </button>
        </div>
      </div>
    </div>
  );
}

// ── Auto-sync ─────────────────────────────────────────────────────────────────
// A small switch (left of the Workspace picker). The backend persists the flag
// and owns all reconciliation; this only reflects/toggles it via tsSetAutoSync.
function AutoSyncToggle({ on, busy, onToggle }) {
  return (
    <label style={{ display: 'inline-flex', alignItems: 'center', gap: 9, cursor: busy ? 'default' : 'pointer', userSelect: 'none' }}
      title="Install all your My Cloud skills to every agent on all your devices">
      <span className="t-micro" style={{ whiteSpace: 'nowrap', fontWeight: 600, color: on ? 'var(--accent-text)' : 'var(--text-secondary)' }}>Auto sync</span>
      <button type="button" className="as-switch" data-on={on ? 'true' : 'false'} disabled={busy}
        role="switch" aria-checked={on} onClick={() => { if (!busy) onToggle(!on); }}>
        <span className="as-track-label">{on ? 'ON' : 'OFF'}</span>
        <span className="as-knob" />
      </button>
    </label>
  );
}

// First-run sync animation: a central "My Cloud" orb emits skill tokens down
// beams into every agent node, which resolve to a check. Driven entirely by the
// computed plan so the counts shown are real. Portaled to <body>.
function AutoSyncOverlay({ plan, task, onDone }) {
  const [phase, setPhase] = useTwS('flow');   // 'flow' (streaming) → 'done' (checks)
  useTwE(() => {
    // Stay in the streaming phase until the actual sync work (`task`) settles —
    // so the animation never ends while installs are still landing — but keep a
    // minimum streaming time so a fast/instant sync still reads as an animation.
    let cancelled = false;
    let closeTimer = null;
    let reduced = false;
    try { reduced = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); } catch (e) { /* no matchMedia */ }
    const minFlowMs = reduced ? 300 : 1600;
    const doneHoldMs = reduced ? 400 : 1100;
    const work = (task && typeof task.then === 'function') ? task : Promise.resolve();
    const minDelay = new Promise(res => setTimeout(res, minFlowMs));
    Promise.all([minDelay, work.catch(() => {})]).then(() => {
      if (cancelled) return;
      setPhase('done');
      closeTimer = setTimeout(() => { if (!cancelled && onDone) onDone(); }, doneHoldMs);
    });
    return () => { cancelled = true; if (closeTimer) clearTimeout(closeTimer); };
  }, []);
  if (!plan) return null;
  // plan is the backend AutoSyncPlanRead: { skills_synced, skipped,
  // agents:[{ device_id, device_name, agent_id, agent_type, label, skill_count }] }.
  const agents = plan.agents || [];
  const flowing = phase === 'flow';
  const skillsSynced = plan.skills_synced || 0;
  const skipped = plan.skipped || 0;

  // Group agents under their device for a tidy column-per-device layout.
  const byDevice = [];
  const devIndex = {};
  agents.forEach(a => {
    if (devIndex[a.device_id] == null) { devIndex[a.device_id] = byDevice.length; byDevice.push({ id: a.device_id, name: a.device_name, agents: [] }); }
    byDevice[devIndex[a.device_id]].agents.push(a);
  });

  let ord = 0;   // global agent ordinal → stagger
  const node = (
    <div style={{
      position: 'fixed', inset: 0, zIndex: 200, padding: 24,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: 'rgba(10,10,11,.55)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)',
      animation: 'parcle-as-overlay-in 220ms ease both',
    }}>
      <div style={{
        width: 760, maxWidth: '100%', maxHeight: '86vh', overflow: 'auto',
        background: 'var(--surface-raised)', border: '1px solid var(--border)', borderRadius: 16,
        boxShadow: 'var(--shadow-2)', padding: '26px 24px 22px',
        animation: 'parcle-as-pop 320ms cubic-bezier(.34,1.56,.64,1) both',
        textAlign: 'center',
      }}>
        {/* Cloud orb + pulse rings */}
        <div style={{ position: 'relative', height: 96, display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 4 }}>
          {flowing && [0, 1, 2].map(i => (
            <span key={i} style={{
              position: 'absolute', left: '50%', top: '50%', width: 72, height: 72, borderRadius: '50%',
              border: '2px solid var(--accent)', pointerEvents: 'none', animationDelay: `${i * 0.73}s`,
            }} className="parcle-as-ring" />
          ))}
          <div className="parcle-as-orb" style={{
            width: 72, height: 72, borderRadius: '50%',
            background: 'radial-gradient(circle at 35% 30%, var(--accent-hover), var(--accent))',
            color: 'var(--on-accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontSize: 30, position: 'relative', zIndex: 1,
          }}>
            {typeof Icons !== 'undefined' && Icons.Cloud ? <Icons.Cloud size={32} /> : '☁'}
          </div>
        </div>
        <div style={{ fontSize: 16, fontWeight: 600 }}>{flowing ? 'Auto-sync starting…' : 'Auto-sync on'}</div>
        <div className="text-secondary t-small" style={{ marginBottom: 18 }}>
          {flowing
            ? `Pushing ${skillsSynced} skill(s) to ${agents.length} agent(s) across ${byDevice.length} device(s)`
            : `Synced ${skillsSynced} skill(s) to ${agents.length} agent(s)${skipped ? ` · ${skipped} skipped (no matching agent)` : ''}`}
        </div>

        {agents.length === 0 ? (
          <div className="text-tertiary t-small" style={{ padding: '12px 0' }}>No agents online to sync.</div>
        ) : (
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: 18, justifyContent: 'center', alignItems: 'flex-start' }}>
            {byDevice.map(dev => (
              <div key={dev.id} style={{
                minWidth: 150, padding: '10px 12px 12px', borderRadius: 12,
                border: '1px solid var(--border-subtle)', background: 'var(--surface)',
              }}>
                <div className="t-micro text-tertiary" style={{ marginBottom: 10, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{dev.name || 'Device'}</div>
                <div style={{ display: 'flex', gap: 14, justifyContent: 'center', flexWrap: 'wrap' }}>
                  {dev.agents.map(a => {
                    const delay = `${(ord++ % 8) * 0.12}s`;
                    const n = a.skill_count || 0;
                    return (
                      <div key={a.agent_id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, width: 64 }}>
                        {/* beam + streaming token */}
                        <div style={{ position: 'relative', width: 2, height: 38, borderRadius: 2,
                          background: 'linear-gradient(var(--accent), transparent)' }}
                          className={flowing ? 'parcle-as-beam' : ''}>
                          {flowing && (
                            <span className="parcle-as-token" style={{
                              position: 'absolute', left: -3, top: 0, width: 8, height: 8, borderRadius: '50%',
                              background: 'var(--accent)', boxShadow: '0 0 8px 2px var(--accent-muted)', animationDelay: delay,
                            }} />
                          )}
                        </div>
                        {/* agent node */}
                        <div style={{
                          width: 44, height: 44, borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center',
                          border: `1px solid ${flowing ? 'var(--border)' : 'var(--accent)'}`,
                          background: flowing ? 'var(--surface-raised)' : 'var(--accent-muted)',
                          color: 'var(--accent-text)', transition: 'all 200ms ease', position: 'relative',
                        }}>
                          {flowing ? (
                            <span className="parcle-as-spin" style={{
                              width: 16, height: 16, borderRadius: '50%',
                              border: '2px solid var(--border)', borderTopColor: 'var(--accent)', display: 'inline-block',
                            }} />
                          ) : (
                            <span className="parcle-as-check" style={{ color: 'var(--accent-text)', fontSize: 18, lineHeight: 1 }}>
                              {typeof Icons !== 'undefined' && Icons.Check ? <Icons.Check size={18} /> : '✓'}
                            </span>
                          )}
                        </div>
                        <span className="mono-sm mono" style={{ fontSize: 10, maxWidth: 64, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.agent_type}</span>
                        <span className="t-micro text-tertiary" style={{ fontSize: 9 }}>{n} skill{n === 1 ? '' : 's'}</span>
                      </div>
                    );
                  })}
                </div>
              </div>
            ))}
          </div>
        )}

        <div style={{ marginTop: 20 }}>
          <button className="btn accent" disabled={flowing} onClick={() => { if (onDone) onDone(); }}>
            {flowing ? 'Syncing…' : 'Done'}
          </button>
        </div>
      </div>
    </div>
  );
  return (typeof ReactDOM !== 'undefined' && ReactDOM.createPortal)
    ? ReactDOM.createPortal(node, document.body)
    : node;
}

// ── SkillsPanel ───────────────────────────────────────────────────────────────
// One agent's installed-skills group inside the Installed skills section, with
// its own collapse toggle. Top-level (not nested in SkillsPanel) so its
// collapsed state survives SkillsPanel's frequent re-renders (polling, etc).
function AgentSkillGroup({ agent, items, selInv, inventory, onView, toggleAndView, detailFromInv }) {
  const [collapsed, setCollapsed] = useTwS(false);
  const isOversized = (it) => !!(it.metadata && it.metadata.oversized);
  // Over-limit skills are shown for awareness but can't be uploaded, so they're
  // never selectable (keeps them out of select-all and the import action).
  const selectableIds = items.filter(it => !isOversized(it)).map(i => i.id);
  return (
    <div style={{ marginBottom: 12 }}>
      <div className="t-micro text-tertiary" style={{ marginBottom: collapsed ? 0 : 6, display: 'flex', alignItems: 'center', gap: 7, fontSize: 13 }}>
        {selectableIds.length > 0 && <SelectAllChk ids={selectableIds} sel={selInv} />}
        <span onClick={() => setCollapsed(c => !c)} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, cursor: 'pointer', userSelect: 'none' }}>
          <span className="text-tertiary" style={{ fontSize: 10, width: 9, display: 'inline-block' }}>{collapsed ? '▶' : '▼'}</span>
          {typeof StatusDot !== 'undefined' && <StatusDot kind={agent.status === 'online' ? 'live' : 'idle'} size={8} />}
          {agent.agent_type}{agent.label && agent.label !== 'default' ? ` · ${agent.label}` : ''}
          <span style={{ opacity: 0.65 }}>· {items.length}</span>
        </span>
      </div>
      {!collapsed && items.length === 0 && <div className="text-tertiary t-small" style={{ marginBottom: 6 }}>No skills installed.</div>}
      {!collapsed && items.length > 0 && (
        <div style={skillGridStyle}>
          {items.map(it => {
            const oversized = isOversized(it);
            return (
              <SkillRow key={it.id} oversized={oversized}
                sizeBytes={it.metadata && it.metadata.bundle_size_bytes}
                checked={!oversized && selInv.ids.has(it.id)}
                onToggle={oversized ? null : () => toggleAndView(selInv, it.id, inventory, x => x.id, detailFromInv)}
                onView={() => onView(detailFromInv(it))}>
                <span style={skillNameStyle}>{it.name}</span>
              </SkillRow>
            );
          })}
        </div>
      )}
    </div>
  );
}

