/* ACL / Access controls. The policy is one opaque HuJSON document
   (GET/PUT /api/v1/policy). `ACL` is a thin router: the raw editor is the
   EXPERT lens; the structured lenses (Rules, SSH, Groups, Tag owners, Hosts,
   Auto-approve) are NOVICE lenses over the SAME document — each parses the
   text, edits one block, and writes it back via a comment-preserving splice.
   Both modes edit one source of truth. file mode = read-only everywhere. */
const ACL_LENSES = [
  { id: "policy", label: "Raw policy", icon: "code", group: "Expert" },
  { id: "rules", label: "Rules", icon: "shield", group: "Structured" },
  { id: "ssh", label: "SSH", icon: "server", group: "Structured" },
  { id: "groups", label: "Groups", icon: "users", group: "Structured" },
  { id: "tagOwners", label: "Tag owners", icon: "tag", group: "Structured" },
  { id: "hosts", label: "Hosts", icon: "dns", group: "Structured" },
  { id: "auto", label: "Auto-approve", icon: "routes", group: "Structured" },
  { id: "docs", label: "Reference", icon: "book", group: "Help" },
];

// maps a lens/block to its Reference section id (for the contextual ? help)
const ACL_DOC_SECTION = { policy: "how", rules: "acls", ssh: "ssh", groups: "groups", tagOwners: "tagowners", hosts: "hosts", auto: "autoapprovers" };

function ACL() {
  const s = useStore();
  const [tab, setTab] = useState(() => localStorage.getItem("hs.acltab") || "rules");
  const [mode, setMode] = useState(s.aclPolicy.mode); // database | file
  useEffect(() => { localStorage.setItem("hs.acltab", tab); }, [tab]);
  const shared = { tab, setTab, mode, setMode };
  switch (tab) {
    case "policy": return <PolicyEditor {...shared} />;
    case "groups": return <GroupsEditor {...shared} />;
    case "rules": return <RulesLens {...shared} />;
    case "ssh": return <SshLens {...shared} />;
    case "tagOwners": return <TagOwnersLens {...shared} />;
    case "hosts": return <HostsLens {...shared} />;
    case "auto": return <AutoApproveLens {...shared} />;
    case "docs": return <AclDocs {...shared} />;
    default: return <RulesLens {...shared} />;
  }
}

function AclTabs({ tab, setTab, mode, setMode }) {
  const cur = ACL_LENSES.find((l) => l.id === tab) || ACL_LENSES[0];
  return (
    <>
      <Menu align="left" width={210} trigger={
        <button className="btn"><Ic name={cur.icon} size={14} />{cur.label}<Ic name="chevDown" size={13} style={{ marginLeft: 2 }} /></button>
      }>
        <div className="menu-label">Expert</div>
        {ACL_LENSES.filter((l) => l.group === "Expert").map((l) => <MenuItem key={l.id} icon={l.id === tab ? "check" : l.icon} onClick={() => setTab(l.id)}>{l.label}</MenuItem>)}
        <div className="menu-label">Structured (novice)</div>
        {ACL_LENSES.filter((l) => l.group === "Structured").map((l) => <MenuItem key={l.id} icon={l.id === tab ? "check" : l.icon} onClick={() => setTab(l.id)}>{l.label}</MenuItem>)}
        <div className="menu-label">Help</div>
        {ACL_LENSES.filter((l) => l.group === "Help").map((l) => <MenuItem key={l.id} icon={l.id === tab ? "check" : l.icon} onClick={() => setTab(l.id)}>{l.label}</MenuItem>)}
      </Menu>
      <div className="seg" title="policy.mode in config.yaml">
        <button className={mode === "database" ? "on" : ""} onClick={() => setMode("database")}>database</button>
        <button className={mode === "file" ? "on" : ""} onClick={() => setMode("file")}>file</button>
      </div>
    </>
  );
}

