/* ============================================================
   acl_lenses.jsx — structured (novice) lenses over the policy model.
   Each lens parses the live policy text via ACLM.model, edits ONE block,
   serializes it back with a comment-preserving splice, and shares the
   same Review→check→diff→PUT save pipeline (LensShell). file mode is
   read-only in every lens. The raw "Policy" lens stays authoritative.
   ============================================================ */

/* ---- shared chrome + save pipeline (diff, lockout, concurrency) ---- */
// Heuristic guard: does the policy still grant a broad "reach everything" path?
// (Not tied to any signed-in user — API-key auth is separate from ACL policy.)
function hasBroadAccess(m) {
  return (m.acls || []).some((r) => (r.dst || []).some((d) => d === "*:*") && (r.src || []).length > 0);
}

function LensShell({ tab, setTab, mode, setMode, subtitle, dirty, canSave, syntaxErr, proposedText, baseText, loadedAt, onRevert, onSaved, children }) {
  const s = useStore();
  const toast = useToast();
  const readOnly = mode === "file";
  const [showDiff, setShowDiff] = useState(false);

  const lockout = useMemo(() => {
    if (!proposedText) return false;
    const before = ACLM.model(baseText),after = ACLM.model(proposedText);
    return before && after && hasBroadAccess(before) && !hasBroadAccess(after);
  }, [proposedText, baseText]);
  const stale = loadedAt != null && s.aclPolicy.updatedAt !== loadedAt;

  const save = () => {
    HS.act.saveAcl(proposedText);
    toast.push({ kind: "success", title: "Policy saved", msg: "PUT /api/v1/policy" });
    setShowDiff(false);
    onSaved && onSaved();
  };

  return (
    <div className="page wide fade-up" style={{ paddingBottom: 24 }}>
      <div className="section-head">
        <div>
          <h2 style={{ fontSize: 19, display: "inline-flex", alignItems: "center", gap: 8 }}>Access controls
            <span className="copy-btn" title="Open the reference for this section" onClick={() => {localStorage.setItem("hs.acldocs", (window.ACL_DOC_SECTION || {})[tab] || "how");setTab("docs");}} style={{ color: "var(--text-faint)" }}><Ic name="info" size={15} /></span>
          </h2>
          <div className="sub">{subtitle}</div>
        </div>
        <div className="spacer" />
        <AclTabs tab={tab} setTab={setTab} mode={mode} setMode={setMode} />
        {syntaxErr ?
        <span className="badge danger"><Ic name="warn" size={12} />raw invalid</span> :
        dirty ? <span className="badge warn" style={{ height: 22 }}>unsaved</span> : <span className="badge online"><span className="dot" />in sync</span>}
        <button className="btn" disabled={!dirty || readOnly} onClick={onRevert}>Revert</button>
        <button className="btn primary" disabled={!dirty || readOnly || !canSave || !!syntaxErr} 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 (<span className="code-inline">policy.mode: file</span>).</b> Every lens is read-only — <span className="code-inline">PUT /api/v1/policy</span> would fail. Switch the server to <span className="code-inline">database</span> mode to edit.</Banner></div>}
      {syntaxErr && <div style={{ marginBottom: 14 }}><Banner cls="danger" icon="warn"><b>The raw policy has a syntax error</b> ({syntaxErr}). Fix it in the <a style={{ textDecoration: "underline", cursor: "pointer" }} onClick={() => setTab("policy")}>Raw policy</a> lens before using the structured editors.</Banner></div>}

      {!syntaxErr && children}

      {showDiff &&
      <Modal title="Review &amp; save policy" subtitle="Validated against the live policy before PUT" icon="acl" size="xl" onClose={() => setShowDiff(false)}
      footer={<>
            <div style={{ display: "flex", flexDirection: "column", gap: 2, fontSize: 11.5, marginRight: "auto" }}>
              {lockout && <span style={{ color: "var(--danger)" }}><Ic name="warn" size={11} /> This removes the broad “reach everything” (*:*) rule — make sure it's intentional.</span>}
              {stale && <span style={{ color: "var(--warn)" }}><Ic name="warn" size={11} /> Policy changed on the server since you loaded it.</span>}
            </div>
            <button className="btn" onClick={() => setShowDiff(false)}>Cancel</button>
            <button className={"btn " + (lockout ? "danger" : "primary")} onClick={save}>{lockout ? "Save anyway" : "Apply policy"}</button>
          </>}>
          <DiffView before={baseText} after={proposedText} />
        </Modal>
      }
    </div>);

}

/* ---- token chips & pickers ---- */
function kindMeta(kind) {
  return {
    user: { icon: "users", color: "var(--accent)" },
    group: { icon: "users", color: "var(--info)" },
    tag: { icon: "tag", color: "var(--info)" },
    host: { icon: "dns", color: "var(--text-dim)" },
    cidr: { icon: "routes", color: "var(--text-dim)" },
    autogroup: { icon: "shield", color: "var(--warn)" },
    wildcard: { icon: "globe", color: "var(--text-dim)" }
  }[kind] || { icon: "info", color: "var(--text-dim)" };
}

function TokenChip({ token, onRemove, readOnly, title }) {
  const s = useStore();
  const kind = ACLM.tokenKind(token);
  const meta = kindMeta(kind);
  const u = kind === "user" ? s.users.find((x) => x.name + "@" === token) : null;
  return (
    <span className="tag" style={{ height: 26, paddingLeft: 5, gap: 5 }} title={title || ACLM.tokenLabel(token)}>
      {u ? <Avatar user={u} size={17} /> : <Ic name={meta.icon} size={12} style={{ color: meta.color }} />}
      <span className="mono" style={{ fontSize: 12 }}>{token}</span>
      {!readOnly && onRemove && <span className="x" onClick={onRemove}><Ic name="x" size={11} /></span>}
    </span>);

}

const PORT_PRESETS = ["*", "22", "80", "443", "53", "5432", "3389", "80,443"];
function DstChip({ token, onChangePorts, onRemove, readOnly }) {
  const { target, ports } = ACLM.splitDst(token);
  const kind = ACLM.tokenKind(target);
  const meta = kindMeta(kind);
  const [custom, setCustom] = useState("");
  return (
    <span className="tag" style={{ height: 26, paddingLeft: 5, gap: 0, overflow: "hidden" }}>
      <span style={{ display: "inline-flex", alignItems: "center", gap: 5, paddingRight: 6 }}>
        <Ic name={meta.icon} size={12} style={{ color: meta.color }} />
        <span className="mono" style={{ fontSize: 12 }}>{target}</span>
      </span>
      {readOnly ?
      <span className="mono" style={{ fontSize: 11.5, background: "var(--surface-3)", padding: "0 6px", height: 26, display: "inline-flex", alignItems: "center" }}>:{ports}</span> :
      <Menu align="left" trigger={<span className="mono" style={{ fontSize: 11.5, background: "var(--surface-3)", padding: "0 6px", height: 26, display: "inline-flex", alignItems: "center", cursor: "pointer" }} title="Edit ports">:{ports}<Ic name="chevDown" size={10} style={{ marginLeft: 2 }} /></span>}>
            <div className="menu-label">Ports</div>
            {PORT_PRESETS.map((p) => <MenuItem key={p} icon={p === ports ? "check" : undefined} onClick={() => onChangePorts(p)}>{p === "*" ? "* (all ports)" : p}</MenuItem>)}
            <div style={{ padding: "6px 10px", display: "flex", gap: 6 }}>
              <input className="input mono" style={{ height: 28, fontSize: 12 }} placeholder="8000-8100" value={custom} onChange={(e) => setCustom(e.target.value)} onKeyDown={(e) => {if (e.key === "Enter" && custom.trim()) {onChangePorts(custom.trim());}}} />
            </div>
          </Menu>}
      {!readOnly && onRemove && <span className="x" style={{ marginLeft: 4, marginRight: 2 }} onClick={onRemove}><Ic name="x" size={11} /></span>}
    </span>);

}

/* TokenPicker — menu of valid tokens for a context.
   ctx: "src" | "dst" | "owner" | "member" | "approver" | "sshuser" */
function TokenPicker({ ctx, base, onPick, exclude = [], readOnly, label = "Add" }) {
  const s = useStore();
  const [customCidr, setCustomCidr] = useState("");
  if (readOnly) return null;
  const ex = new Set(exclude);
  const users = s.users.filter((u) => !ex.has(u.name + "@"));
  const groups = Object.keys(base.groups || {}).filter((g) => !ex.has(g));
  const tags = [...new Set([...Object.keys(base.tagOwners || {}), ...s.machines.flatMap((m) => m.tags || [])])].filter((t) => !ex.has(t));
  const hosts = Object.keys(base.hosts || {}).filter((h) => !ex.has(h));
  const autos = ACLM.AUTOGROUPS.filter((a) => a.ctx.includes(ctx));
  const wantUsers = ["src", "dst", "owner", "member", "approver"].includes(ctx);
  const wantGroups = ["src", "dst", "owner", "approver"].includes(ctx);
  const wantTags = ["src", "dst", "approver"].includes(ctx);
  const wantHosts = ctx === "dst";
  const wantWild = ctx === "src" || ctx === "dst";
  const wantAuto = ctx === "src" || ctx === "dst";

  const pick = (tok) => {onPick(ctx === "dst" ? ACLM.joinDst(tok, "*") : tok);};

  return (
    <Menu align="left" width={250} trigger={<button className="btn sm" style={{ height: 26 }}><Ic name="plus" size={13} />{label}</button>}>
      {ctx === "member" && <div className="menu-label">Users (members are users only)</div>}
      {ctx !== "member" && wantUsers && <div className="menu-label">Users</div>}
      {wantUsers && users.map((u) => <MenuItem key={u.id} onClick={() => pick(u.name + "@")}><Avatar user={u} size={18} />{u.name}<span className="meta">{u.name}@</span></MenuItem>)}
      {wantUsers && users.length === 0 && <div className="menu-item" style={{ color: "var(--text-faint)" }}>All added</div>}

      {wantGroups && groups.length > 0 && <div className="menu-label">Groups</div>}
      {wantGroups && groups.map((g) => <MenuItem key={g} icon="users" onClick={() => pick(g)}>{g}</MenuItem>)}

      {wantTags && tags.length > 0 && <div className="menu-label">Tags</div>}
      {wantTags && tags.map((t) => <MenuItem key={t} icon="tag" onClick={() => pick(t)}>{t}</MenuItem>)}

      {wantHosts && hosts.length > 0 && <div className="menu-label">Hosts</div>}
      {wantHosts && hosts.map((h) => <MenuItem key={h} icon="dns" onClick={() => pick(h)}>{h}</MenuItem>)}

      {wantAuto && autos.length > 0 && <div className="menu-label">Special groups</div>}
      {wantAuto && autos.map((a) => <MenuItem key={a.token} icon="shield" onClick={() => pick(a.token)}>{a.label}<span className="meta">{a.token}</span></MenuItem>)}

      {ctx === "sshuser" && <><div className="menu-label">SSH login users</div>
        {["root", "ubuntu", "ops", "admin"].map((n) => <MenuItem key={n} icon="users" onClick={() => onPick(n)}>{n}</MenuItem>)}
        {ACLM.SSH_USER_AUTOGROUPS.map((a) => <MenuItem key={a.token} icon="shield" onClick={() => onPick(a.token)}>{a.label}<span className="meta">{a.token}</span></MenuItem>)}</>}

      {wantWild && <><div className="menu-label">Wildcard</div><MenuItem icon="globe" onClick={() => pick("*")}>Everything (*)</MenuItem></>}

      {ctx === "dst" && <div style={{ padding: "6px 10px", borderTop: "1px solid var(--border)" }}>
        <div style={{ fontSize: 11, color: "var(--text-faint)", marginBottom: 4 }}>Custom CIDR / IP</div>
        <input className="input mono" style={{ height: 28, fontSize: 12 }} placeholder="10.0.0.0/8" value={customCidr} onChange={(e) => setCustomCidr(e.target.value)} onKeyDown={(e) => {if (e.key === "Enter" && customCidr.trim()) {onPick(ACLM.joinDst(customCidr.trim(), "*"));setCustomCidr("");}}} />
      </div>}
    </Menu>);

}