/* Raw HuJSON policy editor (GET/PUT /api/v1/policy). */
function PolicyEditor({ tab, setTab, mode, setMode }) {
  const s = useStore();
  const toast = useToast();
  const [text, setText] = useState(s.aclPolicy.text);
  const [dirty, setDirty] = useState(false);
  const [showDiff, setShowDiff] = useState(false);
  const taRef = useRef(null);
  const preRef = useRef(null);
  const readOnly = mode === "file";

  const valid = useMemo(() => validateHujson(text), [text]);
  const semantic = useMemo(() => semanticCheck(text), [text]);

  const onScroll = () => { if (preRef.current && taRef.current) { preRef.current.scrollTop = taRef.current.scrollTop; preRef.current.scrollLeft = taRef.current.scrollLeft; } };

  const doSave = () => {
    if (!valid.ok) { toast.push({ kind: "error", title: "Invalid policy", msg: valid.msg }); return; }
    HS.act.saveAcl(text); setDirty(false); setShowDiff(false);
    toast.push({ kind: "success", title: "Policy saved", msg: "PUT /api/v1/policy" });
  };

  const counts = useMemo(() => ({
    acls: (text.match(/"action"\s*:/g) || []).length,
    groups: (text.match(/"group:[^"]+"\s*:/g) || []).length,
    ssh: ((text.match(/"ssh"\s*:/) ? text.split('"ssh"')[1] : "") || "").match(/"action"\s*:/g)?.length || 0,
    autoR: Object.keys(s.autoApprovers.routes).length,
    autoE: (s.autoApprovers.exitNode || []).length,
  }), [text]);

  return (
    <div className="page wide fade-up" style={{ height: "100%", display: "flex", flexDirection: "column", paddingBottom: 24 }}>
      <div className="section-head">
        <div>
          <h2 style={{ fontSize: 19 }}>Access controls</h2>
          <div className="sub">HuJSON policy (v2) — tags, groups, autoApprovers, ACL &amp; SSH rules</div>
        </div>
        <div className="spacer" />
        <AclTabs tab={tab} setTab={setTab} mode={mode} setMode={setMode} />
        {valid.ok ? <span className="badge online"><span className="dot" />Valid</span> : <span className="badge danger"><Ic name="warn" size={12} />{valid.msg}</span>}
        <button className="btn" onClick={() => { setText(s.aclPolicy.text); setDirty(false); }} disabled={!dirty || readOnly}>Revert</button>
        <button className="btn primary" onClick={() => setShowDiff(true)} disabled={!dirty || readOnly || !valid.ok}><Ic name="check" size={15} />Review &amp; save</button>
      </div>

      {readOnly && (
        <div style={{ marginBottom: 14 }}>
          <Banner cls="warn" icon="shield"><b>Policy is file-managed (<span className="code-inline">policy.mode: file</span>).</b> It's owned by a file on disk and read-only from the API — <span className="code-inline">PUT /api/v1/policy</span> would fail. Switch the server to <span className="code-inline">database</span> mode to edit here.</Banner>
        </div>
      )}

      <div style={{ display: "grid", gridTemplateColumns: "1fr 280px", gap: 14, flex: 1, minHeight: 0 }} className="acl-cols">
        {/* editor */}
        <div className="card" style={{ display: "flex", flexDirection: "column", overflow: "hidden", minHeight: 420 }}>
          <div className="tbl-toolbar" style={{ flexShrink: 0 }}>
            <Ic name="code" size={15} style={{ color: "var(--text-dim)" }} />
            <span className="mono" style={{ fontSize: 12.5 }}>policy.hujson</span>
            {readOnly && <span className="badge" style={{ height: 18 }}>read-only</span>}
            {dirty && !readOnly && <span className="badge warn" style={{ height: 18 }}>unsaved</span>}
            <div style={{ flex: 1 }} />
            <span className="copy-btn" onClick={() => { navigator.clipboard?.writeText(text); toast.push({ kind: "success", title: "Copied policy" }); }}><Ic name="copy" size={15} /></span>
          </div>
          <div className="code-edit" style={{ position: "relative", flex: 1, overflow: "hidden" }}>
            <pre ref={preRef} className="code-hl" aria-hidden="true" dangerouslySetInnerHTML={{ __html: highlightHujson(text) + "\n" }} />
            <textarea ref={taRef} className="code-ta mono" spellCheck={false} value={text} readOnly={readOnly}
              onChange={(e) => { setText(e.target.value); setDirty(true); }} onScroll={onScroll} />
          </div>
        </div>

        {/* side: structure + checks */}
        <div style={{ display: "flex", flexDirection: "column", gap: 14, overflowY: "auto", minHeight: 0 }}>
          <div className="card card-pad">
            <div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".06em", color: "var(--text-faint)", fontWeight: 600, marginBottom: 10 }}>Policy summary</div>
            <div style={{ display: "flex", flexDirection: "column", gap: 9 }}>
              <SummaryRow icon="tag" label="Tag owners" value={s.tagOwners.length} />
              <SummaryRow icon="users" label="Groups" value={counts.groups} />
              <SummaryRow icon="shield" label="ACL rules" value={counts.acls} />
              <SummaryRow icon="server" label="SSH rules" value={counts.ssh} />
              <SummaryRow icon="routes" label="Auto-approve routes" value={counts.autoR} />
              <SummaryRow icon="exit" label="Auto-approve exit" value={counts.autoE} />
            </div>
          </div>

          <div className="card card-pad">
            <div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".06em", color: "var(--text-faint)", fontWeight: 600, marginBottom: 10 }}>Semantic check</div>
            {semantic.ok
              ? <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5, color: "var(--online)" }}><Ic name="check" size={15} />All references resolve</div>
              : <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
                  {semantic.issues.map((m, i) => <div key={i} style={{ display: "flex", gap: 7, fontSize: 12, color: "var(--warn)" }}><Ic name="warn" size={14} style={{ flexShrink: 0, marginTop: 1 }} />{m}</div>)}
                </div>}
            <div style={{ fontSize: 11, color: "var(--text-faint)", marginTop: 8 }}>Mirrors <span className="code-inline">headscale policy check</span>.</div>
          </div>

          <div className="card card-pad" style={{ background: "var(--surface-2)" }}>
            <div style={{ display: "flex", gap: 9, color: "var(--text-dim)" }}>
              <Ic name="info" size={16} style={{ flexShrink: 0, marginTop: 1 }} />
              <div style={{ fontSize: 12, lineHeight: 1.5 }}>The API treats policy as an opaque HuJSON string. <span className="code-inline">autogroup:*</span>, comments and trailing commas are supported.</div>
            </div>
          </div>
        </div>
      </div>

      {showDiff && (
        <Modal title="Review policy changes" subtitle="Diff against the live policy before applying" icon="acl" size="xl" onClose={() => setShowDiff(false)}
          footer={<><span style={{ fontSize: 12, color: "var(--text-faint)" }}>{!semantic.ok && "⚠ semantic warnings present"}</span><div className="spacer" /><button className="btn" onClick={() => setShowDiff(false)}>Cancel</button><button className="btn primary" onClick={doSave}>Apply policy</button></>}>
          <DiffView before={s.aclPolicy.text} after={text} />
        </Modal>
      )}

      <style>{`
        .code-edit .code-hl, .code-edit .code-ta {
          margin: 0; position: absolute; inset: 0; padding: 16px 18px; white-space: pre; overflow: auto;
          font-family: var(--font-mono); font-size: 12.5px; line-height: 1.65; tab-size: 2;
        }
        .code-edit .code-hl { pointer-events: none; color: var(--text); z-index: 1; }
        .code-edit .code-ta { z-index: 2; background: transparent; border: none; outline: none; resize: none; color: transparent; caret-color: var(--accent); }
        .code-edit .code-ta[readonly] { caret-color: transparent; }
        .hl-key { color: var(--accent); }
        .hl-str { color: var(--online); }
        .hl-com { color: var(--text-faint); font-style: italic; }
        .hl-punc { color: var(--text-dim); }
        .hl-tag { color: var(--info); }
        .hl-auto { color: var(--warn); font-weight: 600; }
        @media (max-width: 940px){ .acl-cols{ grid-template-columns: 1fr; } }
      `}</style>
    </div>
  );
}