/* ---- resolver: "what does this allow" ---- */
function ResolverLine({ rule, ctx }) {
  const summary = useMemo(() => {
    const srcSet = new Set();
    (rule.src || []).forEach((t) => ACLM.machinesForToken(t, ctx).forEach((m) => srcSet.add(m.id)));
    const dstSet = new Set();
    let internet = false;
    (rule.dst || []).forEach((d) => {const { target } = ACLM.splitDst(d);if (target === "autogroup:internet") internet = true;ACLM.machinesForToken(target, ctx).forEach((m) => dstSet.add(m.id));});
    const srcWild = (rule.src || []).includes("*");
    const dstWild = (rule.dst || []).some((d) => ACLM.splitDst(d).target === "*");
    return { src: srcWild ? ctx.machines.length : srcSet.size, dst: dstWild ? ctx.machines.length : dstSet.size, srcWild, dstWild, internet };
  }, [rule, ctx]);
  const seg = (n, wild) => wild ? "every machine" : `${n} machine${n !== 1 ? "s" : ""}`;
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 7, fontSize: 12, color: "var(--text-dim)" }}>
      <Ic name="activity" size={13} style={{ color: "var(--accent)", flexShrink: 0 }} />
      <span><b style={{ color: "var(--text)" }}>{seg(summary.src, summary.srcWild)}</b> can reach {summary.internet ? <b style={{ color: "var(--info)" }}>the internet (exit nodes)</b> : <b style={{ color: "var(--text)" }}>{seg(summary.dst, summary.dstWild)}</b>}</span>
    </div>);

}

/* ---- reachability simulator ---- */
function Reachability({ base, ctx }) {
  const s = useStore();
  const [open, setOpen] = useState(false);
  const [src, setSrc] = useState(s.machines[0] ? s.machines[0].id : null);
  const [dst, setDst] = useState(s.machines[1] ? s.machines[1].id : null);
  const srcM = s.machines.find((m) => m.id === src);
  const dstM = s.machines.find((m) => m.id === dst);

  const result = useMemo(() => {
    if (!srcM || !dstM) return null;
    for (let i = 0; i < base.acls.length; i++) {
      const r = base.acls[i];
      const srcOk = (r.src || []).some((t) => t === "*" || ACLM.machineMatches(t, srcM, ctx));
      const dstOk = (r.dst || []).some((d) => {const { target } = ACLM.splitDst(d);return target === "*" || ACLM.machineMatches(target, dstM, ctx);});
      if (srcOk && dstOk) return { allowed: true, ruleIndex: i, rule: r };
    }
    return { allowed: false };
  }, [srcM, dstM, base, ctx]);

  const MachineSelect = ({ value, onChange }) =>
  <Menu align="left" width={230} trigger={<button className="btn sm" style={{ minWidth: 150, justifyContent: "flex-start" }}>{(s.machines.find((m) => m.id === value) || {}).name || "—"}<div style={{ flex: 1 }} /><Ic name="chevDown" size={13} /></button>}>
      {s.machines.map((m) => <MenuItem key={m.id} icon={osIcon(m.kind, m.os)} onClick={() => onChange(m.id)}>{m.name}<span className="meta">{m.ip4}</span></MenuItem>)}
    </Menu>;


  return (
    <div className="card card-pad" style={{ marginBottom: 14, display: "flex", flexDirection: "column", gap: open ? 12 : 0 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }} onClick={() => setOpen((v) => !v)}>
        <Ic name="activity" size={17} style={{ color: "var(--accent)" }} />
        <b style={{ fontSize: 13.5 }}>Reachability check</b>
        <span className="sub" style={{ color: "var(--text-faint)", fontSize: 12 }}>simulate whether one machine can reach another</span>
        <div style={{ flex: 1 }} />
        <Ic name={open ? "chevDown" : "chevRight"} size={15} style={{ color: "var(--text-faint)" }} />
      </div>
      {open &&
      <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
          <MachineSelect value={src} onChange={setSrc} />
          <Ic name="chevRight" size={15} style={{ color: "var(--text-faint)" }} />
          <MachineSelect value={dst} onChange={setDst} />
          {result && (result.allowed ?
        <span className="badge online" style={{ height: 26 }}><Ic name="check" size={13} />Allowed by rule #{result.ruleIndex + 1}</span> :
        <span className="badge danger" style={{ height: 26 }}><Ic name="x" size={13} />No rule allows this</span>)}
          <span style={{ fontSize: 11.5, color: "var(--text-faint)", flexBasis: "100%" }}>Default-deny: traffic is blocked unless an <span className="code-inline">accept</span> rule matches both source and destination. SSH is evaluated separately.</span>
        </div>
      }
    </div>);

}

/* ---- recipes (beat the blank page) ---- */
const RECIPES = [
{ label: "Everyone can use exit nodes", rule: { action: "accept", src: ["autogroup:member"], dst: ["autogroup:internet:*"] } },
{ label: "Allow all internal traffic", rule: { action: "accept", src: ["*"], dst: ["*:*"] } },
{ label: "Admins → everything", rule: { action: "accept", src: ["group:ops"], dst: ["*:*"] } },
{ label: "Per-user device isolation (own devices only)", rule: { action: "accept", src: ["autogroup:member"], dst: ["autogroup:self:*"] } },
{ label: "Engineers → dev servers", rule: { action: "accept", src: ["group:eng"], dst: ["tag:dev:*"] } },
{ label: "Engineers → prod over HTTPS", rule: { action: "accept", src: ["group:eng"], dst: ["tag:prod:443"] } },
{ label: "CI → database (Postgres)", rule: { action: "accept", src: ["tag:ci"], dst: ["tag:prod:5432"] } },
{ label: "Web tier → DB tier (TCP 5432)", rule: { action: "accept", src: ["tag:edge"], dst: ["tag:prod:5432"], proto: "tcp" } },
{ label: "K8s nodes intra-cluster", rule: { action: "accept", src: ["tag:k8s"], dst: ["tag:k8s:*"] } },
{ label: "Reach an internal subnet (RFC1918)", rule: { action: "accept", src: ["group:eng"], dst: ["10.0.0.0/8:*"] } },
{ label: "Allow ICMP ping everywhere", rule: { action: "accept", src: ["*"], dst: ["*:*"], proto: "icmp" } },
{ label: "Lock down CI (prod HTTPS only)", rule: { action: "accept", src: ["tag:ci"], dst: ["tag:prod:443"] } }];


/* ============================================================
   1) RULES lens — the centerpiece sentence builder
   ============================================================ */
function RulesLens(props) {
  const { mode } = props;
  const s = useStore();
  const readOnly = mode === "file";
  const text = s.aclPolicy.text;
  const base = useMemo(() => ACLM.model(text), [text]);
  const syntaxErr = ACLM.parseErr(text);
  const [acls, setAcls] = useState(() => base ? clone(base.acls) : []);
  const [loadedAt] = useState(s.aclPolicy.updatedAt);
  useEffect(() => {if (base) setAcls(clone(base.acls));}, [text]);

  const ctx = { users: s.users, machines: s.machines, groups: base ? base.groups : {}, tagOwners: base ? base.tagOwners : {}, hosts: base ? base.hosts : {} };
  const proposed = useMemo(() => base ? ACLM.writers.acls(text, { ...base, acls }) : text, [acls, text]);
  const dirty = base ? JSON.stringify(acls) !== JSON.stringify(base.acls) : false;

  const upd = (i, patch) => setAcls((a) => a.map((r, j) => j === i ? { ...r, ...patch } : r));
  const addSrc = (i, tok) => upd(i, { src: [...acls[i].src, tok] });
  const rmSrc = (i, tok) => upd(i, { src: acls[i].src.filter((x) => x !== tok) });
  const addDst = (i, tok) => upd(i, { dst: [...acls[i].dst, tok] });
  const rmDst = (i, tok) => upd(i, { dst: acls[i].dst.filter((x) => x !== tok) });
  const setPorts = (i, di, ports) => upd(i, { dst: acls[i].dst.map((d, k) => k === di ? ACLM.joinDst(ACLM.splitDst(d).target, ports) : d) });
  const addRule = (rule) => setAcls((a) => [...a, { action: "accept", src: [], dst: [], _extra: {}, representable: true, ...rule }]);
  const del = (i) => setAcls((a) => a.filter((_, j) => j !== i));

  return (
    <LensShell {...props} subtitle="Rules — who may reach what (default-deny; every rule is an Allow)"
    dirty={dirty} canSave={acls.every((r) => r.src.length && r.dst.length)} syntaxErr={syntaxErr}
    proposedText={proposed} baseText={text} loadedAt={loadedAt}
    onRevert={() => setAcls(clone(base.acls))}>
      {base && <>
        <Reachability base={base} ctx={ctx} />
        <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
          {acls.length === 0 && <div className="card"><div className="empty"><Ic name="shield" size={36} /><h3>No rules — nothing is allowed</h3><div>Default-deny is in effect. Add a rule or start from a recipe.</div></div></div>}
          {acls.map((r, i) =>
          <RuleCard key={i} r={r} i={i} readOnly={readOnly} base={base} ctx={ctx}
          addSrc={addSrc} rmSrc={rmSrc} addDst={addDst} rmDst={rmDst} setPorts={setPorts} del={del} goRaw={() => props.setTab("policy")} />
          )}
          {!readOnly &&
          <div style={{ display: "flex", gap: 9, flexWrap: "wrap" }}>
              <button className="btn" onClick={() => addRule({})}><Ic name="plus" size={15} />New rule</button>
              <Menu align="left" width={230} trigger={<button className="btn"><Ic name="acl" size={14} />Start from recipe<Ic name="chevDown" size={13} style={{ marginLeft: 2 }} /></button>}>
                <div className="menu-label">Insert a starter rule</div>
                {RECIPES.map((rc, k) => <MenuItem key={k} icon="plus" onClick={() => addRule(rc.rule)}>{rc.label}</MenuItem>)}
              </Menu>
            </div>
          }
        </div>
      </>}
    </LensShell>);

}