function SummaryRow({ icon, label, value }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
      <Ic name={icon} size={15} style={{ color: "var(--text-dim)" }} />
      <span style={{ fontSize: 13, flex: 1 }}>{label}</span>
      <span className="mono" style={{ fontWeight: 600 }}>{value}</span>
    </div>
  );
}

/* line diff (LCS-free, simple) for the review modal */
function DiffView({ before, after }) {
  const a = before.split("\n"), b = after.split("\n");
  const bSet = new Set(b), aSet = new Set(a);
  const rows = [];
  // crude alignment: walk both, mark removed (in a not b) and added (in b not a)
  const max = Math.max(a.length, b.length);
  // build using a simple two-pointer on equality
  let i = 0, j = 0;
  while (i < a.length || j < b.length) {
    if (i < a.length && j < b.length && a[i] === b[j]) { rows.push({ t: " ", txt: a[i] }); i++; j++; }
    else if (j < b.length && !aSet.has(b[j])) { rows.push({ t: "+", txt: b[j] }); j++; }
    else if (i < a.length && !bSet.has(a[i])) { rows.push({ t: "-", txt: a[i] }); i++; }
    else { if (i < a.length) { rows.push({ t: "-", txt: a[i] }); i++; } if (j < b.length) { rows.push({ t: "+", txt: b[j] }); j++; } }
  }
  const color = (t) => t === "+" ? "var(--online)" : t === "-" ? "var(--danger)" : "var(--text-dim)";
  const bg = (t) => t === "+" ? "var(--online-soft)" : t === "-" ? "var(--danger-soft)" : "transparent";
  const changed = rows.filter((r) => r.t !== " ").length;
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      <div style={{ fontSize: 12.5, color: "var(--text-dim)" }}>{changed === 0 ? "No line changes." : `${rows.filter(r=>r.t==="+").length} added · ${rows.filter(r=>r.t==="-").length} removed`}</div>
      <div style={{ border: "1px solid var(--border)", borderRadius: 8, overflow: "auto", maxHeight: "52vh", fontFamily: "var(--font-mono)", fontSize: 12, lineHeight: 1.6 }}>
        {rows.map((r, i) => (
          <div key={i} style={{ display: "flex", background: bg(r.t), color: color(r.t), padding: "0 10px", whiteSpace: "pre" }}>
            <span style={{ width: 16, flexShrink: 0, opacity: .7 }}>{r.t}</span>
            <span style={{ color: r.t === " " ? "var(--text-dim)" : color(r.t) }}>{r.txt || " "}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

function escapeHtml(s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
function highlightHujson(src) {
  return escapeHtml(src).split("\n").map((line) => {
    const cm = line.match(/^(\s*)(\/\/.*)$/);
    if (cm) return `${cm[1]}<span class="hl-com">${cm[2]}</span>`;
    let out = line.replace(/"([^"]*)"(\s*:)?/g, (m, content, colon) => {
      if (colon) return `<span class="hl-key">"${content}"</span><span class="hl-punc">${colon}</span>`;
      let cls = "hl-str";
      if (content.startsWith("autogroup:")) cls = "hl-auto";
      else if (content.startsWith("tag:") || content.startsWith("group:")) cls = "hl-tag";
      return `<span class="${cls}">"${content}"</span>`;
    });
    out = out.replace(/([{}\[\],])/g, '<span class="hl-punc">$1</span>');
    return out;
  }).join("\n");
}
function validateHujson(src) {
  try {
    let s = src.replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1");
    JSON.parse(s);
    return { ok: true };
  } catch (e) {
    const m = String(e.message).match(/position (\d+)/);
    return { ok: false, msg: "Syntax error" + (m ? ` near char ${m[1]}` : "") };
  }
}
/* semantic check: do referenced tags/groups/hosts/autogroups resolve? */
function semanticCheck(src) {
  const v = validateHujson(src);
  if (!v.ok) return { ok: false, issues: ["Fix syntax errors first"] };
  let obj;
  try { obj = JSON.parse(src.replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1")); } catch { return { ok: true }; }
  const tags = new Set(Object.keys(obj.tagOwners || {}));
  const groups = new Set(Object.keys(obj.groups || {}));
  const hosts = new Set(Object.keys(obj.hosts || {}));
  const issues = [];
  const KNOWN_AUTO = new Set(["autogroup:self", "autogroup:member", "autogroup:members", "autogroup:internet", "autogroup:tagged", "autogroup:nonroot"]);
  const refs = [];
  (obj.acls || []).forEach((r) => { (r.src || []).concat(r.dst || []).forEach((x) => refs.push(x)); });
  (obj.ssh || []).forEach((r) => { (r.src || []).concat(r.dst || []).forEach((x) => refs.push(x)); });
  refs.forEach((ref) => {
    const base = String(ref).split(":").slice(0, 2).join(":").replace(/:\*$/, "");
    const head = String(ref).split(":")[0];
    if (head === "autogroup") { if (!KNOWN_AUTO.has(base) && !KNOWN_AUTO.has(head + ":" + String(ref).split(":")[1])) issues.push(`Unknown ${base}`); return; }
    if (head === "tag" && !tags.has("tag:" + String(ref).split(":")[1])) issues.push(`Tag tag:${String(ref).split(":")[1]} has no owner`);
    if (head === "group" && !groups.has("group:" + String(ref).split(":")[1])) issues.push(`Group group:${String(ref).split(":")[1]} undefined`);
  });
  // dedupe
  const uniq = [...new Set(issues)].slice(0, 5);
  return { ok: uniq.length === 0, issues: uniq };
}

/* ============================================================
   Groups editor — a structured lens over the policy `groups` block.
   Groups have no standalone API: edits serialize back into the policy
   HuJSON and PUT /api/v1/policy. Members are users (username@); groups
   cannot nest or contain tags. Distinct from built-in autogroup:*.
   ============================================================ */
function GroupsEditor({ tab, setTab, mode, setMode }) {
  const s = useStore();
  const toast = useToast();
  const readOnly = mode === "file";
  const baseObj = useMemo(() => parsePolicyObj(s.aclPolicy.text), [s.aclPolicy.text]);
  const syntaxOk = !!baseObj;

  // rows track original key so renames can rewrite references
  const [rows, setRows] = useState(() => toRows(baseObj));
  const [confirmDel, setConfirmDel] = useState(null);
  const [showDiff, setShowDiff] = useState(false);
  useEffect(() => { setRows(toRows(baseObj)); }, [s.aclPolicy.text]);

  const dirty = useMemo(() => JSON.stringify(rows) !== JSON.stringify(toRows(baseObj)), [rows, baseObj]);
  const renames = rows.filter((r) => r.orig && r.key !== r.orig).map((r) => ({ from: r.orig, to: r.key }));
  const groupsObj = Object.fromEntries(rows.map((r) => [r.key, r.members]));
  const dupKey = rows.some((r, i) => rows.findIndex((x) => x.key === r.key) !== i);
  const badKey = rows.some((r) => !/^group:[a-z0-9-]+$/.test(r.key));

  const proposedText = useMemo(() => {
    let t = s.aclPolicy.text;
    renames.forEach((r) => { t = renameGroupInPolicy(t, r.from, r.to); });
    return writeGroupsBlock(t, groupsObj);
  }, [rows, s.aclPolicy.text]);

  const upd = (i, patch) => setRows((rs) => rs.map((r, j) => j === i ? { ...r, ...patch } : r));
  const addMember = (i, uname) => setRows((rs) => rs.map((r, j) => j === i && !r.members.includes(uname + "@") ? { ...r, members: [...r.members, uname + "@"] } : r));
  const rmMember = (i, m) => upd(i, { members: rows[i].members.filter((x) => x !== m) });
  const addGroup = () => { let n = 1, k; do { k = "group:new" + (n > 1 ? n : ""); n++; } while (rows.some((r) => r.key === k)); setRows((rs) => [...rs, { key: k, orig: null, members: [] }]); };
  const save = () => { HS.act.saveAcl(proposedText); toast.push({ kind: "success", title: "Groups saved to policy", msg: "PUT /api/v1/policy" }); setShowDiff(false); };

  return (
    <div className="page wide fade-up" style={{ paddingBottom: 24 }}>
      <div className="section-head">
        <div>
          <h2 style={{ fontSize: 19 }}>Access controls</h2>
          <div className="sub">Groups — a structured view of the policy <span className="code-inline">groups</span> block</div>
        </div>
        <div className="spacer" />
        <AclTabs tab={tab} setTab={setTab} mode={mode} setMode={setMode} />
        <button className="btn" disabled={!dirty || readOnly} onClick={() => setRows(toRows(baseObj))}>Revert</button>
        <button className="btn primary" disabled={!dirty || readOnly || !syntaxOk || dupKey || badKey} onClick={() => setShowDiff(true)}><Ic name="check" size={15} />Review &amp; save</button>
      </div>

      {readOnly && <div style={{ marginBottom: 14 }}><Banner cls="warn" icon="shield"><b>Policy is file-managed.</b> Groups are read-only here — switch the server to <span className="code-inline">database</span> mode to edit.</Banner></div>}
      {!syntaxOk && <div style={{ marginBottom: 14 }}><Banner cls="danger" icon="warn"><b>Policy has syntax errors.</b> Fix them in the <a style={{ textDecoration: "underline", cursor: "pointer" }} onClick={() => setTab("policy")}>Policy</a> tab before editing groups.</Banner></div>}
      {(dupKey || badKey) && <div style={{ marginBottom: 14 }}><Banner cls="danger" icon="warn">Group names must be unique and match <span className="code-inline">group:[a-z0-9-]</span>.</Banner></div>}

      {syntaxOk && (
        <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
          {rows.length === 0 && <div className="card"><div className="empty"><Ic name="users" size={36} /><h3>No groups defined</h3><div>Groups are a naming layer over users, referenced from ACLs, tagOwners and SSH rules.</div></div></div>}
          {rows.map((r, i) => {
            const refs = r.orig ? groupRefs(baseObj, r.orig) : [];
            return (
              <div key={i} className="card card-pad" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
                <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
                  <Ic name="users" size={18} style={{ color: "var(--accent)" }} />
                  {readOnly
                    ? <span className="mono" style={{ fontWeight: 600, fontSize: 14 }}>{r.key}</span>
                    : <input className="input mono" style={{ width: 220, height: 32, fontWeight: 600 }} value={r.key} onChange={(e) => upd(i, { key: e.target.value.replace(/[^a-z0-9:-]/gi, "").toLowerCase() })} />}
                  {r.orig && r.key !== r.orig && <span className="badge warn" style={{ height: 19 }}>renamed from {r.orig}</span>}
                  {!r.orig && <span className="badge accent" style={{ height: 19 }}>new</span>}
                  <span className="badge" style={{ height: 19 }}>{r.members.length} member{r.members.length !== 1 ? "s" : ""}</span>
                  <div style={{ flex: 1 }} />
                  {!readOnly && <button className="btn icon sm ghost" onClick={() => setConfirmDel({ i, row: r, refs })}><Ic name="trash" size={15} /></button>}
                </div>

                {/* members */}
                <div style={{ display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" }}>
                  {r.members.length === 0 && <span style={{ fontSize: 12.5, color: "var(--text-faint)" }}>No members</span>}
                  {r.members.map((m) => {
                    const u = s.users.find((x) => x.name + "@" === m);
                    return (
                      <span key={m} className="tag" style={{ height: 24, paddingLeft: 4 }}>
                        {u ? <Avatar user={u} size={16} /> : <Ic name="warn" size={12} style={{ color: "var(--warn)" }} />}
                        <span className="mono">{m}</span>
                        {!readOnly && <span className="x" onClick={() => rmMember(i, m)}><Ic name="x" size={11} /></span>}
                      </span>
                    );
                  })}
                  {!readOnly && (
                    <Menu align="left" trigger={<button className="btn sm" style={{ height: 24 }}><Ic name="plus" size={13} />Add member</button>}>
                      <div className="menu-label">Users</div>
                      {s.users.filter((u) => !r.members.includes(u.name + "@")).map((u) => <MenuItem key={u.id} onClick={() => addMember(i, u.name)}><Avatar user={u} size={18} />{u.name}<span className="meta">{u.name}@</span></MenuItem>)}
                      {s.users.every((u) => r.members.includes(u.name + "@")) && <div className="menu-item" style={{ color: "var(--text-faint)" }}>All users added</div>}
                    </Menu>
                  )}
                </div>

                {/* reverse index — the high-value part */}
                <div style={{ borderTop: "1px solid var(--border)", paddingTop: 10, display: "flex", gap: 9, alignItems: "flex-start" }}>
                  <Ic name="link" size={14} style={{ color: "var(--text-faint)", marginTop: 2, flexShrink: 0 }} />
                  {refs.length === 0
                    ? <span style={{ fontSize: 12, color: "var(--text-faint)" }}>Not referenced by any rule{!r.orig ? " yet" : ""}.</span>
                    : <div style={{ display: "flex", flexWrap: "wrap", gap: 5, alignItems: "center" }}>
                        <span style={{ fontSize: 12, color: "var(--text-dim)" }}>Referenced by</span>
                        {refs.map((rf, k) => <span key={k} className="tag muted" style={{ fontFamily: "var(--font-sans)" }}>{rf}</span>)}
                      </div>}
                </div>
              </div>
            );
          })}

          {!readOnly && <button className="btn" style={{ alignSelf: "flex-start" }} onClick={addGroup}><Ic name="plus" size={15} />New group</button>}

          <div style={{ display: "flex", gap: 9, color: "var(--text-dim)", fontSize: 12.5, padding: "11px 14px", background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 10 }}>
            <Ic name="info" size={16} style={{ flexShrink: 0, marginTop: 1, color: "var(--accent)" }} />
            <div>Groups are members-are-users only — they can't nest or hold tags, and they're distinct from built-in <span className="code-inline">autogroup:*</span>. Saving rewrites the policy <span className="code-inline">groups</span> block and applies renames across all references in one <span className="code-inline">PUT /api/v1/policy</span>.</div>
          </div>
        </div>
      )}

      {confirmDel && (
        <ConfirmModal danger title={`Delete ${confirmDel.row.key}?`} subtitle={confirmDel.refs.length ? `${confirmDel.refs.length} reference(s) will dangle` : "Not referenced"} confirmLabel="Delete group"
          body={<div style={{ fontSize: 13.5, color: "var(--text-dim)", lineHeight: 1.55 }}>
            {confirmDel.refs.length > 0
              ? <>This group is referenced by <b>{confirmDel.refs.join(", ")}</b>. Deleting it leaves those references dangling — the policy check will flag them until you fix them.</>
              : <>Removes the group from the policy. It isn't referenced anywhere, so nothing else changes.</>}
          </div>}
          onConfirm={() => { setRows((rs) => rs.filter((_, j) => j !== confirmDel.i)); }}
          onClose={() => setConfirmDel(null)} />
      )}

      {showDiff && (
        <Modal title="Review group changes" subtitle="Diff against the live policy before applying" icon="acl" size="xl" onClose={() => setShowDiff(false)}
          footer={<><span style={{ fontSize: 12, color: "var(--text-faint)" }}>{renames.length > 0 && `${renames.length} rename(s) propagated to references`}</span><div className="spacer" /><button className="btn" onClick={() => setShowDiff(false)}>Cancel</button><button className="btn primary" onClick={save}>Apply policy</button></>}>
          <DiffView before={s.aclPolicy.text} after={proposedText} />
        </Modal>
      )}
    </div>
  );
}

/* ---- group helpers ---- */
function parsePolicyObj(src) {
  try { return JSON.parse(src.replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1")); } catch { return null; }
}
function toRows(obj) {
  const g = (obj && obj.groups) || {};
  return Object.entries(g).map(([k, m]) => ({ key: k, orig: k, members: (m || []).slice() }));
}
function refMatch(x, gkey) { x = String(x); return x === gkey || x.startsWith(gkey + ":"); }
function groupRefs(obj, gkey) {
  const refs = [];
  Object.entries(obj.tagOwners || {}).forEach(([tag, owners]) => (owners || []).forEach((o) => { if (o === gkey) refs.push("tagOwner " + tag); }));
  (obj.acls || []).forEach((r, i) => { (r.src || []).forEach((x) => { if (refMatch(x, gkey)) refs.push(`acl #${i + 1} src`); }); (r.dst || []).forEach((x) => { if (refMatch(x, gkey)) refs.push(`acl #${i + 1} dst`); }); });
  (obj.ssh || []).forEach((r, i) => { (r.src || []).forEach((x) => { if (refMatch(x, gkey)) refs.push(`ssh #${i + 1} src`); }); (r.dst || []).forEach((x) => { if (refMatch(x, gkey)) refs.push(`ssh #${i + 1} dst`); }); });
  const aa = obj.autoApprovers || {};
  Object.entries(aa.routes || {}).forEach(([cidr, owners]) => (owners || []).forEach((o) => { if (o === gkey) refs.push("autoApprover " + cidr); }));
  (aa.exitNode || []).forEach((o) => { if (o === gkey) refs.push("autoApprover exitNode"); });
  return [...new Set(refs)];
}
function serializeGroups(groups) {
  const entries = Object.entries(groups);
  if (!entries.length) return '"groups": {}';
  const lines = entries.map(([k, m]) => `    "${k}": [${(m || []).map((x) => `"${x}"`).join(", ")}],`);
  return '"groups": {\n' + lines.join("\n") + "\n  }";
}
function writeGroupsBlock(text, groups) {
  const re = /"groups"\s*:\s*\{[^{}]*\}/;
  const block = serializeGroups(groups);
  if (re.test(text)) return text.replace(re, block);
  // no existing block — insert before "acls"
  if (/"acls"\s*:/.test(text)) return text.replace(/("acls"\s*:)/, block + ",\n\n  $1");
  return text;
}
function renameGroupInPolicy(text, from, to) {
  const esc = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  return text.replace(new RegExp(esc + "(?![A-Za-z0-9_-])", "g"), to);
}

window.ACL = ACL;
Object.assign(window, { PolicyEditor, GroupsEditor, AclTabs, DiffView, ACL_LENSES, ACL_DOC_SECTION });