function RuleCard({ r, i, readOnly, base, ctx, addSrc, rmSrc, addDst, rmDst, setPorts, del, goRaw }) {
  if (!r.representable) {
    return (
      <div className="card card-pad" style={{ display: "flex", alignItems: "center", gap: 11, borderStyle: "dashed" }}>
        <Ic name="code" size={17} style={{ color: "var(--text-faint)" }} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 13, fontWeight: 550 }}>Rule #{i + 1} <span className="badge" style={{ height: 17 }}>advanced</span></div>
          <div style={{ fontSize: 12, color: "var(--text-dim)" }}>Uses fields the visual builder can't represent ({Object.keys(r._extra).join(", ")}). Shown read-only to avoid data loss.</div>
        </div>
        <button className="btn sm" onClick={goRaw}><Ic name="code" size={13} />Edit in raw</button>
      </div>);

  }
  return (
    <div className="card card-pad" style={{ display: "flex", flexDirection: "column", gap: 11 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
        <span className="badge accent" style={{ height: 20 }}>Allow</span>
        <span style={{ fontSize: 12, color: "var(--text-faint)" }}>rule #{i + 1}</span>
        {r.proto && <span className="badge" style={{ height: 19 }} title="protocol restriction">proto: {r.proto}</span>}
        <div style={{ flex: 1 }} />
        {!readOnly && <button className="btn icon sm ghost" title="Edit raw" onClick={goRaw}><Ic name="code" size={14} /></button>}
        {!readOnly && <button className="btn icon sm ghost" title="Delete rule" onClick={() => del(i)}><Ic name="trash" size={14} /></button>}
      </div>

      <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
        {r.src.map((t) => <TokenChip key={t} token={t} readOnly={readOnly} onRemove={() => rmSrc(i, t)} />)}
        {r.src.length === 0 && <span style={{ fontSize: 12.5, color: "var(--text-faint)" }}>any source…</span>}
        <TokenPicker ctx="src" base={base} exclude={r.src} onPick={(t) => addSrc(i, t)} readOnly={readOnly} label="Source" />
      </div>

      <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13, color: "var(--text-dim)", fontWeight: 550, whiteSpace: "nowrap" }}>
        <Ic name="chevDown" size={14} style={{ color: "var(--text-faint)" }} />to reach</div>

      <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
        {r.dst.map((d, di) => <DstChip key={d + di} token={d} readOnly={readOnly} onChangePorts={(p) => setPorts(i, di, p)} onRemove={() => rmDst(i, d)} />)}
        {r.dst.length === 0 && <span style={{ fontSize: 12.5, color: "var(--text-faint)" }}>any destination…</span>}
        <TokenPicker ctx="dst" base={base} onPick={(t) => addDst(i, t)} readOnly={readOnly} label="Destination" />
      </div>

      <div style={{ borderTop: "1px solid var(--border)", paddingTop: 9 }}>
        <ResolverLine rule={r} ctx={ctx} />
      </div>
    </div>);

}

/* ============================================================
   2) SSH lens
   ============================================================ */
function SshLens(props) {
  const { mode } = props;
  const s = useStore();
  const readOnly = mode === "file";
  const text = s.aclPolicy.text;
  const base = useMemo(() => ACLM.model(text), [text]);
  const syntaxErr = ACLM.parseErr(text);
  const [rules, setRules] = useState(() => base ? clone(base.ssh) : []);
  const [loadedAt] = useState(s.aclPolicy.updatedAt);
  useEffect(() => {if (base) setRules(clone(base.ssh));}, [text]);

  const proposed = useMemo(() => base ? ACLM.writers.ssh(text, { ...base, ssh: rules }) : text, [rules, text]);
  const dirty = base ? JSON.stringify(rules) !== JSON.stringify(base.ssh) : false;
  const upd = (i, patch) => setRules((a) => a.map((r, j) => j === i ? { ...r, ...patch } : r));
  const add = () => setRules((a) => [...a, { action: "accept", src: [], dst: [], users: ["autogroup:nonroot"], _extra: {}, representable: true }]);

  return (
    <LensShell {...props} subtitle="SSH — who may open a Tailscale SSH session, as which login"
    dirty={dirty} canSave={rules.every((r) => r.src.length && r.dst.length && r.users.length)} syntaxErr={syntaxErr}
    proposedText={proposed} baseText={text} loadedAt={loadedAt}
    onRevert={() => setRules(clone(base.ssh))}>
      {base && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
        {rules.length === 0 && <div className="card"><div className="empty"><Ic name="server" size={36} /><h3>No SSH rules</h3><div>Tailscale SSH is denied by default. Add a rule to allow it.</div></div></div>}
        {rules.map((r, i) => r.representable ?
        <div key={i} className="card card-pad" style={{ display: "flex", flexDirection: "column", gap: 11 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 9 }}>
              {!readOnly ?
            <div className="seg" style={{ height: 26 }}>
                    <button className={r.action === "accept" ? "on" : ""} onClick={() => upd(i, { action: "accept", checkPeriod: undefined })}>Allow</button>
                    <button className={r.action === "check" ? "on" : ""} onClick={() => upd(i, { action: "check", checkPeriod: r.checkPeriod || "12h" })}>Check</button>
                  </div> :
            <span className="badge accent" style={{ height: 20 }}>{r.action === "check" ? "Check" : "Allow"}</span>}
              <span style={{ fontSize: 12, color: "var(--text-faint)" }}>ssh #{i + 1}</span>
              {r.action === "check" && <span className="badge warn" style={{ height: 19 }} title="re-authentication interval">re-auth every {r.checkPeriod || "12h"}</span>}
              <div style={{ flex: 1 }} />
              {!readOnly && <button className="btn icon sm ghost" onClick={() => setRules((a) => a.filter((_, j) => j !== i))}><Ic name="trash" size={14} /></button>}
            </div>
            <ChipRow label="from" items={r.src} readOnly={readOnly} onRemove={(t) => upd(i, { src: r.src.filter((x) => x !== t) })}
          picker={<TokenPicker ctx="src" base={base} exclude={r.src} onPick={(t) => upd(i, { src: [...r.src, t] })} readOnly={readOnly} label="Source" />} />
            <ChipRow label="to SSH into" items={r.dst} readOnly={readOnly} onRemove={(t) => upd(i, { dst: r.dst.filter((x) => x !== t) })}
          picker={<TokenPicker ctx="src" base={base} exclude={r.dst} onPick={(t) => upd(i, { dst: [...r.dst, t] })} readOnly={readOnly} label="Target" />} />
            <ChipRow label="as users" items={r.users} readOnly={readOnly} mono onRemove={(t) => upd(i, { users: r.users.filter((x) => x !== t) })}
          picker={<TokenPicker ctx="sshuser" base={base} exclude={r.users} onPick={(t) => upd(i, { users: [...r.users, t] })} readOnly={readOnly} label="Login" />} />
            {r.action === "check" && !readOnly &&
          <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5, color: "var(--text-dim)" }}>Re-check every
                <Menu align="left" trigger={<button className="btn sm" style={{ height: 26 }}>{r.checkPeriod || "12h"}<Ic name="chevDown" size={12} /></button>}>
                  {["1h", "12h", "24h", "168h"].map((p) => <MenuItem key={p} onClick={() => upd(i, { checkPeriod: p })}>{p}</MenuItem>)}
                </Menu>
              </div>
          }
          </div> :

        <div key={i} className="card card-pad" style={{ display: "flex", alignItems: "center", gap: 11, borderStyle: "dashed" }}>
            <Ic name="code" size={17} style={{ color: "var(--text-faint)" }} />
            <div style={{ flex: 1 }}><div style={{ fontSize: 13, fontWeight: 550 }}>SSH rule #{i + 1} <span className="badge" style={{ height: 17 }}>advanced</span></div><div style={{ fontSize: 12, color: "var(--text-dim)" }}>Has fields the builder can't represent ({Object.keys(r._extra).join(", ")}).</div></div>
            <button className="btn sm" onClick={() => props.setTab("policy")}><Ic name="code" size={13} />Edit in raw</button>
          </div>
        )}
        {!readOnly && <button className="btn" style={{ alignSelf: "flex-start" }} onClick={add}><Ic name="plus" size={15} />New SSH rule</button>}
        <InfoNote>SSH rules are separate from network ACLs. <b>Allow</b> grants the session; <b>Check</b> grants it but forces periodic re-authentication (a security control). <span className="code-inline">autogroup:nonroot</span> blocks root logins.</InfoNote>
      </div>}
    </LensShell>);

}

function ChipRow({ label, items, onRemove, picker, readOnly, mono }) {
  const s = useStore();
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
      <span style={{ fontSize: 12.5, color: "var(--text-dim)", fontWeight: 550, minWidth: 78 }}>{label}</span>
      {items.length === 0 && <span style={{ fontSize: 12.5, color: "var(--text-faint)" }}>none…</span>}
      {items.map((t) => {
        const u = !mono && s.users.find((x) => x.name + "@" === t);
        return <span key={t} className="tag" style={{ height: 26, paddingLeft: u ? 5 : 8, gap: 5 }}>
          {u && <Avatar user={u} size={17} />}<span className="mono" style={{ fontSize: 12 }}>{t}</span>
          {!readOnly && <span className="x" onClick={() => onRemove(t)}><Ic name="x" size={11} /></span>}
        </span>;
      })}
      {picker}
    </div>);

}

/* ============================================================
   3) TAG OWNERS lens
   ============================================================ */
function TagOwnersLens(props) {
  const { mode } = props;
  const s = useStore();
  const readOnly = mode === "file";
  const text = s.aclPolicy.text;
  const base = useMemo(() => ACLM.model(text), [text]);
  const syntaxErr = ACLM.parseErr(text);
  const [rows, setRows] = useState(() => base ? toKV(base.tagOwners) : []);
  const [loadedAt] = useState(s.aclPolicy.updatedAt);
  useEffect(() => {if (base) setRows(toKV(base.tagOwners));}, [text]);

  const obj = Object.fromEntries(rows.map((r) => [r.key, r.val]));
  const proposed = useMemo(() => base ? ACLM.writers.tagOwners(text, { ...base, tagOwners: obj }) : text, [rows, text]);
  const dirty = base ? JSON.stringify(obj) !== JSON.stringify(base.tagOwners) : false;
  const badKey = rows.some((r) => !/^tag:[a-z0-9-]+$/.test(r.key)) || rows.some((r, i) => rows.findIndex((x) => x.key === r.key) !== i);
  const upd = (i, patch) => setRows((rs) => rs.map((r, j) => j === i ? { ...r, ...patch } : r));
  const addTag = () => {let n = 1,k;do {k = "tag:new" + (n > 1 ? n : "");n++;} while (rows.some((r) => r.key === k));setRows((rs) => [...rs, { key: k, val: [] }]);};

  const tagCount = (t) => s.machines.filter((m) => (m.tags || []).includes(t)).length;

  return (
    <LensShell {...props} subtitle="Tag owners — who may assign each tag to a device"
    dirty={dirty} canSave={!badKey} syntaxErr={syntaxErr} proposedText={proposed} baseText={text} loadedAt={loadedAt}
    onRevert={() => setRows(toKV(base.tagOwners))}>
      {base && <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
        {badKey && <Banner cls="danger" icon="warn">Tag names must be unique and match <span className="code-inline">tag:[a-z0-9-]</span>.</Banner>}
        {rows.map((r, i) => {
          const refs = ACLM.referencesOf(base, r.key);
          const live = tagCount(r.key);
          return (
            <div key={i} className="card card-pad" style={{ display: "flex", flexDirection: "column", gap: 11 }}>
              <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
                <Ic name="tag" size={17} style={{ color: "var(--info)" }} />
                {readOnly ? <span className="mono" style={{ fontWeight: 600, fontSize: 14 }}>{r.key}</span> :
                <input className="input mono" style={{ width: 200, height: 32, fontWeight: 600 }} value={r.key} onChange={(e) => upd(i, { key: e.target.value.replace(/[^a-z0-9:-]/gi, "").toLowerCase() })} />}
                <span className="badge" style={{ height: 19 }}>{live} machine{live !== 1 ? "s" : ""} tagged</span>
                <div style={{ flex: 1 }} />
                {!readOnly && <button className="btn icon sm ghost" onClick={() => setRows((rs) => rs.filter((_, j) => j !== i))}><Ic name="trash" size={15} /></button>}
              </div>
              <ChipRow label="owners" items={r.val} readOnly={readOnly} onRemove={(t) => upd(i, { val: r.val.filter((x) => x !== t) })}
              picker={<TokenPicker ctx="owner" base={base} exclude={r.val} onPick={(t) => upd(i, { val: [...r.val, t] })} readOnly={readOnly} label="Add owner" />} />
              <ReverseIndex refs={refs} empty="Not used in any rule yet." />
              <div style={{ fontSize: 11.5, color: "var(--text-faint)" }}>Owners decide who may apply <span className="mono">{r.key}</span> to a device. Owners can be users or groups.</div>
            </div>);

        })}
        {!readOnly && <button className="btn" style={{ alignSelf: "flex-start" }} onClick={addTag}><Ic name="plus" size={15} />New tag owner</button>}
        <InfoNote>A tagged device is owned by its tags, not a user. Only listed owners may register a device with that tag (or set it later).</InfoNote>
      </div>}
    </LensShell>);

}

/* ============================================================
   4) HOSTS lens
   ============================================================ */
function HostsLens(props) {
  const { mode } = props;
  const s = useStore();
  const readOnly = mode === "file";
  const text = s.aclPolicy.text;
  const base = useMemo(() => ACLM.model(text), [text]);
  const syntaxErr = ACLM.parseErr(text);
  const [rows, setRows] = useState(() => base ? toKV2(base.hosts) : []);
  const [loadedAt] = useState(s.aclPolicy.updatedAt);
  useEffect(() => {if (base) setRows(toKV2(base.hosts));}, [text]);

  const obj = Object.fromEntries(rows.map((r) => [r.key, r.val]));
  const proposed = useMemo(() => base ? ACLM.writers.hosts(text, { ...base, hosts: obj }) : text, [rows, text]);
  const dirty = base ? JSON.stringify(obj) !== JSON.stringify(base.hosts) : false;
  const dupKey = rows.some((r, i) => r.key && rows.findIndex((x) => x.key === r.key) !== i);
  const badCidr = rows.some((r) => r.val && !ACLM.isCidrOrIp(r.val));
  const upd = (i, patch) => setRows((rs) => rs.map((r, j) => j === i ? { ...r, ...patch } : r));

  // overlap warning vs node IPs
  const nodeIps = new Set(s.machines.map((m) => m.ip4));

  return (
    <LensShell {...props} subtitle="Hosts — friendly names for an IP or CIDR you can use in rules"
    dirty={dirty} canSave={!dupKey && !badCidr && rows.every((r) => r.key && r.val)} syntaxErr={syntaxErr}
    proposedText={proposed} baseText={text} loadedAt={loadedAt} onRevert={() => setRows(toKV2(base.hosts))}>
      {base && <div className="card" style={{ overflow: "hidden" }}>
        <div className="tbl-scroll">
          <table className="tbl">
            <thead><tr><th style={{ width: "32%" }}>Name</th><th>IP / CIDR</th><th>Used by</th><th></th></tr></thead>
            <tbody>
              {rows.length === 0 && <tr><td colSpan={4}><div className="empty" style={{ padding: 30 }}><Ic name="dns" size={32} /><h3>No host aliases</h3><div>Name an IP/subnet so rules can read clearly, e.g. <span className="code-inline">db-primary → 100.64.0.6</span>.</div></div></td></tr>}
              {rows.map((r, i) => {
                const refs = ACLM.referencesOf(base, r.key);
                const overlap = r.val && [...nodeIps].some((ip) => r.val.split("/")[0] === ip);
                return (
                  <tr key={i} style={{ cursor: "default" }}>
                    <td>{readOnly ? <span className="mono">{r.key}</span> : <input className="input mono" style={{ height: 30 }} value={r.key} placeholder="db-primary" onChange={(e) => upd(i, { key: e.target.value.replace(/[^a-z0-9.-]/gi, "").toLowerCase() })} />}</td>
                    <td>
                      <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
                        {readOnly ? <span className="ip">{r.val}</span> : <input className={"input mono" + (r.val && !ACLM.isCidrOrIp(r.val) ? "" : "")} style={{ height: 30, width: 180, borderColor: r.val && !ACLM.isCidrOrIp(r.val) ? "var(--danger)" : undefined }} value={r.val} placeholder="100.64.0.6 or 10.0.0.0/24" onChange={(e) => upd(i, { val: e.target.value })} />}
                        {overlap && <span className="badge" style={{ height: 18 }} title="matches a node's address">node IP</span>}
                      </div>
                    </td>
                    <td>{refs.length ? <span style={{ fontSize: 11.5, color: "var(--text-dim)" }}>{refs.join(", ")}</span> : <span style={{ fontSize: 11.5, color: "var(--text-faint)" }}>—</span>}</td>
                    <td style={{ textAlign: "right" }}>{!readOnly && <button className="btn icon sm ghost" onClick={() => setRows((rs) => rs.filter((_, j) => j !== i))}><Ic name="trash" size={15} /></button>}</td>
                  </tr>);

              })}
            </tbody>
          </table>
        </div>
        {!readOnly && <div style={{ padding: 12, borderTop: "1px solid var(--border)" }}><button className="btn sm" onClick={() => setRows((rs) => [...rs, { key: "", val: "" }])}><Ic name="plus" size={14} />Add host</button></div>}
      </div>}
    </LensShell>);

}

/* ============================================================
   5) AUTO-APPROVE lens
   ============================================================ */
function AutoApproveLens(props) {
  const { mode } = props;
  const s = useStore();
  const readOnly = mode === "file";
  const text = s.aclPolicy.text;
  const base = useMemo(() => ACLM.model(text), [text]);
  const syntaxErr = ACLM.parseErr(text);
  const [routes, setRoutes] = useState(() => base ? toKV(base.autoApprovers.routes) : []);
  const [exit, setExit] = useState(() => base ? clone(base.autoApprovers.exitNode) : []);
  const [loadedAt] = useState(s.aclPolicy.updatedAt);
  useEffect(() => {if (base) {setRoutes(toKV(base.autoApprovers.routes));setExit(clone(base.autoApprovers.exitNode));}}, [text]);

  const aa = { routes: Object.fromEntries(routes.map((r) => [r.key, r.val])), exitNode: exit };
  const proposed = useMemo(() => base ? ACLM.writers.autoApprovers(text, { ...base, autoApprovers: aa }) : text, [routes, exit, text]);
  const dirty = base ? JSON.stringify(aa) !== JSON.stringify(base.autoApprovers) : false;
  const updR = (i, patch) => setRoutes((rs) => rs.map((r, j) => j === i ? { ...r, ...patch } : r));

  return (
    <LensShell {...props} subtitle="Auto-approve — routes & exit nodes approved automatically by policy"
    dirty={dirty} canSave={routes.every((r) => r.key)} syntaxErr={syntaxErr} proposedText={proposed} baseText={text} loadedAt={loadedAt}
    onRevert={() => {setRoutes(toKV(base.autoApprovers.routes));setExit(clone(base.autoApprovers.exitNode));}}>
      {base && <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
        <div className="card card-pad" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
          <div className="section-head" style={{ margin: 0 }}><Ic name="routes" size={16} style={{ color: "var(--accent)" }} /><h2>Subnet routes</h2><div className="spacer" /><a className="btn sm ghost" href="#routes"><Ic name="link" size={13} />Routes page</a></div>
          {routes.length === 0 && <span style={{ fontSize: 12.5, color: "var(--text-faint)" }}>No auto-approved subnet routes.</span>}
          {routes.map((r, i) =>
          <div key={i} style={{ display: "flex", alignItems: "center", gap: 9, flexWrap: "wrap" }}>
              {readOnly ? <span className="ip" style={{ width: 150 }}>{r.key}</span> : <input className="input mono" style={{ height: 30, width: 150 }} value={r.key} placeholder="10.0.0.0/16" onChange={(e) => updR(i, { key: e.target.value })} />}
              <Ic name="chevRight" size={14} style={{ color: "var(--text-faint)" }} />
              <ChipRow label="" items={r.val} readOnly={readOnly} onRemove={(t) => updR(i, { val: r.val.filter((x) => x !== t) })}
            picker={<TokenPicker ctx="approver" base={base} exclude={r.val} onPick={(t) => updR(i, { val: [...r.val, t] })} readOnly={readOnly} label="Approver" />} />
              {!readOnly && <button className="btn icon sm ghost" onClick={() => setRoutes((rs) => rs.filter((_, j) => j !== i))}><Ic name="trash" size={14} /></button>}
            </div>
          )}
          {!readOnly && <button className="btn sm" style={{ alignSelf: "flex-start" }} onClick={() => setRoutes((rs) => [...rs, { key: "", val: [] }])}><Ic name="plus" size={14} />Add route rule</button>}
          <div style={{ fontSize: 11.5, color: "var(--text-faint)" }}>When a node carrying one of these approver tags/groups advertises the prefix, it's approved without a manual click.</div>
        </div>

        <div className="card card-pad" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
          <div className="section-head" style={{ margin: 0 }}><Ic name="exit" size={16} style={{ color: "var(--info)" }} /><h2>Exit nodes</h2></div>
          <ChipRow label="approvers" items={exit} readOnly={readOnly} onRemove={(t) => setExit(exit.filter((x) => x !== t))}
          picker={<TokenPicker ctx="approver" base={base} exclude={exit} onPick={(t) => setExit([...exit, t])} readOnly={readOnly} label="Approver" />} />
          <div style={{ fontSize: 11.5, color: "var(--text-faint)" }}>These nodes' exit-node advertisements (<span className="mono">0.0.0.0/0</span>, <span className="mono">::/0</span>) are approved automatically.</div>
        </div>
      </div>}
    </LensShell>);

}

/* ---- small shared bits ---- */
function ReverseIndex({ refs, empty }) {
  return (
    <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)" }}>{empty}</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>);

}
function InfoNote({ children }) {
  return <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>{children}</div></div>;
}
function clone(x) {return JSON.parse(JSON.stringify(x));}
function toKV(obj) {return Object.entries(obj || {}).map(([key, val]) => ({ key, val: (val || []).slice() }));}
function toKV2(obj) {return Object.entries(obj || {}).map(([key, val]) => ({ key, val }));}

Object.assign(window, { RulesLens, SshLens, TagOwnersLens, HostsLens, AutoApproveLens, LensShell, TokenChip, DstChip, TokenPicker, ResolverLine, Reachability, RuleCard, ChipRow, ReverseIndex, InfoNote });