/* settings.jsx — Channel category management + scraper status + Phase-2 whitelist */

// A channel can carry several categories at once (e.g. signals + analysis).
// 'recap' and 'ignore' own the whole channel, so they can't be combined.
const CHANNEL_CATS = ["signals", "analysis", "recap", "chat", "ignore"];
const CHANNEL_CAT_EXCLUSIVE = ["recap", "ignore"];
const CHANNEL_CAT_PRIORITY = ["recap", "ignore", "signals", "analysis", "chat"];
const primaryCat = (cats) => CHANNEL_CAT_PRIORITY.find(c => cats.includes(c)) || cats[0] || "signals";
function toggleChannelCat(cats, cat) {
  const has = cats.includes(cat);
  if (CHANNEL_CAT_EXCLUSIVE.includes(cat)) return has ? cats : [cat];   // exclusive: becomes the only one, never empties
  const next = has ? cats.filter(c => c !== cat)
                   : [...cats.filter(c => !CHANNEL_CAT_EXCLUSIVE.includes(c)), cat];  // adding a normal tier drops recap/ignore
  return next.length ? next : cats;                                     // never allow an empty set
}

// Multi-select category pills for a channel (used in the add form + each row).
function ChannelCatChips({ categories, onToggle, disabled }) {
  return (
    <div className="cat-select" role="group" aria-label="Channel categories">
      {CHANNEL_CATS.map(cat => (
        <button type="button" key={cat} disabled={disabled}
          aria-pressed={categories.includes(cat)}
          className={cx(categories.includes(cat) && "on")}
          onClick={disabled ? undefined : () => onToggle(cat)}>{cat}</button>
      ))}
    </div>
  );
}

// Multi-author lock for a channel: pick one or more analysts as allowed authors
// (e.g. a trader posting both himself and via his bot). Empty = any author.
// value is an array of discord_user_ids.
function OwnerMultiSelect({ analysts, value = [], onChange }) {
  const byId = new Map((analysts || []).map(a => [a.discord_user_id, a]));
  const available = (analysts || []).filter(a => !value.includes(a.discord_user_id));
  const label = (a, id) => a ? (a.handle + (a.is_bot ? " (bot)" : "")) : id;
  return (
    <div className="owner-multi">
      <select
        className="select" value="" aria-label="Add an allowed author for this channel"
        style={{ fontFamily: "var(--f-mono)", fontSize: "0.75rem" }}
        onChange={e => { if (e.target.value) onChange([...value, e.target.value]); }}
      >
        <option value="">{value.length ? "+ add author" : "any author"}</option>
        {available.map(a => (
          <option key={a.discord_user_id} value={a.discord_user_id}>{a.handle}{a.is_bot ? " (bot)" : ""}</option>
        ))}
      </select>
      {value.length > 0 && (
        <div className="owner-chips">
          {value.map(id => (
            <span key={id} className="owner-chip">
              {label(byId.get(id), id)}
              <button type="button" aria-label={`Remove ${label(byId.get(id), id)}`} title="Remove" onClick={() => onChange(value.filter(x => x !== id))}>×</button>
            </span>
          ))}
        </div>
      )}
    </div>
  );
}

function ScraperStatusCard({ status, currentServer }) {
  return (
    <div style={{
      display: "grid",
      gridTemplateColumns: "auto 1fr auto",
      alignItems: "center",
      gap: 14,
      padding: "14px 16px",
      background: "var(--bg-2)",
      border: "1px solid var(--border-1)",
      borderRadius: "var(--radius)",
      marginBottom: 14,
    }}>
      <div style={{
        width: 38, height: 38, borderRadius: 8,
        background: "var(--accent-glow)", color: "var(--accent)",
        display: "grid", placeItems: "center",
      }}>{I("zap", { size: 18 })}</div>
      <div>
        <div style={{ fontWeight: 600, fontSize: "0.875rem" }}>SinuxMod · trade signal listener</div>
        <div className="mono" style={{ fontSize: "0.6875rem", color: "var(--fg-2)", marginTop: 5 }}>
          {currentServer ? <>watching <strong style={{ color: "var(--fg-1)" }}>{currentServer.name}</strong> · guild_id <span style={{ color: "var(--fg-1)" }}>{currentServer.guild_id}</span></> : "no server selected"}
        </div>
      </div>
      <StatusPill status={status} />
    </div>
  );
}

// ── Resolve a Discord id → its current name (server-side, via the bot token) so
//    entering an id in these forms auto-fills the name instead of typing it.
function useDiscordName(type, id) {
  const clean = String(id || "").trim();
  const valid = /^\d{5,25}$/.test(clean);
  const [state, setState] = useState({ name: null, loading: false, err: null });
  useEffect(() => {
    if (!valid) { setState({ name: null, loading: false, err: null }); return; }
    let alive = true;
    setState(s => ({ ...s, loading: true, err: null }));
    const t = setTimeout(() => {                                   // debounce typing
      tapeFetch(apiBase() + `/api/discord/resolve?type=${type}&id=${clean}`)
        .then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || "lookup failed"); return j; })
        .then(j => { if (alive) setState({ name: j.name || null, loading: false, err: j.name ? null : "no name found" }); })
        .catch(e => { if (alive) setState({ name: null, loading: false, err: e.message }); });
    }, 450);
    return () => { alive = false; clearTimeout(t); };
  }, [type, clean, valid]);
  return { ...state, valid };
}

// Renders under an id input: looks the id up and auto-fills nameValue/setName until
// the owner types their own value (never overwrites a manual edit); shows a status line.
function DiscordNameResolver({ type, id, nameValue, setName, transform }) {
  const { name, loading, err, valid } = useDiscordName(type, id);
  const autoRef = useRef("");
  useEffect(() => {
    if (!name) return;
    const v = transform ? transform(name) : name;
    if (!nameValue || nameValue === autoRef.current) { setName(v); autoRef.current = v; }
  }, [name]);
  if (!valid) return null;
  return (
    <div className="id-resolve mono">
      {loading ? <span className="id-resolve-wait">Looking up name…</span>
        : name ? <span className="id-resolve-ok">{I("check", { size: 11 })} {name}</span>
        : <span className="id-resolve-err">{err || "not found"}</span>}
    </div>
  );
}

function AddChannelForm({ analysts, currentServer, onAdded }) {
  const [open, setOpen] = useState(false);
  const [channelId, setChannelId] = useState("");
  const [name, setName] = useState("");
  const [categories, setCategories] = useState(["signals", "analysis"]);
  const [ownerUserIds, setOwnerUserIds] = useState([]);
  const [instMode, setInstMode] = useState("any");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  if (!currentServer) return null;

  async function submit(e) {
    e.preventDefault();
    setErr(null);
    if (!channelId.trim() || !name.trim()) { setErr("channel ID and name are required"); return; }
    setBusy(true);
    try {
      const r = await tapeFetch(apiBase() + "/api/channels", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          channel_id: channelId.trim(),
          guild_id: currentServer.guild_id,
          name: name.trim(),
          categories,
          owner_user_ids: ownerUserIds,
          instrument_mode: instMode,
        }),
      });
      const j = await r.json();
      if (!r.ok) throw new Error(j.error || "request failed");
      onAdded && onAdded(j.channel);
      toast.success("Channel added");
      setChannelId(""); setName(""); setOwnerUserIds([]); setCategories(["signals", "analysis"]); setInstMode("any");
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(false);
    }
  }

  if (!open) {
    return (
      <button className="btn primary" onClick={() => setOpen(true)}>
        {I("plus", { size: 14 })} Add channel
      </button>
    );
  }

  return (
    <form onSubmit={submit} className="form-card">
      <div className="form-card-hdr">
        <h3>Add channel</h3>
        <button type="button" className="icon-btn" title="Close" onClick={() => setOpen(false)}>{I("close", { size: 16 })}</button>
      </div>
      <div className="form-grid" style={{ gridTemplateColumns: "1.4fr 1.3fr 1fr 1fr 1.1fr" }}>
        <div className="field">
          <label className="field-label">Channel ID</label>
          <input className="input mono" placeholder="Discord channel ID" value={channelId} onChange={e => setChannelId(e.target.value)} style={{ fontSize: "0.75rem" }} />
          <DiscordNameResolver type="channel" id={channelId} nameValue={name} setName={setName} />
        </div>
        <div className="field">
          <label className="field-label">Name</label>
          <input className="input" placeholder="auto-fills from the ID" value={name} onChange={e => setName(e.target.value)} />
        </div>
        <div className="field">
          <label className="field-label">Categories</label>
          <ChannelCatChips categories={categories} onToggle={(cat) => setCategories(prev => toggleChannelCat(prev, cat))} />
        </div>
        <div className="field">
          <label className="field-label">Instrument</label>
          <select className="select" value={instMode} onChange={e => setInstMode(e.target.value)} style={{ fontFamily: "var(--f-mono)", fontSize: "0.75rem" }}>
            <option value="any">any</option>
            <option value="options">options only</option>
            <option value="shares">shares only</option>
          </select>
        </div>
        <div className="field">
          <label className="field-label">Authors (optional)</label>
          <OwnerMultiSelect analysts={analysts} value={ownerUserIds} onChange={setOwnerUserIds} />
        </div>
      </div>
      {err && <div className="form-err">{I("alert", { size: 12 })} {err}</div>}
      <div className="form-actions">
        <span className="field-hint">Enable Developer Mode in Discord, then right-click a channel → Copy Channel ID.</span>
        <button type="submit" className="btn primary" disabled={busy}>
          {busy ? "Adding…" : <>{I("plus", { size: 14 })} Add channel</>}
        </button>
      </div>
    </form>
  );
}

function ChannelEditor({ channel, analysts, onCancel, onSave }) {
  const [name, setName] = useState(channel.name || "");
  const [owners, setOwners] = useState(
    (Array.isArray(channel.owner_user_ids) && channel.owner_user_ids.length)
      ? channel.owner_user_ids
      : (channel.owner_user_id ? [channel.owner_user_id] : [])
  );
  const [instMode, setInstMode] = useState(channel.instrument_mode || "any");
  const [enabled, setEnabled] = useState(channel.enabled !== false);
  const [sepAcct, setSepAcct] = useState(!!channel.account_label);
  const [acctLabel, setAcctLabel] = useState(channel.account_label || "");

  function save() {
    if (!name.trim()) return;
    onSave({ name: name.trim(), owner_user_ids: owners, instrument_mode: instMode, enabled,
      account_label: sepAcct && acctLabel.trim() ? acctLabel.trim() : null });
  }

  return (
    <div className="row-editor">
      <div className="form-grid" style={{ gridTemplateColumns: "1.4fr 1.6fr 1fr 1fr" }}>
        <div className="field">
          <label className="field-label">Channel name</label>
          <input className="input" value={name} onChange={e => setName(e.target.value)} />
        </div>
        <div className="field">
          <label className="field-label">Authors — only these users are parsed (empty = any)</label>
          <OwnerMultiSelect analysts={analysts} value={owners} onChange={setOwners} />
        </div>
        <div className="field">
          <label className="field-label">Instrument</label>
          <select className="select" value={instMode} onChange={e => setInstMode(e.target.value)} style={{ fontFamily: "var(--f-mono)", fontSize: "0.75rem" }}>
            <option value="any">any</option>
            <option value="options">options only</option>
            <option value="shares">shares only</option>
          </select>
        </div>
        <div className="field">
          <label className="field-label">Status</label>
          <label className="switch toggle-row">
            <input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
            <span className="switch-track"><span className="switch-thumb" /></span>
            <span className="toggle-txt">{enabled ? "watching (live)" : "paused"}</span>
          </label>
        </div>
      </div>
      <div className="field" style={{ marginTop: 12 }}>
        <label className="switch toggle-row">
          <input type="checkbox" checked={sepAcct} onChange={e => setSepAcct(e.target.checked)} />
          <span className="switch-track"><span className="switch-thumb" /></span>
          <span className="toggle-txt">Separate account — track this channel's positions on their own</span>
        </label>
        {sepAcct && (
          <>
            <input className="input" style={{ marginTop: 8, maxWidth: 320 }} maxLength={40}
              placeholder="Account label, e.g. 20k Challenge"
              value={acctLabel} onChange={e => setAcctLabel(e.target.value)} />
            <div style={{ marginTop: 6, fontSize: "0.72rem", color: "var(--fg-3)", lineHeight: 1.5 }}>
              Posts here show as their own entity (e.g. <span className="mono">Author · {acctLabel.trim() || "20k Challenge"}</span>) with its own positions, win-rate and recaps — never mixed with the author's other channels. Lock this channel's <strong>Authors</strong> to the trader so only their posts land in the account.
            </div>
          </>
        )}
      </div>
      <div className="form-actions" style={{ justifyContent: "flex-end" }}>
        <button className="btn" onClick={onCancel}>Cancel</button>
        <button className="btn primary" onClick={save}>{I("check", { size: 14 })} Save changes</button>
      </div>
    </div>
  );
}

function ChannelsScreen({ channels, setChannels, analysts, currentServer, servers, canEdit }) {
  const [editingId, setEditingId] = useState(null);
  const ownerByUserId = useMemo(() => {
    const m = new Map();
    for (const a of analysts) m.set(a.discord_user_id, a);
    return m;
  }, [analysts]);
  // Same per-server grouping as the Analysts screen: when channels from more
  // than one server are in view, each category shows a server header per group.
  const serverName = (gid) => ((servers || []).find(s => s.guild_id === gid) || {}).name || gid || "Unknown server";
  const multiServer = useMemo(() => new Set(channels.map(c => c.guild_id || "—")).size > 1, [channels]);
  const byServer = (list) => {
    const m = new Map();
    for (const c of list) { const g = c.guild_id || "—"; if (!m.has(g)) m.set(g, []); m.get(g).push(c); }
    return [...m.entries()];
  };

  function onChannelAdded(row) {
    // Normalize like app.jsx does
    const norm = {
      id: row.channel_id,
      name: row.name,
      category: row.category,
      categories: (Array.isArray(row.categories) && row.categories.length) ? row.categories : [row.category],
      instrument_mode: row.instrument_mode || "any",
      msgs: 0,
      owner_user_id: row.owner_user_id,
      owner_user_ids: (Array.isArray(row.owner_user_ids) && row.owner_user_ids.length) ? row.owner_user_ids : (row.owner_user_id ? [row.owner_user_id] : []),
      guild_id: row.guild_id,
      enabled: row.enabled !== false,
    };
    // Replace if exists, else prepend
    setChannels(prev => {
      const exists = prev.some(c => c.id === norm.id);
      return exists ? prev.map(c => c.id === norm.id ? norm : c) : [norm, ...prev];
    });
  }

  async function removeChannel(id) {
    if (!(await confirmDialog({ title: "Stop watching channel", message: "The bot will stop parsing this channel.", confirmLabel: "Stop watching", danger: true }))) return;
    const removed = channels.find(c => c.id === id);
    setChannels(prev => prev.filter(c => c.id !== id));
    try {
      await tapeSend(apiBase() + "/api/channels/" + id, { method: "DELETE" });
      toast.success("Channel removed");
    } catch (err) {
      if (removed) setChannels(prev => prev.some(c => c.id === id) ? prev : [removed, ...prev]);
      toast.error("Couldn't remove channel — " + err.message);
    }
  }

  // Toggle one category on a channel. `categories` is the source of truth; we keep
  // the derived primary in sync locally so the channel stays in the right group.
  async function setCats(id, cat) {
    const cur = channels.find(c => c.id === id);
    if (!cur) return;
    const prevCats = cur.categories || [cur.category];
    const nextCats = toggleChannelCat(prevCats, cat);
    if (nextCats === prevCats) return;  // no-op (e.g. tried to clear the last one)
    setChannels(channels.map(c => c.id === id ? { ...c, categories: nextCats, category: primaryCat(nextCats) } : c));
    try {
      await tapeSend(apiBase() + "/api/channels/" + id, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ categories: nextCats }),
      });
    } catch (err) {
      setChannels(prev => prev.map(c => c.id === id ? { ...c, categories: prevCats, category: primaryCat(prevCats) } : c));
      toast.error("Couldn't update channel — " + err.message);
    }
  }

  async function saveChannel(id, patch) {
    const before = channels.find(c => c.id === id);
    setChannels(prev => prev.map(c => c.id === id ? { ...c, ...patch } : c));
    setEditingId(null);
    try {
      await tapeSend(apiBase() + "/api/channels/" + id, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(patch),
      });
    } catch (err) {
      if (before) setChannels(prev => prev.map(c => c.id === id ? before : c));
      toast.error("Couldn't save channel — " + err.message);
    }
  }

  const grouped = {
    signals: channels.filter(c => c.category === "signals"),
    analysis: channels.filter(c => c.category === "analysis"),
    recap: channels.filter(c => c.category === "recap"),
    chat: channels.filter(c => c.category === "chat"),
    ignore: channels.filter(c => c.category === "ignore"),
  };

  return (
    <div>
      <h2>Channel categories</h2>
      <p className="sub">Tag each Discord channel with one or more tiers — a channel can be both Signals and Analysis. Signals are parsed always · Analysis adds context · Chat is parsed only for whitelisted users · Recap reconciles a daily closed-trade summary · Ignore is skipped. (Recap and Ignore own the whole channel.)</p>

      <ScraperStatusCard status="ready" currentServer={currentServer} />

      {canEdit
        ? <div className="add-bar"><AddChannelForm analysts={analysts} currentServer={currentServer} onAdded={onChannelAdded} /></div>
        : <p className="sub" style={{ marginTop: -8 }}>View only — channels are configured by an admin.</p>}

      {channels.length === 0 && (
        <div className="empty" style={{ padding: 40 }}>
          No channels registered for this server yet. Use the form above to add one.
        </div>
      )}

      {[
        ["signals",  "Signals",  "Highest priority. Every message is parsed and logged as a trade event.", "var(--accent)"],
        ["analysis", "Analysis", "Parsed with full context. Trade rationale and macro takes go here.", "var(--c-watch)"],
        ["recap",    "Recap",    "A daily summary of closed/TP'd trades. Read-only reconciliation against tracked positions — matches are confirmed and discrepancies flagged for review; nothing is auto-closed (the analyst's own words are the source of truth). Lock to the recap sender via the channel's author(s).", "var(--c-up)"],
        ["chat",     "Chat",     "High noise. Only whitelisted users parsed.", "var(--fg-2)"],
        ["ignore",   "Ignore",   "Bots, screeners, off-topic. Skipped entirely.", "var(--fg-3)"],
      ].filter(([k]) => grouped[k].length > 0).map(([k, label, desc, col]) => (
        <div key={k} className="cat-group">
          <div className="cat-group-hdr">
            <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
              <span className="cat-name">
                <span className="dot" style={{ background: col, color: col, margin: 0 }} />{label}
              </span>
              <span className="cat-count">{grouped[k].length}</span>
            </div>
            <div className="cat-desc">{desc}</div>
          </div>
          <div className="rows">
            {byServer(grouped[k]).map(([gid, clist]) => (
            <Fragment key={"srv-" + gid}>
              {multiServer && (
                <div className="setting-grp">
                  {I("server", { size: 13 })}
                  <span className="setting-grp-name">{serverName(gid)}</span>
                  <span className="setting-grp-n mono">{clist.length} channel{clist.length === 1 ? "" : "s"}</span>
                </div>
              )}
              {clist.map(c => {
              const ownerIds = (c.owner_user_ids && c.owner_user_ids.length) ? c.owner_user_ids : (c.owner_user_id ? [c.owner_user_id] : []);
              const ownerLabels = ownerIds.map(id => { const a = ownerByUserId.get(id); return a ? (a.handle + (a.is_bot ? " (bot)" : "")) : id; });
              return (
                <Fragment key={c.id}>
                  <div className="channel-row">
                    <span className="hash">#</span>
                    <div className="row-main" style={{ minWidth: 0 }}>
                      <div className="name">{c.name}</div>
                      <div className="id">
                        {c.id}
                        {c.instrument_mode && c.instrument_mode !== "any" && <> · <span style={{ color: "var(--accent)" }}>{c.instrument_mode}</span></>}
                        {ownerLabels.length > 0 && <> · {ownerLabels.length > 1 ? "authors" : "author"} <span style={{ color: "var(--fg-1)" }}>{ownerLabels.join(", ")}</span></>}
                      </div>
                    </div>
                    {canEdit ? (
                      <ChannelCatChips categories={c.categories || [c.category]} onToggle={(cat) => setCats(c.id, cat)} />
                    ) : (
                      <div className="cat-select">
                        {(c.categories || [c.category]).map(cat => (
                          <button key={cat} className="on" disabled style={{ cursor: "default" }}>{cat}</button>
                        ))}
                      </div>
                    )}
                    <span className="live-dot" style={{ color: c.enabled ? "var(--c-up)" : "var(--fg-3)" }}>
                      <span className="d" />{c.enabled ? "live" : "off"}
                    </span>
                    {canEdit ? (
                      <div className="row-actions">
                        <button
                          className={cx("icon-btn", editingId === c.id && "active")}
                          title="Edit channel"
                          onClick={() => setEditingId(editingId === c.id ? null : c.id)}
                        >{I("edit", { size: 15 })}</button>
                        <button
                          className="icon-btn danger"
                          title="Remove channel"
                          onClick={(e) => { e.stopPropagation(); removeChannel(c.id); }}
                        >{I("trash", { size: 15 })}</button>
                      </div>
                    ) : <div className="row-actions" />}
                  </div>
                  {canEdit && editingId === c.id && (
                    <ChannelEditor
                      channel={c}
                      analysts={analysts}
                      onCancel={() => setEditingId(null)}
                      onSave={(patch) => saveChannel(c.id, patch)}
                    />
                  )}
                </Fragment>
              );
              })}
            </Fragment>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

function AddAnalystForm({ currentServer, onAdded }) {
  const [open, setOpen] = useState(false);
  const [discordUserId, setDiscordUserId] = useState("");
  const [handle, setHandle] = useState("");
  const [priority, setPriority] = useState("core");
  const [scope, setScope] = useState(["signals", "analysis"]);
  const [isBot, setIsBot] = useState(false);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  if (!currentServer) return null;

  function toggleScope(cat) {
    setScope(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]);
  }

  async function submit(e) {
    e.preventDefault();
    setErr(null);
    if (!discordUserId.trim() || !handle.trim()) { setErr("Discord user ID and handle are required"); return; }
    setBusy(true);
    try {
      const r = await tapeFetch(apiBase() + "/api/analysts", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          guild_id: currentServer.guild_id,
          discord_user_id: discordUserId.trim(),
          handle: handle.trim(),
          priority,
          channel_scope: scope,
          is_bot: isBot,
        }),
      });
      const j = await r.json();
      if (!r.ok) throw new Error(j.error || "request failed");
      onAdded && onAdded(j.analyst);
      toast.success(`Added ${j.analyst.handle}`);
      setDiscordUserId(""); setHandle(""); setPriority("core"); setScope(["signals","analysis"]); setIsBot(false);
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(false);
    }
  }

  if (!open) {
    return (
      <button className="btn primary" onClick={() => setOpen(true)}>
        {I("plus", { size: 14 })} Add analyst
      </button>
    );
  }

  return (
    <form onSubmit={submit} className="form-card">
      <div className="form-card-hdr">
        <h3>Add analyst</h3>
        <button type="button" className="icon-btn" title="Close" onClick={() => setOpen(false)}>{I("close", { size: 16 })}</button>
      </div>
      <div className="form-grid" style={{ gridTemplateColumns: "1.4fr 1fr 1fr" }}>
        <div className="field">
          <label className="field-label">Discord user ID</label>
          <input className="input mono" placeholder="Discord user ID" value={discordUserId} onChange={e => setDiscordUserId(e.target.value)} style={{ fontSize: "0.75rem" }} />
          <DiscordNameResolver type="user" id={discordUserId} nameValue={handle} setName={setHandle} transform={s => s.toUpperCase()} />
        </div>
        <div className="field">
          <label className="field-label">Handle</label>
          <input className="input" placeholder="auto-fills from the ID" value={handle} onChange={e => setHandle(e.target.value.toUpperCase())} />
        </div>
        <div className="field">
          <label className="field-label">Priority</label>
          <select className="select" value={priority} onChange={e => setPriority(e.target.value)} style={{ fontFamily: "var(--f-mono)", fontSize: "0.75rem" }}>
            <option value="core">core</option>
            <option value="secondary">secondary</option>
            <option value="watchlist">watchlist</option>
          </select>
        </div>
      </div>
      <div className="form-grid" style={{ gridTemplateColumns: "1.4fr 1fr", marginTop: 14 }}>
        <div className="field">
          <label className="field-label">Parses in</label>
          <CatChips scope={scope} onToggle={toggleScope} />
        </div>
        <div className="field">
          <label className="field-label">Type</label>
          <ToggleCard name="Bot / app analyst" desc="Reads its embeds & forwards" checked={isBot} onChange={setIsBot} />
        </div>
      </div>
      {err && <div className="form-err">{I("alert", { size: 12 })} {err}</div>}
      <div className="form-actions">
        <span className="field-hint">Enable Developer Mode in Discord, then right-click a user → Copy User ID.</span>
        <button type="submit" className="btn primary" disabled={busy}>
          {busy ? "Adding…" : <>{I("plus", { size: 14 })} Add analyst</>}
        </button>
      </div>
    </form>
  );
}

// One toggle: name + description on the left, a switch on the right.
function ToggleCard({ name, desc, checked, onChange }) {
  return (
    <label className="toggle-card">
      <span className="toggle-meta">
        <span className="toggle-name">{name}</span>
        <span className="toggle-desc">{desc}</span>
      </span>
      <span className="switch">
        <input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
        <span className="switch-track"><span className="switch-thumb" /></span>
      </span>
    </label>
  );
}

// Toggleable category pills — replaces the raw "Parses in" checkboxes.
function CatChips({ scope, onToggle }) {
  return (
    <div className="scope-chips">
      {["signals", "analysis", "chat"].map(cat => {
        const on = scope.includes(cat);
        return (
          <button type="button" key={cat} aria-pressed={on} className={cx("scope-chip", on && "on")} onClick={() => onToggle(cat)}>
            <span className="scope-chip-box">{on && I("check", { size: 11 })}</span>{cat}
          </button>
        );
      })}
    </div>
  );
}

// One linked account chip — resolves its Discord name and can be unlinked.
function LinkedChip({ uid, onRemove, busy }) {
  const { name } = useDiscordName("user", uid);
  return (
    <span className="linked-chip mono">
      {name || uid}
      <button type="button" className="linked-chip-x" title="Unlink" onClick={onRemove} disabled={busy}>{I("close", { size: 11 })}</button>
    </span>
  );
}

// "Linked accounts" for an analyst: link a bot/alt account so its calls fall under
// this analyst. Uses the merge endpoints (which re-point history + absorb duplicates).
function LinkedAccounts({ analyst, onChanged }) {
  const [newId, setNewId] = useState("");
  const [busy, setBusy] = useState(false);
  const linked = analyst.linked_user_ids || [];
  const cleanNew = newId.replace(/[^0-9]/g, "");
  async function add() {
    if (!cleanNew || busy) return;
    setBusy(true);
    try {
      const j = await tapeSend(apiBase() + "/api/analysts/" + analyst.id + "/link", {
        method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ user_id: cleanNew }),
      }).then(r => r.json());
      toast.success("Linked" + (j.removed_duplicate ? " — merged the existing analyst" : ""));
      setNewId("");
      onChanged && onChanged(j.analyst, cleanNew);
    } catch (e) { toast.error("Couldn't link — " + e.message); }
    finally { setBusy(false); }
  }
  async function remove(uid) {
    setBusy(true);
    try {
      const j = await tapeSend(apiBase() + "/api/analysts/" + analyst.id + "/link/" + uid, { method: "DELETE" }).then(r => r.json());
      toast.success("Unlinked");
      onChanged && onChanged(j.analyst, null);
    } catch (e) { toast.error("Couldn't unlink — " + e.message); }
    finally { setBusy(false); }
  }
  return (
    <div className="field" style={{ marginTop: 14 }}>
      <label className="field-label">Linked accounts</label>
      <div className="linked-chips">
        {linked.map(uid => <LinkedChip key={uid} uid={uid} onRemove={() => remove(uid)} busy={busy} />)}
        {!linked.length && <span className="field-hint">None yet.</span>}
      </div>
      <div className="linked-add">
        <input className="input mono" placeholder="Discord user ID to link (e.g. a bot)" value={newId} onChange={e => setNewId(e.target.value)} style={{ fontSize: "0.75rem" }} />
        <button type="button" className="btn" onClick={add} disabled={busy || !cleanNew}>{I("link", { size: 13 })} Link</button>
      </div>
      <DiscordNameResolver type="user" id={newId} nameValue="" setName={() => {}} />
      <span className="field-hint">Link a trader's <strong>bot / alt account</strong> so both post under this analyst — their existing calls merge in, and future ones attribute here too.</span>
    </div>
  );
}

// "Also known as" — the other names a published recap might call this analyst.
// A recap header says "BISHOP CALLS:" while the roster says "THE PAWN (THE MARKET
// BISHOP)"; the matcher deliberately refuses that leap rather than guess, so the
// owner states it here once and every future recap attributes correctly.
function AliasNames({ aliases, onChange }) {
  const [draft, setDraft] = useState("");
  const clean = draft.trim().toUpperCase();
  const dup = !!clean && aliases.some(a => a.toUpperCase() === clean);
  function add() {
    if (!clean || dup) return;
    onChange([...aliases, clean]);
    setDraft("");
  }
  return (
    <div className="field" style={{ marginTop: 14 }}>
      <label className="field-label">Also known as</label>
      <div className="linked-chips">
        {aliases.map(a => (
          <span key={a} className="linked-chip mono">
            {a}
            <button type="button" className="linked-chip-x" title="Remove alias"
              onClick={() => onChange(aliases.filter(x => x !== a))}>{I("close", { size: 11 })}</button>
          </span>
        ))}
        {!aliases.length && <span className="field-hint">None yet.</span>}
      </div>
      <div className="linked-add">
        <input className="input mono" placeholder='Name a recap uses, e.g. "BISHOP"' value={draft}
          onChange={e => setDraft(e.target.value.toUpperCase())}
          onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
          style={{ fontSize: "0.75rem" }} />
        <button type="button" className="btn" onClick={add} disabled={!clean || dup}>{I("plus", { size: 13 })} Add</button>
      </div>
      <span className="field-hint">
        Names a <strong>published recap</strong> uses for this analyst that don't match their handle.
        Branding words are already handled automatically ("DEMON" finds "DEMON ALERTS"), so only add
        a name that's genuinely different. A name that already identifies another analyst in this
        server is rejected — a shared name makes recaps ambiguous, so neither would match.
      </span>
    </div>
  );
}

function AnalystEditor({ analyst, onCancel, onSave, onLinkChanged }) {
  const [handle, setHandle] = useState(analyst.handle || "");
  const [scope, setScope] = useState(analyst.channels || []);
  const [isBot, setIsBot] = useState(!!analyst.is_bot);
  const [enabled, setEnabled] = useState(analyst.enabled !== false);
  const [trimBe, setTrimBe] = useState(!!analyst.trim_to_breakeven);
  const [pctGain, setPctGain] = useState(!!analyst.pct_is_gain);
  const [aliases, setAliases] = useState(analyst.aliases || []);
  const [readImages, setReadImages] = useState(!!analyst.read_images);
  const [styleTag, setStyleTag] = useState(analyst.style_tag || "");
  const [riskNote, setRiskNote] = useState(analyst.risk_note || "");
  const [styleNote, setStyleNote] = useState(analyst.style_note || "");
  // Linking a bot can flip the primary's read_images on the server; keep the toggle in
  // sync so a later Save doesn't silently revert it (the editor doesn't remount).
  useEffect(() => { setReadImages(!!analyst.read_images); }, [analyst.read_images]);

  function toggleScope(cat) {
    setScope(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]);
  }
  function save() {
    if (!handle.trim()) return;
    onSave({
      handle: handle.trim().toUpperCase(), channel_scope: scope, is_bot: isBot, enabled,
      trim_to_breakeven: trimBe, pct_is_gain: pctGain, read_images: readImages, aliases,
      style_tag: styleTag.trim() || null, risk_note: riskNote.trim() || null, style_note: styleNote.trim() || null,
    });
  }

  return (
    <div className="row-editor">
      <div className="form-grid" style={{ gridTemplateColumns: "1fr 1fr" }}>
        <div className="field">
          <label className="field-label">Handle</label>
          <input className="input" value={handle} onChange={e => setHandle(e.target.value.toUpperCase())} />
        </div>
        <div className="field">
          <label className="field-label">Discord user ID (fixed)</label>
          <input className="input mono" value={analyst.discord_user_id} disabled style={{ fontSize: "0.75rem", opacity: 0.65 }} />
        </div>
      </div>
      <div className="field" style={{ marginTop: 14 }}>
        <label className="field-label">Parses in</label>
        <CatChips scope={scope} onToggle={toggleScope} />
      </div>
      <div className="toggle-grid">
        <ToggleCard name="Bot / app" desc="Reads its embeds & forwards" checked={isBot} onChange={setIsBot} />
        <ToggleCard name="Active" desc="Parse this analyst's posts" checked={enabled} onChange={setEnabled} />
        <ToggleCard name="Breakeven on trim" desc="A trim moves runners' stop to entry" checked={trimBe} onChange={setTrimBe} />
        <ToggleCard name="States % as gain" desc="A bare % is this trade's gain, not a trim size (e.g. Ansh)" checked={pctGain} onChange={setPctGain} />
        <ToggleCard name="Read images" desc="Vision-parse posted cards/tables (conservative)" checked={readImages} onChange={setReadImages} />
      </div>
      <AliasNames aliases={aliases} onChange={setAliases} />
      <div className="field" style={{ marginTop: 14 }}>
        <label className="field-label">Profile — style &amp; risk <span style={{ color: "var(--fg-3)", fontWeight: 400 }}>(shown to members on this analyst's card)</span></label>
        <input className="input" maxLength={40} placeholder="Style tag, e.g. 0DTE · Aggressive" value={styleTag} onChange={e => setStyleTag(e.target.value)} style={{ marginBottom: 8 }} />
        <input className="input" maxLength={200} placeholder="Risk-management note (one line)" value={riskNote} onChange={e => setRiskNote(e.target.value)} style={{ marginBottom: 8 }} />
        <textarea className="input" rows={3} maxLength={400} placeholder="About the style (1–3 sentences, optional)" value={styleNote} onChange={e => setStyleNote(e.target.value)} />
      </div>
      <LinkedAccounts analyst={analyst} onChanged={onLinkChanged} />
      <div className="form-actions" style={{ justifyContent: "flex-end" }}>
        <button className="btn" onClick={onCancel}>Cancel</button>
        <button className="btn primary" onClick={save}>{I("check", { size: 14 })} Save changes</button>
      </div>
    </div>
  );
}

function WhitelistScreen({ analysts, events, currentServer, servers, setAnalysts, canEdit }) {
  const [editingId, setEditingId] = useState(null);
  const avatars = useAvatars(analysts.map(a => a.discord_user_id));
  // Group by server so the same analyst registered in >1 server reads clearly
  // (header per server) instead of looking like confusing duplicate rows.
  const serverName = (gid) => ((servers || []).find(s => s.guild_id === gid) || {}).name || gid || "Unknown server";
  const serverGroups = useMemo(() => {
    const m = new Map();
    for (const a of analysts) { const g = a.guild_id || "—"; if (!m.has(g)) m.set(g, []); m.get(g).push(a); }
    return [...m.entries()];
  }, [analysts]);
  const multiServer = serverGroups.length > 1;
  // Keyed by analyst id (NOT handle) so a duplicate handle across servers keeps
  // its own tier rather than sharing one.
  const [tier, setTier] = useState(() => {
    const m = {};
    analysts.forEach(a => { m[a.id] = a.priority; });
    return m;
  });

  useEffect(() => {
    const m = {};
    analysts.forEach(a => { m[a.id] = a.priority; });
    setTier(m);
  }, [analysts]);

  // Per analyst PER SERVER (handle|guild) so a duplicate handle shows the count
  // for its own server, not a combined total.
  const eventCounts = useMemo(() => {
    const m = new Map();
    for (const e of events) { const k = e.analyst + "|" + (e.guildId || ""); m.set(k, (m.get(k) || 0) + 1); }
    return m;
  }, [events]);
  const evCount = (a) => eventCounts.get(a.handle + "|" + (a.guild_id || "")) || 0;

  async function setTierFor(analyst, t) {
    const prevTier = tier[analyst.id];
    setTier(prev => ({ ...prev, [analyst.id]: t }));
    try {
      await tapeSend(apiBase() + "/api/analysts/" + analyst.id, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ priority: t }),
      });
    } catch (err) {
      setTier(prev => ({ ...prev, [analyst.id]: prevTier }));
      toast.error("Couldn't update tier — " + err.message);
    }
  }

  async function saveAnalyst(a, patch) {
    // patch may include: handle, channel_scope, is_bot, enabled, trim_to_breakeven,
    // read_images, aliases
    const before = analysts.find(x => x.id === a.id);
    setAnalysts && setAnalysts(prev => prev.map(x => x.id === a.id ? {
      ...x,
      handle: patch.handle !== undefined ? patch.handle : x.handle,
      channels: patch.channel_scope !== undefined ? patch.channel_scope : x.channels,
      is_bot: patch.is_bot !== undefined ? patch.is_bot : x.is_bot,
      enabled: patch.enabled !== undefined ? patch.enabled : x.enabled,
      trim_to_breakeven: patch.trim_to_breakeven !== undefined ? patch.trim_to_breakeven : x.trim_to_breakeven,
      pct_is_gain: patch.pct_is_gain !== undefined ? patch.pct_is_gain : x.pct_is_gain,
      read_images: patch.read_images !== undefined ? patch.read_images : x.read_images,
      aliases: patch.aliases !== undefined ? patch.aliases : x.aliases,
      style_tag: patch.style_tag !== undefined ? patch.style_tag : x.style_tag,
      risk_note: patch.risk_note !== undefined ? patch.risk_note : x.risk_note,
      style_note: patch.style_note !== undefined ? patch.style_note : x.style_note,
    } : x));
    setEditingId(null);
    try {
      await tapeSend(apiBase() + "/api/analysts/" + a.id, {
        method: "PATCH",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(patch),
      });
    } catch (err) {
      if (before) setAnalysts && setAnalysts(prev => prev.map(x => x.id === a.id ? before : x));
      toast.error("Couldn't save analyst — " + err.message);
    }
  }

  // After a link/unlink: refresh the primary's linked list (+ read_images the merge
  // may have turned on) and drop any standalone analyst row that was just absorbed.
  // Re-pointed events show on the next feed refresh.
  function onLinkChanged(updatedRow, absorbedUid) {
    setAnalysts && setAnalysts(prev => prev
      .filter(x => !(absorbedUid && x.discord_user_id === absorbedUid && x.id !== updatedRow.id))
      .map(x => x.id === updatedRow.id
        ? { ...x, linked_user_ids: updatedRow.linked_user_ids || [], read_images: updatedRow.read_images === true }
        : x));
  }

  function onAnalystAdded(row) {
    const norm = {
      handle: row.handle,
      color: row.color || "var(--a-1)",
      priority: row.priority || "core",
      channels: row.channel_scope || [],
      events: 0,
      discord_user_id: row.discord_user_id,
      guild_id: row.guild_id,
      is_bot: row.is_bot,
      trim_to_breakeven: row.trim_to_breakeven === true,
      pct_is_gain: row.pct_is_gain === true,
      read_images: row.read_images === true,
      linked_user_ids: row.linked_user_ids || [],
      enabled: row.enabled !== false,
      id: row.id,
    };
    setAnalysts && setAnalysts(prev => {
      const exists = prev.some(a => a.id === norm.id);
      return exists ? prev.map(a => a.id === norm.id ? norm : a) : [...prev, norm];
    });
  }

  async function removeAnalyst(a) {
    // The dialog must name the data consequence: removal now ARMS a deletion, and a
    // dialog that says only "remove from the registry" would be a lie about what
    // happens in 14 days.
    if (!(await confirmDialog({
      title: "Remove analyst",
      message: `Remove ${a.handle} from this server's analyst registry?\n\nThey stop being parsed immediately, and their trades stop showing straight away. Their trade history and watchlist entries for THIS server are then deleted after 14 days — only this server's; anywhere else they're registered is untouched. Re-adding them within 14 days cancels that.`,
      confirmLabel: "Remove", danger: true,
    }))) return;
    setAnalysts && setAnalysts(prev => prev.filter(x => x.id !== a.id));
    try {
      const j = await tapeSend(apiBase() + "/api/analysts/" + a.id, { method: "DELETE" }).then(r => r.json()).catch(() => ({}));
      toast.success(`Removed ${a.handle}` + (j && j.purge_after
        ? ` — history deletes ${new Date(j.purge_after).toLocaleDateString()}`
        : ""));
    } catch (err) {
      setAnalysts && setAnalysts(prev => prev.some(x => x.id === a.id) ? prev : [...prev, a]);
      toast.error("Couldn't remove analyst — " + err.message);
    }
  }

  return (
    <div>
      <h2>Analyst registry</h2>
      <p className="sub">Configured analysts for the selected server. Bots are allowed when explicitly registered. Tier controls weighting in chat channels.</p>

      <div className="tier-legend">
        <span className="mono" style={{ fontSize: "0.6875rem", color: "var(--fg-2)", letterSpacing: ".10em", textTransform: "uppercase" }}>Tiers</span>
        <span className="pill high">Core — every msg</span>
        <span className="pill medium">Secondary — high-conf only</span>
        <span className="pill low">Watchlist — mute</span>
      </div>

      {canEdit
        ? <div className="add-bar"><AddAnalystForm currentServer={currentServer} onAdded={onAnalystAdded} /></div>
        : <p className="sub" style={{ marginTop: -8 }}>View only — analysts are configured by an admin.</p>}

      {analysts.length === 0 && (
        <div className="empty" style={{ padding: 40 }}>
          No analysts registered yet. Use the form above to add one.
        </div>
      )}

      <div className="panel setting-list">
        {serverGroups.map(([gid, glist]) => (
          <Fragment key={"srv-" + gid}>
            {multiServer && (
              <div className="setting-grp">
                {I("server", { size: 13 })}
                <span className="setting-grp-name">{serverName(gid)}</span>
                <span className="setting-grp-n mono">{glist.length} analyst{glist.length === 1 ? "" : "s"}</span>
              </div>
            )}
            {glist.map(a => (
          <Fragment key={a.id || a.handle}>
            <div className="user-row">
              <DiscordAvatar
                url={avatars[a.discord_user_id]} className="avatar"
                fallback={<div className="avatar" style={{ background: a.color, color: "var(--bg-0)" }}>{a.handle[0]}</div>}
              />
              <div className="row-main" style={{ minWidth: 0 }}>
                <div className="uname">
                  {a.handle}
                  {a.is_bot && <span style={{ marginLeft: 8, fontFamily: "var(--f-mono)", fontSize: "0.625rem", color: "var(--c-watch)", letterSpacing: ".08em" }}>BOT</span>}
                  {a.enabled === false && <span style={{ marginLeft: 8, fontFamily: "var(--f-mono)", fontSize: "0.625rem", color: "var(--fg-3)", letterSpacing: ".08em" }}>DISABLED</span>}
                </div>
                <div className="mono" style={{ fontSize: "0.6875rem", color: "var(--fg-3)", marginTop: 5 }}>
                  {a.discord_user_id} · scope: {a.channels.join(" · ") || "—"}
                </div>
              </div>
              {canEdit ? (
                <div className="tier-btns" style={{ display: "flex", gap: 6 }}>
                  {["core", "secondary", "watchlist"].map(t => (
                    <button key={t} className="btn" style={{
                      padding: "6px 10px", fontSize: "0.6875rem",
                      fontFamily: "var(--f-mono)", textTransform: "uppercase", letterSpacing: ".10em",
                      background: tier[a.id] === t ? "var(--accent-glow)" : "var(--bg-2)",
                      color: tier[a.id] === t ? "var(--accent)" : "var(--fg-2)",
                      borderColor: tier[a.id] === t ? "var(--accent-dim)" : "var(--border-1)",
                    }} onClick={() => setTierFor(a, t)}>{t}</button>
                  ))}
                </div>
              ) : (
                <span className="mono" style={{ fontSize: "0.6875rem", color: "var(--fg-2)", textTransform: "uppercase", letterSpacing: ".10em" }}>{tier[a.id] || a.priority}</span>
              )}
              <span className="mono" style={{ fontSize: "0.6875rem", color: "var(--fg-2)" }}>{evCount(a)} ev</span>
              {canEdit ? (
                <div className="row-actions">
                  <button
                    className={cx("icon-btn", editingId === a.id && "active")}
                    title="Edit analyst"
                    onClick={() => setEditingId(editingId === a.id ? null : a.id)}
                  >{I("edit", { size: 15 })}</button>
                  <button
                    className="icon-btn danger"
                    title="Remove analyst"
                    onClick={() => removeAnalyst(a)}
                  >{I("trash", { size: 15 })}</button>
                </div>
              ) : <div className="row-actions" />}
            </div>
            {canEdit && editingId === a.id && (
              <AnalystEditor
                analyst={a}
                onCancel={() => setEditingId(null)}
                onSave={(patch) => saveAnalyst(a, patch)}
                onLinkChanged={onLinkChanged}
              />
            )}
          </Fragment>
            ))}
          </Fragment>
        ))}
      </div>
    </div>
  );
}

function PipelineScreen({ events, status }) {
  const cutoff = Date.now() - 86400000;
  const recent = events.filter(e => e.ts >= cutoff);

  return (
    <div>
      <h2>Pipeline status</h2>
      <p className="sub">Live view of the scrape → parse → store pipeline. Each stage runs as an independent worker.</p>

      <div className="pipeline-stats" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 10, marginBottom: 24 }}>
        {[
          ["scraper", status?.phase === "polling" ? "live · api" : status?.phase === "error" ? "polling error" : "idle", status?.count ?? 0, "events seen", status?.phase === "error" ? "var(--c-down)" : "var(--accent)"],
          ["parser",  "claude-haiku-4-5", recent.length, "events / 24h", "var(--c-watch)"],
          ["prices",  "Alpha Vantage", "—", "via backend proxy", "var(--c-trim)"],
          ["api",     status?.phase === "error" ? "endpoint error" : "online", "—", "supabase backed", status?.phase === "error" ? "var(--c-down)" : "var(--c-up)"],
        ].map(([k, sub, n, unit, c]) => (
          <div key={k} className="stat" style={{ padding: 14 }}>
            <div className="lbl" style={{ color: c }}><span className="dot" style={{ background: c, color: c, width: 6, height: 6 }} />{k}</div>
            <div className="val">{typeof n === "number" ? n.toLocaleString() : n}</div>
            <div className="delta">{sub} · {unit}</div>
          </div>
        ))}
      </div>

      <div style={{
        background: "var(--bg-2)", border: "1px solid var(--border-1)",
        borderRadius: "var(--radius)", padding: "16px 18px", marginBottom: 24,
      }}>
        <h3 style={{ margin: "0 0 8px", fontSize: "0.875rem" }}>Backend keys</h3>
        <p className="sub" style={{ fontSize: "0.75rem", marginBottom: 8 }}>
          Both <code className="mono" style={{ color: "var(--fg-1)" }}>SUPABASE_KEY</code> and <code className="mono" style={{ color: "var(--fg-1)" }}>ALPHA_VANTAGE_KEY</code> are set as environment variables on the API server (Vercel) — not in the browser. Update them in your Vercel project Settings → Environment Variables.
        </p>
        {status?.error && (
          <div style={{
            marginTop: 8, padding: "8px 10px",
            background: "color-mix(in oklab, var(--c-down) 12%, transparent)",
            border: "1px solid color-mix(in oklab, var(--c-down) 40%, transparent)",
            borderRadius: 4, fontFamily: "var(--f-mono)", fontSize: "0.6875rem", color: "var(--c-down)",
          }}>
            {I("alert", { size: 12 })} last poll failed: {status.error}
          </div>
        )}
      </div>

      <div className="panel">
        <div className="panel-hdr"><div className="panel-title">Run log</div><div className="panel-meta">last 12 events</div></div>
        <div className="scroll-y" style={{ maxHeight: 400 }}>
          {events.length === 0 && (
            <div className="empty" style={{ padding: 40 }}>
              No events parsed yet. Once your Discord bot writes the first <code className="mono">tape_trade_events</code> row it will show up here.
            </div>
          )}
          {events.slice(0, 12).map(e => (
            <div key={e.id} className="runlog-row" style={{
              padding: "10px 18px",
              borderBottom: "1px solid var(--border-1)",
              fontFamily: "var(--f-mono)", fontSize: "0.6875rem",
              display: "grid", gridTemplateColumns: "100px 1fr",
              gap: 12, color: "var(--fg-1)",
            }}>
              <span style={{ color: "var(--fg-3)" }}>{fmtTime(e.ts, {short:true})}</span>
              <span>
                <span style={{ color: "var(--accent)" }}>scraper</span> received · <span style={{ color: "var(--c-watch)" }}>parser</span> → <span style={{ color: "var(--fg-0)" }}>{e.action.toUpperCase()}</span> <span className="ticker">${e.ticker}</span> · stored as <span style={{ color: "var(--fg-0)" }}>{e.id}</span>
              </span>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function AddServerForm({ onAdded, onSelectServer }) {
  const [open, setOpen] = useState(false);
  const [guildId, setGuildId] = useState("");
  const [name, setName] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);

  async function submit(e) {
    e.preventDefault();
    setErr(null);
    if (!guildId.trim() || !name.trim()) { setErr("server ID and name are required"); return; }
    setBusy(true);
    try {
      const r = await tapeFetch(apiBase() + "/api/servers", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ guild_id: guildId.trim(), name: name.trim() }),
      });
      const j = await r.json();
      if (!r.ok) throw new Error(j.error || "request failed");
      onAdded && onAdded(j.server);
      onSelectServer && onSelectServer(j.server.guild_id);
      toast.success(`Added ${j.server.name}`);
      setGuildId(""); setName(""); setOpen(false);
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(false);
    }
  }

  if (!open) {
    return (
      <button className="btn primary" onClick={() => setOpen(true)}>
        {I("plus", { size: 14 })} Add server
      </button>
    );
  }

  return (
    <form onSubmit={submit} className="form-card">
      <div className="form-card-hdr">
        <h3>Add Discord server</h3>
        <button type="button" className="icon-btn" title="Close" onClick={() => setOpen(false)}>{I("close", { size: 16 })}</button>
      </div>
      <div className="form-grid" style={{ gridTemplateColumns: "1.6fr 1.2fr" }}>
        <div className="field">
          <label className="field-label">Server (guild) ID</label>
          <input className="input mono" placeholder="e.g. 1423793066107601030" value={guildId} onChange={e => setGuildId(e.target.value)} style={{ fontSize: "0.75rem" }} />
          <DiscordNameResolver type="guild" id={guildId} nameValue={name} setName={setName} />
        </div>
        <div className="field">
          <label className="field-label">Display name</label>
          <input className="input" placeholder="auto-fills from the ID" value={name} onChange={e => setName(e.target.value)} />
        </div>
      </div>
      {err && <div className="form-err">{I("alert", { size: 12 })} {err}</div>}
      <div className="form-actions">
        <span className="field-hint">The SinuxMod bot must already be a member of this server. Right-click the server icon → Copy Server ID (Developer Mode on).</span>
        <button type="submit" className="btn primary" disabled={busy}>
          {busy ? "Adding…" : <>{I("plus", { size: 14 })} Add server</>}
        </button>
      </div>
    </form>
  );
}

// A Discord-embed-style preview of the daily recap the bot will post. Data comes
// from GET /api/servers/:id/recap-preview (same compute the bot uses); the label +
// note update live from the form so the owner sees their footer as they type.
function RecapPreviewCard({ preview, label, note }) {
  const b = preview.brand || {};
  const mv = (r) => r.basis != null
    ? `${fmtNum(r.basis)} → ${r.exit != null ? fmtNum(r.exit) : "—"}`
    : `@ ${r.exit != null ? fmtNum(r.exit) : "—"}`;
  const group = (groups, isTrim) => groups.map(g => (
    <div key={g.analyst}>
      <div className="rcp-an">{g.analyst}</div>
      {g.rows.map((r, i) => (
        <div key={i} className="rcp-row">
          <span className={cx("rcp-sq", isTrim ? "trim" : (r.pct != null && r.pct < 0 ? "down" : "up"))} />
          <span className="mono rcp-mv">${r.ticker} {r.contract} — {mv(r)}</span>
          {r.pct != null && <span className={cx("rcp-pct", r.pct >= 0 ? "up" : "down")}>{fmtPct(r.pct)}</span>}
        </div>
      ))}
    </div>
  ));
  return (
    <div className="recap-preview" style={{ borderLeftColor: b.color }}>
      <div className="rcp-hd">
        {b.logo && <img src={b.logo} alt="" className="rcp-logo" />}
        <span className="rcp-title" style={{ color: b.color }}>🔥 {b.name} — {(label || "").trim() || "Daily Recap"}</span>
      </div>
      <div className="rcp-stats" style={{ fontWeight: 600 }}>📅 {preview.day}</div>
      {preview.isEmpty ? (
        <div className="rcp-empty">No closed trades on the latest session yet — this is the layout the recap will use once there are.</div>
      ) : (
        <>
          <div className="rcp-stats">{preview.stats.winRate}% win rate · avg {fmtPct(preview.stats.avg)} · {preview.stats.wins}W-{preview.stats.losses}L</div>
          {preview.play && <div className="rcp-play">🏆 <strong>Play of the day:</strong> ${preview.play.ticker} {preview.play.contract} — <strong>{fmtPct(preview.play.pct)}</strong> ({preview.play.analyst})</div>}
          {preview.closed.length > 0 && <div className="rcp-block"><div className="rcp-sec">Positions closed</div>{group(preview.closed, false)}</div>}
          {preview.trims.length > 0 && <div className="rcp-block"><div className="rcp-sec">Trims taken</div>{group(preview.trims, true)}</div>}
          <div className="rcp-link">Full detail &amp; live positions → sinuxsignals.com/app</div>
        </>
      )}
      {note && note.trim() && <div className="rcp-note">{note.trim()}</div>}
      <div className="rcp-footer">{b.logo && <img src={b.logo} alt="" className="rcp-flogo" />}<span>{b.name} · {preview.day}</span></div>
    </div>
  );
}

function ServerEditor({ server, onCancel, onSave, onReset }) {
  const [name, setName] = useState(server.name || "");
  const [enabled, setEnabled] = useState(server.enabled !== false);
  const [showRecap, setShowRecap] = useState(server.show_recap !== false);
  const [recapAutofill, setRecapAutofill] = useState(!!server.recap_autofill);
  const [liveData, setLiveData] = useState(!!server.live_data);
  const [byAnalyst, setByAnalyst] = useState(!!server.positions_by_analyst);
  // Publish-daily-recap config (the bot posts a branded recap to Discord).
  const rp = server.recap_publish || {};
  const [pubEnabled, setPubEnabled] = useState(!!rp.enabled);
  const [pubChannel, setPubChannel] = useState(rp.channel_id || "");
  const [pubRole, setPubRole] = useState(rp.role_id || "");
  const [pubLabel, setPubLabel] = useState(rp.label || "Daily Recap");
  const [pubTime, setPubTime] = useState(rp.post_et || "16:30");
  const [pubNote, setPubNote] = useState(rp.note || "");
  const [pubReactions, setPubReactions] = useState(rp.reactions || "");
  // ── Custom domain (white-label subdomain) — registered with Vercel + Discord on
  // save; these actions hit the domain endpoints directly (not the server PATCH).
  const [cdInput, setCdInput] = useState(server.custom_domain || "");
  const [cdSaved, setCdSaved] = useState(server.custom_domain || null);
  const [cdVerified, setCdVerified] = useState(!!server.custom_domain_verified);
  const [cdInfo, setCdInfo] = useState(null);
  const [cdBusy, setCdBusy] = useState(false);
  const [cdErr, setCdErr] = useState("");
  async function cdRegister() {
    const d = cdInput.trim(); if (!d) return;
    setCdBusy(true); setCdErr("");
    try {
      const r = await tapeSend(apiBase() + "/api/servers/" + server.guild_id + "/domain", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ domain: d }) });
      const j = await r.json(); setCdSaved(j.domain); setCdInput(j.domain); setCdInfo(j); setCdVerified(!!j.verified);
      toast.success(j.verified ? "Domain registered and live" : "Registered — add the CNAME below, then click Verify");
    } catch (e) { setCdErr(e.message); toast.error(e.message); }
    setCdBusy(false);
  }
  async function cdVerify() {
    setCdBusy(true); setCdErr("");
    try {
      const r = await tapeSend(apiBase() + "/api/servers/" + server.guild_id + "/domain/verify", { method: "POST" });
      const j = await r.json(); setCdVerified(!!j.verified); setCdInfo(prev => ({ ...(prev || {}), ...j }));
      if (j.verified) { setCdErr(""); toast.success("Domain verified — it's live!"); }
      else { const m = j.misconfigured ? "DNS not detected yet — the CNAME may still be propagating (a few minutes)." : "Not verified yet — add the CNAME below, then try again."; setCdErr(m); toast.error(m); }
    } catch (e) { setCdErr(e.message); toast.error(e.message); }
    setCdBusy(false);
  }
  async function cdRemove() {
    setCdBusy(true); setCdErr("");
    try {
      await tapeSend(apiBase() + "/api/servers/" + server.guild_id + "/domain", { method: "DELETE" });
      setCdSaved(null); setCdInfo(null); setCdVerified(false); setCdInput("");
      toast.info("Custom domain removed");
    } catch (e) { setCdErr(e.message); toast.error(e.message); }
    setCdBusy(false);
  }
  const [crChannels, setCrChannels] = useState(null);   // null = not yet loaded
  const [crRoles, setCrRoles] = useState(null);
  const [crErr, setCrErr] = useState("");
  const [preview, setPreview] = useState(null);
  const [previewBusy, setPreviewBusy] = useState(false);
  const [previewErr, setPreviewErr] = useState("");
  async function loadPreview() {
    setPreviewBusy(true); setPreviewErr("");
    try {
      const r = await tapeFetch(apiBase() + "/api/servers/" + server.guild_id + "/recap-preview");
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(j.error || "preview failed");
      setPreview(j);
    } catch (e) { setPreviewErr(e.message); }
    setPreviewBusy(false);
  }
  // Lazily fetch the server's channels + roles for the pickers when publishing is on.
  useEffect(() => {
    if (!pubEnabled || crChannels != null || !server.guild_id) return;
    let alive = true;
    (async () => {
      try {
        const [cr, rr] = await Promise.all([
          tapeFetch(apiBase() + "/api/servers/" + server.guild_id + "/channels"),
          tapeFetch(apiBase() + "/api/servers/" + server.guild_id + "/roles"),
        ]);
        const cj = await cr.json().catch(() => ({})), rj = await rr.json().catch(() => ({}));
        if (!alive) return;
        setCrChannels(cr.ok ? (cj.channels || []) : []);
        setCrRoles(rr.ok ? (rj.roles || []) : []);
        if (!cr.ok || !rr.ok) setCrErr(String(cj.error || rj.error || "couldn't load channels / roles"));
      } catch (e) { if (alive) { setCrChannels([]); setCrRoles([]); setCrErr(e.message); } }
    })();
    return () => { alive = false; };
  }, [pubEnabled, server.guild_id]);
  function save() {
    if (!name.trim()) return;
    onSave({
      name: name.trim(), enabled, show_recap: showRecap, recap_autofill: recapAutofill,
      live_data: liveData, positions_by_analyst: byAnalyst,
      recap_publish: {
        enabled: pubEnabled,
        channel_id: pubChannel || null, role_id: pubRole || null,
        label: pubLabel.trim() || "Daily Recap", post_et: pubTime || "16:30",
        note: pubNote.trim() || null,
        reactions: pubReactions.trim() || null,
      },
    });
  }
  return (
    <div className="row-editor">
      <div className="form-grid" style={{ gridTemplateColumns: "1.6fr 1fr" }}>
        <div className="field">
          <label className="field-label">Display name</label>
          <input className="input" value={name} onChange={e => setName(e.target.value)} />
        </div>
        <div className="field">
          <label className="field-label">Status</label>
          <label className="switch rg toggle-row">
            <input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
            <span className="switch-track"><span className="switch-thumb" /></span>
            <span className="toggle-txt">{enabled ? "enabled" : "disabled"}</span>
          </label>
        </div>
      </div>
      <div className="field" style={{ marginTop: 10 }}>
        <label className="field-label">Published daily recap</label>
        <label className="switch rg toggle-row">
          <input type="checkbox" checked={showRecap} onChange={e => setShowRecap(e.target.checked)} />
          <span className="switch-track"><span className="switch-thumb" /></span>
          <span className="toggle-txt">{showRecap ? "show the recap channel's published recap in Recaps" : "hidden — recaps are derived from tracked trades only"}</span>
        </label>
      </div>
      <div className="field" style={{ marginTop: 10 }}>
        <label className="field-label">When a recap names an exit we didn't record</label>
        <label className="switch rg toggle-row">
          <input type="checkbox" checked={recapAutofill} onChange={e => setRecapAutofill(e.target.checked)} />
          <span className="switch-track"><span className="switch-thumb" /></span>
          <span className="toggle-txt">{recapAutofill
            ? "auto-fill it as a trim on the position"
            : "manual approve — raise a To-Do and let me pick the action"}</span>
        </label>
        <div className="field-hint" style={{ marginTop: 6 }}>
          Either way the recap is checked against our own parsing first: if we already recorded a
          matching trim or close, the recap adds nothing and no To-Do is raised. A recap is never
          allowed to CLOSE a position — it can't tell "took some off" from "fully out", so auto-fill
          records a trim and leaves the position open.
        </div>
      </div>
      <div className="field" style={{ marginTop: 10 }}>
        <label className="field-label">Live market data</label>
        <label className="switch rg toggle-row">
          <input type="checkbox" checked={liveData} onChange={e => setLiveData(e.target.checked)} />
          <span className="switch-track"><span className="switch-thumb" /></span>
          <span className="toggle-txt">{liveData
            ? "on — members see live prices ticking on this server's positions"
            : "off — positions show the analyst's last stated mark"}</span>
        </label>
        <div className="field-hint" style={{ marginTop: 6 }}>
          When on, share positions (and the underlying of option plays) update from a live market
          feed; option premiums use real marks only if the data plan includes options, otherwise they
          stay analyst-stated. Turn this on only for servers whose live-data usage you intend to pay
          for — it's the switch that spends the market-data quota.
        </div>
      </div>
      <div className="field" style={{ marginTop: 10 }}>
        <label className="field-label">Open Positions layout</label>
        <label className="switch rg toggle-row">
          <input type="checkbox" checked={byAnalyst} onChange={e => setByAnalyst(e.target.checked)} />
          <span className="switch-track"><span className="switch-thumb" /></span>
          <span className="toggle-txt">{byAnalyst
            ? "analyst-first — open on a grid of analysts; tap one to see their positions"
            : "full list — every open position at once (default)"}</span>
        </label>
        <div className="field-hint" style={{ marginTop: 6 }}>
          A gentler landing when a server has many open plays: members first see the analysts (with
          each one's open-position count), then drill into an analyst. Applies when only this server
          is in view.
        </div>
      </div>
      <div className="field" style={{ marginTop: 10 }}>
        <label className="field-label">Publish daily recap to Discord</label>
        <label className="switch rg toggle-row">
          <input type="checkbox" checked={pubEnabled} onChange={e => setPubEnabled(e.target.checked)} />
          <span className="switch-track"><span className="switch-thumb" /></span>
          <span className="toggle-txt">{pubEnabled
            ? "on — the bot posts a branded daily recap to a channel"
            : "off"}</span>
        </label>
        <div className="field-hint" style={{ marginTop: 6 }}>
          After the close, the bot posts this server's recap — 🏆 play of the day, positions closed by
          analyst, trims taken, and the day's stats — as a branded embed (your logo + accent colour),
          pinging a role you choose. Computed from your tracked trades; empty days are skipped.
        </div>
        {pubEnabled && (
          <div className="form-grid" style={{ gridTemplateColumns: "1fr 1fr", marginTop: 10 }}>
            <div className="field">
              <label className="field-label">Channel</label>
              <select className="select" value={pubChannel} onChange={e => setPubChannel(e.target.value)}>
                <option value="">{crChannels == null ? "loading…" : "— pick a channel —"}</option>
                {(crChannels || []).map(c => <option key={c.id} value={c.id}>#{c.name}</option>)}
              </select>
            </div>
            <div className="field">
              <label className="field-label">Ping role (optional)</label>
              <select className="select" value={pubRole} onChange={e => setPubRole(e.target.value)}>
                <option value="">— no ping —</option>
                <option value="@here">@here (online members)</option>
                <option value="@everyone">@everyone</option>
                {(crRoles || []).map(r => <option key={r.id} value={r.id}>{r.name}{r.managed ? " (managed)" : ""}</option>)}
              </select>
              {(pubRole === "@here" || pubRole === "@everyone") && <div className="field-hint" style={{ marginTop: 4 }}>The bot needs the “Mention @everyone” permission in that channel for {pubRole} to actually ring.</div>}
            </div>
            <div className="field">
              <label className="field-label">Recap label</label>
              <input className="input" value={pubLabel} onChange={e => setPubLabel(e.target.value)} placeholder="Daily Recap" />
            </div>
            <div className="field">
              <label className="field-label">Post time (ET · after the 4pm close)</label>
              <input className="input" type="time" value={pubTime} onChange={e => setPubTime(e.target.value)} />
            </div>
            <div className="field" style={{ gridColumn: "1 / -1" }}>
              <label className="field-label">Footer note (optional — CTA / disclaimer)</label>
              <textarea className="input" rows={2} value={pubNote} onChange={e => setPubNote(e.target.value)} placeholder="e.g. Not financial advice — educational only. Join → …" />
            </div>
            <div className="field" style={{ gridColumn: "1 / -1" }}>
              <label className="field-label">Auto reactions (optional)</label>
              <input className="input" value={pubReactions} onChange={e => setPubReactions(e.target.value)} placeholder="🔥 💰 📈  (space-separated)" />
              <div className="field-hint">Emojis the bot adds under the recap right after posting — space-separated. Unicode (🔥) or your server's custom emojis (<span className="mono">&lt;:name:id&gt;</span>). Leave blank for none.</div>
            </div>
            {crErr && <div className="field-hint" style={{ gridColumn: "1 / -1", color: "var(--c-down)" }}>{crErr}</div>}
            <div className="field-hint" style={{ gridColumn: "1 / -1" }}>
              Title reads <strong>{(server.branding && server.branding.name) || "Sinux Signals"} — {pubLabel || "Daily Recap"}</strong>. The stats, play of the day, closed positions, trims and the “Full detail →” link are filled in automatically from your tracked trades.
            </div>
            <div className="field" style={{ gridColumn: "1 / -1" }}>
              <button className="btn btn-sm" type="button" onClick={loadPreview} disabled={previewBusy}>
                {I("eye", { size: 13 })} {previewBusy ? "Loading…" : (preview ? "Refresh preview" : "Preview message")}
              </button>
              {previewErr && <span className="field-hint" style={{ color: "var(--c-down)", marginLeft: 8 }}>{previewErr}</span>}
              {preview && <RecapPreviewCard preview={preview} label={pubLabel} note={pubNote} />}
            </div>
          </div>
        )}
      </div>
      <div className="field" style={{ marginTop: 16 }}>
        <label className="field-label">Custom domain <span style={{ color: "var(--fg-3)", fontWeight: 400 }}>(serve this portal on your own subdomain)</span></label>
        <div className="field-hint" style={{ marginBottom: 8 }}>
          Point a subdomain like <span className="mono">signals.yourbrand.com</span> here and members reach the portal on your own domain — auto-branded and scoped to this server. Register it, then complete the two required steps shown (a DNS record + a Discord OAuth callback) and Verify.
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          <input className="input mono" style={{ flex: "1 1 240px", fontSize: "0.8rem" }} placeholder="signals.yourbrand.com" value={cdInput} onChange={e => setCdInput(e.target.value)} disabled={cdBusy || !!cdSaved} />
          {!cdSaved
            ? <button className="btn primary" type="button" onClick={cdRegister} disabled={cdBusy || !cdInput.trim()}>{cdBusy ? "Working…" : "Register"}</button>
            : <>
                <button className="btn" type="button" onClick={cdVerify} disabled={cdBusy}>{cdBusy ? "Checking…" : "Verify"}</button>
                <button className="btn danger" type="button" onClick={cdRemove} disabled={cdBusy} title="Remove this custom domain">Remove</button>
              </>}
        </div>
        {cdErr && <div className="field-hint" style={{ color: "var(--c-down)", marginTop: 6 }}>{cdErr}</div>}
        {cdSaved && (
          <div style={{ marginTop: 10, padding: 12, border: "1px solid var(--border-2)", borderRadius: 8, background: "var(--bg-2)" }}>
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
              <span className="mono" style={{ fontWeight: 600 }}>{cdSaved}</span>
              <span style={{ fontSize: "0.7rem", fontWeight: 700, padding: "2px 8px", borderRadius: 999, background: cdVerified ? "var(--accent-glow)" : "var(--bg-3)", color: cdVerified ? "var(--accent)" : "var(--fg-2)" }}>
                {cdVerified ? "✓ LIVE" : "PENDING DNS"}
              </span>
            </div>
            <div className="field-hint" style={{ marginBottom: 6 }}>Two steps to go live — <strong>both required</strong>:</div>

            <div className="field-hint" style={{ marginBottom: 4, fontWeight: 600 }}>1 · Add this record at your DNS provider</div>
            <div className="mono" style={{ fontSize: "0.72rem", padding: "8px 10px", background: "var(--bg-0)", borderRadius: 6, overflowX: "auto", whiteSpace: "nowrap" }}>
              CNAME&nbsp;&nbsp;<strong>{(cdInfo && cdInfo.cname && cdInfo.cname.name) || cdSaved.split(".")[0]}</strong>&nbsp;&nbsp;→&nbsp;&nbsp;<strong>{(cdInfo && cdInfo.cname && cdInfo.cname.value) || "cname.vercel-dns.com"}</strong>
            </div>

            <div className="field-hint" style={{ marginTop: 12, marginBottom: 4, fontWeight: 600 }}>
              2 · Add this OAuth callback in the <span style={{ color: "var(--accent)" }}>Discord Developer Portal</span> → your app → OAuth2 → Redirects
            </div>
            <div className="mono" style={{ fontSize: "0.72rem", padding: "8px 10px", background: "var(--bg-0)", borderRadius: 6, overflowX: "auto", whiteSpace: "nowrap" }}>
              https://{cdSaved}/api/auth/callback
            </div>
            <div className="field-hint" style={{ marginTop: 4, color: "var(--c-down)" }}>
              Required — Discord has no API for this, so it must be added by hand. Without it, sign-in on this domain fails with “Invalid OAuth state”.
            </div>

            <div className="field-hint" style={{ marginTop: 12 }}>
              Then click <strong>Verify</strong> once DNS propagates (usually a few minutes).
            </div>
            {cdInfo && cdInfo.discordAdded === true && (
              <div className="field-hint" style={{ marginTop: 4, color: "var(--accent)" }}>✓ The OAuth callback was added to Discord automatically — step 2 is done.</div>
            )}
          </div>
        )}
      </div>
      <div className="form-actions" style={{ justifyContent: onReset ? "space-between" : "flex-end" }}>
        {onReset && (
          <button className="btn danger" type="button" onClick={onReset} title="Delete all trade data for this server (keeps channels, analysts & access)">
            {I("trash", { size: 13 })} Reset data
          </button>
        )}
        <div style={{ display: "flex", gap: 8 }}>
          <button className="btn" onClick={onCancel}>Cancel</button>
          <button className="btn primary" onClick={save}>{I("check", { size: 14 })} Save changes</button>
        </div>
      </div>
    </div>
  );
}

function ServersScreen({ servers, setServers, onSelectServer, canProvision }) {
  const [editingId, setEditingId] = useState(null);

  function onServerAdded(row) {
    setServers && setServers(prev => {
      const exists = prev.some(s => s.guild_id === row.guild_id);
      return exists ? prev.map(s => s.guild_id === row.guild_id ? row : s) : [...prev, row];
    });
  }
  async function saveServer(gid, patch) {
    const before = servers.find(s => s.guild_id === gid);
    setServers && setServers(prev => prev.map(s => s.guild_id === gid ? { ...s, ...patch } : s));
    setEditingId(null);
    try {
      await tapeSend(apiBase() + "/api/servers/" + gid, {
        method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch),
      });
    } catch (err) {
      if (before) setServers && setServers(prev => prev.map(s => s.guild_id === gid ? before : s));
      toast.error("Couldn't save server — " + err.message);
    }
  }
  async function resetServerData(s) {
    if (!(await confirmDialog({
      title: `Reset ${s.name}'s data?`,
      message: `This permanently deletes ALL recorded trade data for ${s.name} — open positions, signal events, watchlists and recaps.\n\nChannels, analysts, users and access are KEPT. This cannot be undone.`,
      confirmLabel: "Reset data", danger: true, requireText: s.name,
    }))) return;
    try {
      const j = await tapeSend(apiBase() + "/api/servers/" + s.guild_id + "/data", { method: "DELETE" }).then(r => r.json());
      const n = Object.values((j && j.deleted) || {}).reduce((a, b) => a + Number(b || 0), 0);
      toast.success(`Reset ${s.name} — cleared ${n} record${n === 1 ? "" : "s"}. Refresh to see the empty state.`);
      setEditingId(null);
    } catch (e) { toast.error("Couldn't reset — " + e.message); }
  }
  async function removeServer(s) {
    if (!(await confirmDialog({ title: `Remove ${s.name}?`, message: "This deletes the server and ALL of its channels, analysts and recorded events. This cannot be undone.", confirmLabel: "Delete server", danger: true }))) return;
    setServers && setServers(prev => prev.filter(x => x.guild_id !== s.guild_id));
    try {
      await tapeSend(apiBase() + "/api/servers/" + s.guild_id, { method: "DELETE" });
      toast.success(`Removed ${s.name}`);
    } catch (err) {
      setServers && setServers(prev => prev.some(x => x.guild_id === s.guild_id) ? prev : [...prev, s]);
      toast.error("Couldn't remove server — " + err.message);
    }
  }

  return (
    <div>
      <h2>Servers</h2>
      <p className="sub">Discord servers Sinux Signals pulls signals from. {canProvision ? "Add a server, then configure its channels and analysts. The bot must already be a member of each server." : "Edit your servers' name, branding and recap visibility. Adding or removing servers is owner-only."}</p>

      {canProvision && <div className="add-bar"><AddServerForm onAdded={onServerAdded} onSelectServer={onSelectServer} /></div>}

      {(!servers || servers.length === 0) && (
        <div className="empty" style={{ padding: 40 }}>{canProvision ? "No servers yet. Use the form above to add one." : "No servers assigned to you."}</div>
      )}

      {servers && servers.length > 0 && (
        <div className="rows">
          {servers.map(s => (
            <Fragment key={s.guild_id}>
              <div className="channel-row" style={{ gridTemplateColumns: "24px minmax(0,1fr) auto auto" }}>
                <span className="hash" style={{ color: "var(--fg-2)" }}>{I("server", { size: 15 })}</span>
                <div className="row-main" style={{ minWidth: 0 }}>
                  <div className="name">{s.name}</div>
                  <div className="id">{s.guild_id}</div>
                </div>
                <span className="live-dot" style={{ color: s.enabled !== false ? "var(--c-up)" : "var(--fg-3)" }}>
                  <span className="d" />{s.enabled !== false ? "enabled" : "off"}
                </span>
                <div className="row-actions">
                  <button className={cx("icon-btn", editingId === s.guild_id && "active")} title="Edit server" onClick={() => setEditingId(editingId === s.guild_id ? null : s.guild_id)}>{I("edit", { size: 15 })}</button>
                  {canProvision && <button className="icon-btn danger" title="Remove server" onClick={() => removeServer(s)}>{I("trash", { size: 15 })}</button>}
                </div>
              </div>
              {editingId === s.guild_id && (
                <ServerEditor server={s} onCancel={() => setEditingId(null)} onSave={(patch) => saveServer(s.guild_id, patch)} onReset={canProvision ? () => resetServerData(s) : undefined} />
              )}
            </Fragment>
          ))}
        </div>
      )}
    </div>
  );
}

// Role + per-server scope controls, shared by the add and edit user forms.
// `actorRole` = the signed-in user's role (limits which roles they can assign).
// ── Roles + per-admin capability toggles ─────────────────────────────────────
const ROLE_LABELS = { server_owner: "server owner", admin: "admin", editor: "editor", analyst: "analyst", viewer: "viewer" };
function assignableRoles(actorRole) {
  if (actorRole === "owner") return ["server_owner", "admin", "editor", "analyst", "viewer"];
  if (actorRole === "server_owner") return ["admin", "editor", "analyst", "viewer"];
  if (actorRole === "admin") return ["editor", "analyst", "viewer"];
  return ["viewer"];   // editor
}
const ADMIN_PERMS = [
  ["channels",  "Manage channels",            "Add, edit & remove channels and their categories."],
  ["analysts",  "Manage analysts",            "Add, edit & remove analysts and their settings."],
  ["users",     "Manage users & access",      "Add / edit users and configure Discord role grants."],
  ["positions", "Manage & moderate positions","Log position updates and delete events / watchlists."],
  ["server",    "Manage server settings",     "Rename, branding and the recap-visibility toggle."],
  ["bugs",      "View bug reports",           "See & triage submitted reports (all servers). Owner-granted only."],
  ["ratings",   "View ratings",               "See & manage user ratings for their servers. Owner-granted only."],
  ["todos",     "View the To-Do queue",       "See & clear reconciliation and parse tasks for their servers. Owner-granted only."],
];
// A new admin starts with NO capabilities; a LEGACY admin (role admin, empty
// permissions) keeps the old full set so editing them doesn't strip access.
function initAdminPerms(user) {
  const p = (user && user.permissions) || {};
  if (user && user.role === "admin" && Object.keys(p).length === 0) {
    return { channels: true, analysts: true, users: true, positions: !!user.can_manage_positions, server: false, bugs: false, ratings: false, todos: false };
  }
  return { channels: !!p.channels, analysts: !!p.analysts, users: !!p.users, positions: !!p.positions, server: !!p.server, bugs: !!p.bugs, ratings: !!p.ratings, todos: !!p.todos };
}
function PermSwitch({ title, sub, checked, onChange }) {
  return (
    <div className="perm-row">
      <div className="perm-txt">
        <div className="perm-title">{title}</div>
        <div className="perm-sub">{sub}</div>
      </div>
      <label className="switch" title={checked ? "On — click to revoke" : "Off — click to grant"}>
        <input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
        <span className="switch-track"><span className="switch-thumb" /></span>
      </label>
    </div>
  );
}
function PermissionToggles({ perms, setPerms, actorRole }) {
  return (
    <div className="perm-list">
      <div className="field-label" style={{ marginBottom: 2 }}>Admin permissions</div>
      {ADMIN_PERMS.filter(([k]) => ((k !== "bugs" && k !== "ratings" && k !== "todos") || actorRole === "owner")).map(([k, title, sub]) => (
        <PermSwitch key={k} title={title} sub={sub} checked={!!perms[k]} onChange={(v) => setPerms(p => ({ ...p, [k]: v }))} />
      ))}
    </div>
  );
}
// Server owners already carry every server-admin cap from their role; the only
// owner-granted extra is viewing ratings. Shown only when the actor is the owner.
function ServerOwnerExtras({ perms, setPerms, actorRole }) {
  if (actorRole !== "owner") return null;
  return (
    <div className="perm-list">
      <div className="field-label" style={{ marginBottom: 2 }}>Extra access</div>
      <PermSwitch title="View ratings" sub="See & manage user ratings for their servers." checked={!!perms.ratings} onChange={(v) => setPerms(p => ({ ...p, ratings: v }))} />
      <PermSwitch title="View the To-Do queue" sub="See & clear reconciliation and parse tasks for their servers." checked={!!perms.todos} onChange={(v) => setPerms(p => ({ ...p, todos: v }))} />
    </div>
  );
}

// Per-server role assignment: each server the user belongs to carries its OWN
// role — server_owner in one place can be a plain viewer in another. Stored as
// permissions.server_roles ({guild_id: role}); the API derives scope + the
// top-level (highest) role from it.
const ROLE_HINTS = {
  server_owner: "Full control of that server — channels, analysts, users, positions and server settings.",
  admin: "Pick exactly what this admin can do with the toggles below.",
  editor: "Manages individual users (viewers) and views — but can't edit — channels & analysts.",
  analyst: "Can delete their OWN events & watchlists, and (when toggled) manage their own positions.",
  viewer: "Read-only access to that server.",
};
function ServerRolesControls({ serverRoles, setServerRoles, servers, actorRole }) {
  const roleOpts = assignableRoles(actorRole);
  function toggleServer(gid) {
    setServerRoles(prev => {
      const n = { ...prev };
      if (n[gid]) delete n[gid]; else n[gid] = "viewer";
      return n;
    });
  }
  const picked = Object.values(serverRoles);
  const hint = picked.length === 1 ? ROLE_HINTS[picked[0]]
    : "Each server carries its own role — e.g. Analyst in one community, Viewer in another.";
  return (
    <>
      <div className="form-scope">
        <span className="scope-title">Servers &amp; roles</span>
        <div className="scope-role-list">
          {(servers || []).map(s => {
            const on = !!serverRoles[s.guild_id];
            return (
              <div key={s.guild_id} className={cx("scope-role-row", on && "on")}>
                <button type="button" className={cx("scope-chip", on && "on")} onClick={() => toggleServer(s.guild_id)}>
                  <span className="scope-chip-box">{on && I("check", { size: 11 })}</span>{s.name}
                </button>
                {on && (
                  <select className="select" value={serverRoles[s.guild_id]} onChange={e => setServerRoles(prev => ({ ...prev, [s.guild_id]: e.target.value }))} style={{ fontFamily: "var(--f-mono)", fontSize: "0.75rem" }}>
                    {roleOpts.map(r => <option key={r} value={r}>{ROLE_LABELS[r] || r}</option>)}
                  </select>
                )}
              </div>
            );
          })}
          {(!servers || !servers.length) && <span className="field-hint">No servers available.</span>}
        </div>
      </div>
      <div className="field-hint" style={{ marginTop: 10 }}>{hint} Anyone also holding a granting Discord role gets the <strong>higher</strong> of the two in that server.</div>
    </>
  );
}

function AddUserForm({ onAdded, servers, role: actorRole }) {
  const [open, setOpen] = useState(false);
  const [uid, setUid] = useState("");
  const [name, setName] = useState("");
  const [serverRoles, setServerRoles] = useState({});   // guild_id -> role
  const [perms, setPerms] = useState({ channels: false, analysts: false, users: false, positions: false, server: false, bugs: false, ratings: false, todos: false });
  const [canManage, setCanManage] = useState(false);
  const [eventRail, setEventRail] = useState(false);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);
  const pickedRoles = Object.values(serverRoles);

  async function submit(e) {
    e.preventDefault();
    setErr(null);
    if (!uid.trim()) { setErr("Discord user ID is required"); return; }
    if (!pickedRoles.length) { setErr("Select at least one server"); return; }
    setBusy(true);
    try {
      const body = { discord_user_id: uid.trim(), name: name.trim(), server_roles: serverRoles };
      const perm = (pickedRoles.includes("admin") || pickedRoles.includes("server_owner")) ? { ...perms } : {};
      if (eventRail) perm.event_rail = true;
      if (Object.keys(perm).length) body.permissions = perm;
      if (pickedRoles.includes("analyst")) body.can_manage_positions = canManage;
      const r = await tapeFetch(apiBase() + "/api/users", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify(body),
      });
      const j = await r.json();
      if (!r.ok) throw new Error(j.error || "request failed");
      onAdded && onAdded(j.user);
      toast.success("Access granted");
      setUid(""); setName(""); setServerRoles({});
      setPerms({ channels: false, analysts: false, users: false, positions: false, server: false, bugs: false, ratings: false, todos: false }); setCanManage(false); setEventRail(false); setOpen(false);
    } catch (e) {
      setErr(e.message);
    } finally {
      setBusy(false);
    }
  }

  if (!open) {
    return (
      <button className="btn primary" onClick={() => setOpen(true)}>
        {I("plus", { size: 14 })} Add user
      </button>
    );
  }

  return (
    <form onSubmit={submit} className="form-card">
      <div className="form-card-hdr">
        <h3>Grant access</h3>
        <button type="button" className="icon-btn" title="Close" onClick={() => setOpen(false)}>{I("close", { size: 16 })}</button>
      </div>
      <div className="form-grid" style={{ gridTemplateColumns: "1.2fr 1.6fr" }}>
        <div className="field">
          <label className="field-label">Discord user ID</label>
          <input className="input mono" placeholder="e.g. 1211348259470053487" value={uid} onChange={e => setUid(e.target.value)} style={{ fontSize: "0.75rem" }} />
          <DiscordNameResolver type="user" id={uid} nameValue={name} setName={setName} />
        </div>
        <div className="field">
          <label className="field-label">Name (optional)</label>
          <input className="input" placeholder="auto-fills from the ID" value={name} onChange={e => setName(e.target.value)} />
        </div>
      </div>
      <ServerRolesControls serverRoles={serverRoles} setServerRoles={setServerRoles} servers={servers} actorRole={actorRole} />
      {pickedRoles.includes("admin") && <PermissionToggles perms={perms} setPerms={setPerms} actorRole={actorRole} />}
      {pickedRoles.includes("server_owner") && <ServerOwnerExtras perms={perms} setPerms={setPerms} actorRole={actorRole} />}
      {pickedRoles.includes("analyst") && <PermSwitch title="Can manage positions" sub="Log closes, trims, adds & open new positions — limited to their own." checked={canManage} onChange={setCanManage} />}
      {pickedRoles.length > 0 && <PermSwitch title="Latest events rail" sub="Show the live Latest-events feed beside Open Positions (hidden by default)." checked={eventRail} onChange={setEventRail} />}
      {err && <div className="form-err">{I("alert", { size: 12 })} {err}</div>}
      <div className="form-actions">
        <span className="field-hint">They sign in with Discord. Right-click a user → Copy User ID (Developer Mode on).</span>
        <button type="submit" className="btn primary" disabled={busy}>
          {busy ? "Adding…" : <>{I("plus", { size: 14 })} Grant access</>}
        </button>
      </div>
    </form>
  );
}

// Seed the per-server role map from the stored permissions.server_roles, falling
// back to the legacy single role spread over the scope.
function initServerRoles(user) {
  const stored = (user.permissions && user.permissions.server_roles) || null;
  if (stored && typeof stored === "object" && Object.keys(stored).length) return { ...stored };
  const out = {};
  for (const g of (user.server_scope || [])) if (g && g !== "*") out[g] = user.role || "viewer";
  return out;
}

function UserEditor({ user, servers, role: actorRole, onCancel, onSave }) {
  const [name, setName] = useState(user.name || "");
  const [serverRoles, setServerRoles] = useState(() => initServerRoles(user));
  const [perms, setPerms] = useState(() => initAdminPerms(user));
  const [canManage, setCanManage] = useState(user.can_manage_positions === true);
  const [eventRail, setEventRail] = useState(!!(user.permissions && user.permissions.event_rail));
  const pickedRoles = Object.values(serverRoles);
  function save() {
    // Status (enabled) is toggled directly on the row, not here. The server
    // derives scope + the top-level role from server_roles and clears
    // capabilities for roles that don't carry them. event_rail is sent
    // explicitly (true/false) so switching it OFF sticks.
    const patch = { name: name.trim() || null, server_roles: serverRoles };
    patch.permissions = {
      ...((pickedRoles.includes("admin") || pickedRoles.includes("server_owner")) ? perms : {}),
      event_rail: eventRail,
    };
    if (pickedRoles.includes("analyst")) patch.can_manage_positions = canManage;
    onSave(patch);
  }
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onCancel(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);
  return (
    <div className="modal-back" onMouseDown={onCancel}>
      <div className="modal user-modal" role="dialog" aria-modal="true" onMouseDown={(ev) => ev.stopPropagation()}>
        <div className="calc-hdr">
          <div>
            <div className="calc-title">{I("edit", { size: 16 })} Edit access</div>
            <div className="calc-sub mono">{user.name || user.discord_user_id}</div>
          </div>
          <button className="icon-btn" title="Close" onClick={onCancel}>{I("close", { size: 16 })}</button>
        </div>
        <div className="calc-body">
          <div className="form-grid" style={{ gridTemplateColumns: "1fr 1fr" }}>
            <div className="field">
              <label className="field-label">Name</label>
              <input className="input" value={name} onChange={e => setName(e.target.value)} />
            </div>
            <div className="field">
              <label className="field-label">Discord user ID (fixed)</label>
              <input className="input mono" value={user.discord_user_id} disabled style={{ fontSize: "0.75rem", opacity: 0.65 }} />
            </div>
          </div>
          <ServerRolesControls serverRoles={serverRoles} setServerRoles={setServerRoles} servers={servers} actorRole={actorRole} />
          {pickedRoles.includes("admin") && <PermissionToggles perms={perms} setPerms={setPerms} actorRole={actorRole} />}
          {pickedRoles.includes("server_owner") && <ServerOwnerExtras perms={perms} setPerms={setPerms} actorRole={actorRole} />}
          {pickedRoles.includes("analyst") && <PermSwitch title="Can manage positions" sub="Log closes, trims, adds & open new positions — limited to their own." checked={canManage} onChange={setCanManage} />}
          <PermSwitch title="Latest events rail" sub="Show the live Latest-events feed beside Open Positions (hidden by default)." checked={eventRail} onChange={setEventRail} />
        </div>
        <div className="modal-actions">
          <button className="btn" onClick={onCancel}>Cancel</button>
          <button className="btn primary" onClick={save} disabled={!pickedRoles.length}>{I("check", { size: 14 })} Save changes</button>
        </div>
      </div>
    </div>
  );
}

// Access levels a Discord role can grant. Grants MERGE with an individual entry
// per server — highest tier wins. (To block a specific person, add them
// individually and Suspend — suspension beats everything.)
const GRANT_LEVELS = [["viewer", "Viewer"], ["analyst", "Analyst"], ["editor", "Editor"], ["admin", "Admin"], ["server_owner", "Server owner"]];

function AddRoleGrantForm({ servers, onAdded, grantLevels }) {
  const [open, setOpen] = useState(false);
  const [guildId, setGuildId] = useState(servers[0]?.guild_id || "");
  const [roleId, setRoleId] = useState("");
  const [roleName, setRoleName] = useState("");
  const [level, setLevel] = useState("viewer");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState(null);
  // Live role picker (read from Discord via the bot). null = loading.
  const [roles, setRoles] = useState(null);
  const [rolesErr, setRolesErr] = useState(null);
  const [manual, setManual] = useState(false);

  useEffect(() => { if (!guildId && servers[0]) setGuildId(servers[0].guild_id); }, [servers]);

  // Pull the server's roles whenever the form opens or the server changes.
  useEffect(() => {
    if (!open || !guildId) return;
    let alive = true;
    setRoles(null); setRolesErr(null); setRoleId(""); setRoleName("");
    tapeFetch(apiBase() + "/api/servers/" + guildId + "/roles")
      .then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || "failed to load roles"); return j; })
      .then(j => { if (alive) setRoles(j.roles || []); })
      .catch(e => { if (alive) { setRolesErr(e.message); setManual(true); } });
    return () => { alive = false; };
  }, [open, guildId]);

  function pickRole(id) {
    setRoleId(id);
    const r = (roles || []).find(x => x.id === id);
    setRoleName(r ? r.name : "");
  }

  async function submit(e) {
    e.preventDefault();
    setErr(null);
    if (!guildId) { setErr("Pick a server"); return; }
    if (!roleId.trim()) { setErr("Pick a role (or enter its ID)"); return; }
    setBusy(true);
    try {
      const r = await tapeFetch(apiBase() + "/api/role-grants", {
        method: "POST", headers: { "content-type": "application/json" },
        body: JSON.stringify({ guild_id: guildId, role_id: roleId.trim(), role_name: roleName.trim(), level }),
      });
      const j = await r.json();
      if (!r.ok) throw new Error(j.error || "request failed");
      onAdded && onAdded(j.grant);
      toast.success("Role grant saved");
      setRoleId(""); setRoleName(""); setLevel("viewer"); setOpen(false);
    } catch (e) { setErr(e.message); } finally { setBusy(false); }
  }

  if (!open) {
    return (
      <button className="btn primary" onClick={() => setOpen(true)}>
        {I("plus", { size: 14 })} Add role
      </button>
    );
  }

  const toggleStyle = { background: "none", border: "none", color: "var(--accent)", cursor: "pointer", font: "inherit", fontSize: "0.6875rem", padding: 0 };

  return (
    <form onSubmit={submit} className="form-card">
      <div className="form-card-hdr">
        <h3>Grant access by role</h3>
        <button type="button" className="icon-btn" title="Close" onClick={() => setOpen(false)}>{I("close", { size: 16 })}</button>
      </div>
      <div className="form-grid" style={{ gridTemplateColumns: "1fr 1fr" }}>
        <div className="field">
          <label className="field-label">Server</label>
          <select className="select" value={guildId} onChange={e => setGuildId(e.target.value)}>
            {servers.map(s => <option key={s.guild_id} value={s.guild_id}>{s.name || s.guild_id}</option>)}
          </select>
        </div>
        <div className="field">
          <label className="field-label">Access level</label>
          <select className="select" value={level} onChange={e => setLevel(e.target.value)}>
            {grantLevels.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
          </select>
        </div>
        <div className="field" style={{ gridColumn: "1 / -1" }}>
          <label className="field-label" style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
            <span>Role</span>
            <button type="button" style={toggleStyle} onClick={() => setManual(m => !m)}>
              {manual ? "pick from list" : "enter ID manually"}
            </button>
          </label>
          {!manual ? (
            <select className="select" value={roleId} onChange={e => pickRole(e.target.value)} disabled={roles === null}>
              <option value="">{roles === null ? "Loading roles…" : "Select a role…"}</option>
              {(roles || []).map(r => <option key={r.id} value={r.id}>{r.name}{r.managed ? "  (managed)" : ""}</option>)}
            </select>
          ) : (
            <div className="form-grid" style={{ gridTemplateColumns: "1.2fr 1fr", gap: 10, marginTop: 0 }}>
              <input className="input mono" placeholder="Discord role ID" value={roleId} onChange={e => setRoleId(e.target.value)} style={{ fontSize: "0.75rem" }} />
              <input className="input" placeholder="Role name (label)" value={roleName} onChange={e => setRoleName(e.target.value)} />
            </div>
          )}
          {rolesErr && <span className="field-hint" style={{ color: "var(--c-down)" }}>Couldn't load roles ({rolesErr}). Enter the ID manually.</span>}
        </div>
      </div>
      {err && <div className="form-err">{I("alert", { size: 12 })} {err}</div>}
      <div className="form-actions">
        <span className="field-hint">{manual
          ? "Server Settings → Roles → right-click a role → Copy Role ID (Developer Mode on)."
          : "Roles are read live from Discord via the bot."}</span>
        <button type="submit" className="btn primary" disabled={busy}>
          {busy ? "Saving…" : <>{I("plus", { size: 14 })} Add role grant</>}
        </button>
      </div>
    </form>
  );
}

function RoleGrants({ servers, actorRole }) {
  // The dropdown must offer only levels this actor may assign — the API refuses
  // the rest anyway (an admin cannot mint another admin via a role grant).
  const grantLevels = GRANT_LEVELS.filter(([v]) => assignableRoles(actorRole || "admin").includes(v));
  const [grants, setGrants] = useState([]);
  const [loaded, setLoaded] = useState(false);
  const [roleIcons, setRoleIcons] = useState({});   // role_id -> static icon URL
  const serverName = (gid) => (servers.find(s => s.guild_id === gid)?.name) || gid;
  // Same per-server grouping as the Analysts/Channels screens: a header per
  // server whenever grants from more than one server are in view.
  const grantGroups = useMemo(() => {
    const m = new Map();
    for (const g of grants) { const k = g.guild_id || "—"; if (!m.has(k)) m.set(k, []); m.get(k).push(g); }
    return [...m.entries()];
  }, [grants]);
  const multiServer = grantGroups.length > 1;

  useEffect(() => {
    tapeFetch(apiBase() + "/api/role-grants")
      .then(r => r.json())
      .then(j => { setGrants(j.grants || []); setLoaded(true); })
      .catch(() => setLoaded(true));
  }, []);

  // Optional: pull each granted role's icon (static .png). Most roles have none,
  // so this just enriches the ones that do — the shield stays the fallback.
  const grantGuildKey = [...new Set(grants.map(g => g.guild_id))].sort().join(",");
  useEffect(() => {
    const guilds = grantGuildKey ? grantGuildKey.split(",") : [];
    if (!guilds.length) return;
    let alive = true;
    Promise.all(guilds.map(gid =>
      tapeFetch(apiBase() + "/api/servers/" + gid + "/roles")
        .then(r => (r.ok ? r.json() : { roles: [] }))
        .catch(() => ({ roles: [] }))
    )).then(results => {
      if (!alive) return;
      const m = {};
      results.forEach(res => (res.roles || []).forEach(role => {
        if (role.icon) m[role.id] = `https://cdn.discordapp.com/role-icons/${role.id}/${role.icon}.png?size=32`;
      }));
      setRoleIcons(m);
    });
    return () => { alive = false; };
  }, [grantGuildKey]);

  function onAdded(g) {
    setGrants(prev => {
      const exists = prev.some(x => x.id === g.id);
      return exists ? prev.map(x => x.id === g.id ? g : x) : [...prev, g];
    });
  }
  // tapeFetch doesn't throw on HTTP errors — every mutation must check r.ok and
  // ROLL BACK its optimistic update on failure, or the switch shows a state the
  // server never saved (a disabled grant rendering as enabled locks people out
  // while the admin believes access is on).
  async function patchGrant(id, body) {
    const r = await tapeFetch(apiBase() + "/api/role-grants/" + id, {
      method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
    });
    if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || "HTTP " + r.status);
  }
  async function setLevel(g, level) {
    const prevLevel = g.level;
    setGrants(prev => prev.map(x => x.id === g.id ? { ...x, level } : x));
    try {
      await patchGrant(g.id, { level });
      toast.success("Level updated");
    } catch (err) {
      setGrants(prev => prev.map(x => x.id === g.id ? { ...x, level: prevLevel } : x));
      toast.error("Couldn't update level — " + err.message);
    }
  }
  async function toggleEnabled(g) {
    const turningOff = g.enabled !== false;
    const ok = await confirmDialog({
      title: turningOff ? "Disable role access" : "Enable role access",
      message: turningOff
        ? `Everyone holding "${g.role_name || g.role_id}" on ${serverName(g.guild_id)} will lose their ${g.level} access. Individually-added users are unaffected.`
        : `Re-enable ${g.level} access for everyone holding "${g.role_name || g.role_id}" on ${serverName(g.guild_id)}?`,
      confirmLabel: turningOff ? "Disable role" : "Enable role",
      danger: turningOff,
    });
    if (!ok) return;
    setGrants(prev => prev.map(x => x.id === g.id ? { ...x, enabled: !turningOff } : x));
    try {
      await patchGrant(g.id, { enabled: !turningOff });
      toast.success(turningOff ? "Role access disabled" : "Role access enabled");
    } catch (err) {
      setGrants(prev => prev.map(x => x.id === g.id ? { ...x, enabled: turningOff } : x));
      toast.error("Couldn't update — " + err.message);
    }
  }
  async function remove(g) {
    if (!(await confirmDialog({ title: "Remove role grant", message: `Stop granting "${g.role_name || g.role_id}"?`, confirmLabel: "Remove", danger: true }))) return;
    setGrants(prev => prev.filter(x => x.id !== g.id));
    try {
      const r = await tapeFetch(apiBase() + "/api/role-grants/" + g.id, { method: "DELETE" });
      if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || "HTTP " + r.status);
      toast.success("Role grant removed");
    } catch (err) {
      setGrants(prev => prev.some(x => x.id === g.id) ? prev : [...prev, g]);
      toast.error("Couldn't remove grant — " + err.message);
    }
  }

  return (
    <div style={{ marginTop: 36 }}>
      <h2>Role access</h2>
      <p className="sub">Grant portal access to everyone holding a Discord role. Per server, the <strong>highest tier wins</strong> — an individual entry and a granting Discord role combine, so an analyst who also holds a Mod/VIP role gets the higher level in that server. <strong>Suspended</strong> beats everything — suspend an individual entry to block someone even though they hold a granting role.</p>

      <div className="add-bar"><AddRoleGrantForm servers={servers} onAdded={onAdded} grantLevels={grantLevels} /></div>

      {loaded && grants.length === 0 && (
        <div className="empty" style={{ padding: 30 }}>No role grants yet. Map a Discord role ID to an access level.</div>
      )}
      {grants.length > 0 && (
        <div className="rows access-list">
          {grantGroups.map(([gid, glist]) => (
          <Fragment key={"srv-" + gid}>
            {multiServer && (
              <div className="setting-grp">
                {I("server", { size: 13 })}
                <span className="setting-grp-name">{serverName(gid)}</span>
                <span className="setting-grp-n mono">{glist.length} role{glist.length === 1 ? "" : "s"}</span>
              </div>
            )}
            {glist.map(g => {
            const on = g.enabled !== false;
            return (
            <div key={g.id} className="channel-row" style={{ gridTemplateColumns: "24px minmax(0,1fr) auto auto auto", opacity: on ? 1 : 0.55 }}>
              {roleIcons[g.role_id]
                ? <img src={roleIcons[g.role_id]} alt="" width={20} height={20} style={{ borderRadius: 5, objectFit: "cover", flexShrink: 0, display: "block" }} />
                : <span className="hash" style={{ color: g.level === "blocked" ? "var(--c-down)" : "var(--fg-2)" }}>{I("shield", { size: 15 })}</span>}
              <div className="row-main" style={{ minWidth: 0 }}>
                <div className="name">
                  {g.role_name || "Role"}
                  <span className="mono" style={{ fontSize: "0.625rem", color: "var(--fg-3)", marginLeft: 6 }}>{g.role_id}</span>
                </div>
                {!multiServer && <div className="id">{serverName(g.guild_id)}</div>}
              </div>
              <label className="switch" title={on ? "Active — click to disable this role grant" : "Disabled — click to enable"}>
                <input type="checkbox" checked={on} onChange={() => toggleEnabled(g)} />
                <span className="switch-track"><span className="switch-thumb" /></span>
              </label>
              <select className="select" value={g.level} onChange={e => setLevel(g, e.target.value)} style={{ fontFamily: "var(--f-mono)", fontSize: "0.75rem" }}>
                {grantLevels.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
              </select>
              <div className="row-actions">
                <button className="icon-btn danger" title="Remove grant" onClick={() => remove(g)}>{I("trash", { size: 15 })}</button>
              </div>
            </div>
            );})}
          </Fragment>
          ))}
        </div>
      )}
    </div>
  );
}

function AccessScreen({ servers, role }) {
  const [users, setUsers] = useState([]);
  const [loaded, setLoaded] = useState(false);
  const [editingId, setEditingId] = useState(null);
  const [roleFilter, setRoleFilter] = useState("all");
  const avatars = useAvatars(users.map(u => u.discord_user_id));
  // Which existing users you may edit/remove (matches the server-side rule).
  const canActOn = (targetRole) =>
    role === "owner" ? ["server_owner", "admin", "editor", "analyst", "viewer"].includes(targetRole)
    : role === "server_owner" ? ["admin", "editor", "analyst", "viewer"].includes(targetRole)
    : role === "admin" ? ["editor", "analyst", "viewer"].includes(targetRole)
    : targetRole === "viewer";
  const counts = useMemo(() => {
    const c = { all: users.length, admin: 0, editor: 0, viewer: 0 };
    users.forEach(u => { const r = u.role || "viewer"; c[r] = (c[r] || 0) + 1; });
    return c;
  }, [users]);
  const visibleUsers = roleFilter === "all" ? users : users.filter(u => (u.role || "viewer") === roleFilter);

  useEffect(() => {
    tapeFetch(apiBase() + "/api/users")
      .then(r => r.json())
      .then(j => { setUsers(j.users || []); setLoaded(true); })
      .catch(() => setLoaded(true));
  }, []);

  function onAdded(u) {
    setUsers(prev => {
      const exists = prev.some(x => x.discord_user_id === u.discord_user_id);
      return exists ? prev.map(x => x.discord_user_id === u.discord_user_id ? u : x) : [...prev, u];
    });
  }
  async function saveUser(id, patch) {
    const before = users.find(x => x.discord_user_id === id);
    setUsers(prev => prev.map(x => x.discord_user_id === id ? { ...x, ...patch } : x));
    setEditingId(null);
    try {
      const r = await tapeSend(apiBase() + "/api/users/" + id, {
        method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch),
      });
      // Apply the CANONICAL row the API returns, not the raw patch — the server
      // derives role/server_scope and rebuilds the permissions jsonb (incl.
      // server_roles). Merging the patch alone leaves a stale row, and a second
      // save from a reopened editor would then write those stale values back.
      const j = await r.json().catch(() => null);
      if (j && j.user) setUsers(prev => prev.map(x => x.discord_user_id === id ? j.user : x));
      toast.success("User updated");
    } catch (err) {
      if (before) setUsers(prev => prev.map(x => x.discord_user_id === id ? before : x));
      toast.error("Couldn't update user — " + err.message);
    }
  }
  async function remove(u) {
    if (!(await confirmDialog({ title: "Remove access", message: `Revoke portal access for ${u.name || u.discord_user_id}?`, confirmLabel: "Remove access", danger: true }))) return;
    setUsers(prev => prev.filter(x => x.discord_user_id !== u.discord_user_id));
    try {
      await tapeSend(apiBase() + "/api/users/" + u.discord_user_id, { method: "DELETE" });
      toast.success("Access removed");
    } catch (err) {
      setUsers(prev => prev.some(x => x.discord_user_id === u.discord_user_id) ? prev : [...prev, u]);
      toast.error("Couldn't remove access — " + err.message);
    }
  }

  return (
    <div>
      <h2>Access</h2>
      <p className="sub">Discord users allowed to sign in. Assign a role (Admin / Editor / Viewer) and, for Editors and Viewers, which servers they can access.{role === "owner" && <> Bootstrap admins set via the <code className="mono" style={{ color: "var(--fg-1)" }}>TAPE_ALLOWED_USER_IDS</code> env var are owners — always allowed, not listed here.</>}</p>

      <div className="access-toolbar">
        <div className="seg-filter">
          {["all", "server_owner", "admin", "editor", "analyst", "viewer"].map(r => (
            <button key={r} type="button" className={cx("seg-btn", roleFilter === r && "on")} onClick={() => setRoleFilter(r)}>
              {r === "all" ? "All" : (ROLE_LABELS[r] || r)}<span className="seg-count">{counts[r] || 0}</span>
            </button>
          ))}
        </div>
        <AddUserForm onAdded={onAdded} servers={servers} role={role} />
      </div>

      {loaded && visibleUsers.length === 0 && (
        <div className="empty" style={{ padding: 40 }}>{users.length ? "No users with this role." : "No additional users yet. Add a Discord user ID to grant access."}</div>
      )}

      {visibleUsers.length > 0 && (
        <div className="rows access-list">
          {visibleUsers.map(u => {
            const on = u.enabled !== false;
            const actionable = canActOn(u.role);
            const srMap = (u.permissions && u.permissions.server_roles) || {};
            const mixed = new Set(Object.values(srMap)).size > 1;   // different roles per server
            return (
            <Fragment key={u.discord_user_id}>
              <div className="channel-row" style={{ gridTemplateColumns: "24px minmax(0,1fr) auto auto" }}>
                <DiscordAvatar
                  url={avatars[u.discord_user_id]} size={22}
                  fallback={<span className="hash" style={{ color: "var(--fg-2)" }}>{I("user", { size: 15 })}</span>}
                />
                <div className="row-main" style={{ minWidth: 0 }}>
                  <div className="name">
                    {u.name || "—"}
                    <span style={{ marginLeft: 8, fontFamily: "var(--f-mono)", fontSize: "0.625rem", color: "var(--accent)", letterSpacing: ".08em", textTransform: "uppercase" }}>{ROLE_LABELS[u.role] || u.role || "viewer"}</span>
                    {mixed && <span className="perm-chip" title={Object.entries(srMap).map(([g, r]) => `${(servers.find(s => s.guild_id === g) || {}).name || g}: ${ROLE_LABELS[r] || r}`).join(" · ")}>per-server</span>}
                    {u.can_manage_positions && <span className="perm-chip" title="Can manage positions">manage</span>}
                    {u.role === "admin" && u.permissions && Object.values(u.permissions).filter(Boolean).length > 0 && <span className="perm-chip" title="Granted admin permissions">{Object.values(u.permissions).filter(Boolean).length} perms</span>}
                  </div>
                  <div className="id">
                    {u.discord_user_id}
                    {" · "}{(u.server_scope || []).includes("*") ? "all servers" : `${(u.server_scope || []).length} server${(u.server_scope || []).length === 1 ? "" : "s"}`}
                  </div>
                </div>
                {actionable ? (
                  <label className="switch" title={on ? "Active — click to suspend access" : "Suspended — click to enable access"}>
                    <input type="checkbox" checked={on} onChange={() => saveUser(u.discord_user_id, { enabled: !on })} />
                    <span className="switch-track"><span className="switch-thumb" /></span>
                  </label>
                ) : (
                  <span className="live-dot" style={{ color: on ? "var(--c-up)" : "var(--fg-3)" }}><span className="d" />{on ? "active" : "suspended"}</span>
                )}
                <div className="row-actions">
                  {actionable ? (
                    <>
                      <button className={cx("icon-btn", editingId === u.discord_user_id && "active")} title="Edit user" onClick={() => setEditingId(editingId === u.discord_user_id ? null : u.discord_user_id)}>{I("edit", { size: 15 })}</button>
                      <button className="icon-btn danger" title="Remove access" onClick={() => remove(u)}>{I("trash", { size: 15 })}</button>
                    </>
                  ) : (
                    <span style={{ fontFamily: "var(--f-mono)", fontSize: "0.625rem", color: "var(--fg-3)" }}>managed by admin</span>
                  )}
                </div>
              </div>
              {editingId === u.discord_user_id && actionable && (
                <UserEditor user={u} servers={servers} role={role} onCancel={() => setEditingId(null)} onSave={(patch) => saveUser(u.discord_user_id, patch)} />
              )}
            </Fragment>
          );})}
        </div>
      )}

      {(role === "owner" || role === "admin") && <RoleGrants servers={servers} actorRole={role} />}
    </div>
  );
}

function AuditScreen({ servers }) {
  const [entries, setEntries] = useState([]);
  const [loaded, setLoaded] = useState(false);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(false);
  const [q, setQ] = useState("");
  const [type, setType] = useState("all");
  const [guild, setGuild] = useState("all");

  async function load(reset) {
    setLoading(true);
    const off = reset ? 0 : entries.length;
    const params = new URLSearchParams({ limit: "50", offset: String(off) });
    if (q.trim()) params.set("q", q.trim());
    if (type !== "all") params.set("target_type", type);
    if (guild !== "all") params.set("guild_id", guild);
    try {
      const r = await tapeFetch(apiBase() + "/api/audit?" + params.toString());
      const j = await r.json();
      const list = j.entries || [];
      setEntries(prev => reset ? list : [...prev, ...list]);
      setHasMore(!!j.hasMore);
    } catch (err) {
      console.error("[sinux-signals] audit fetch failed:", err);
    } finally {
      setLoading(false); setLoaded(true);
    }
  }

  // Reload (from the top) whenever a filter changes, debounced.
  useEffect(() => {
    const t = setTimeout(() => load(true), 250);
    return () => clearTimeout(t);
  }, [q, type, guild]);

  return (
    <div>
      <h2>Audit log</h2>
      <p className="sub">Configuration changes — who changed what, and when. Newest first.</p>

      <div className="filterbar" style={{ gridTemplateColumns: "1fr auto auto", marginBottom: 14 }}>
        <input className="input search" placeholder="Search actor, action or target…" value={q} onChange={e => setQ(e.target.value)} />
        <select className="select" value={type} onChange={e => setType(e.target.value)} style={{ fontFamily: "var(--f-mono)", fontSize: "0.75rem" }}>
          <option value="all">all types</option>
          <option value="server">servers</option>
          <option value="channel">channels</option>
          <option value="analyst">analysts</option>
          <option value="event">events</option>
          <option value="user">users</option>
          <option value="role-grant">role grants</option>
        </select>
        <select className="select" value={guild} onChange={e => setGuild(e.target.value)} style={{ fontFamily: "var(--f-mono)", fontSize: "0.75rem" }}>
          <option value="all">all servers</option>
          {(servers || []).map(s => <option key={s.guild_id} value={s.guild_id}>{s.name}</option>)}
        </select>
      </div>

      {loaded && entries.length === 0 && (
        <div className="empty" style={{ padding: 40 }}>{loading ? "Loading…" : "No matching activity."}</div>
      )}

      {entries.length > 0 && (
        <div className="rows">
          {entries.map(e => (
            <div key={e.id} className="audit-row">
              <span className="audit-time mono">{fmtTime(e.ts)}</span>
              <span className="audit-actor">{e.actor_name || e.actor_id || "—"}</span>
              <span className="pill" style={{ color: "var(--accent)", justifySelf: "start" }}>{e.action}</span>
              <span className="audit-target mono">{e.target_label || e.target_id || ""}</span>
            </div>
          ))}
        </div>
      )}

      {hasMore && (
        <div style={{ display: "flex", justifyContent: "center", padding: 14 }}>
          <button className="btn" onClick={() => load(false)} disabled={loading}>{loading ? "Loading…" : "Load more"}</button>
        </div>
      )}
    </div>
  );
}

function Settings({ channels, setChannels, analysts, setAnalysts, events, servers, setServers, currentServerId, onSelectServer, liveStatus, canAdmin, canEditChannels, canEditAnalysts, canEditConfig, canViewConfig, canAudit, canManageUsers, canManageServer, isOwner, role }) {
  const allSections = [
    { k: "servers", label: "Servers", icon: "server", show: canManageServer },   // server cap (owner provisions)
    { k: "channels", label: "Channels", icon: "hash", show: canViewConfig },      // editor view / channels-cap edit
    { k: "whitelist", label: "Analysts", icon: "shield", show: canViewConfig },   // editor view / analysts-cap edit
    { k: "access", label: "Access", icon: "user", show: canManageUsers },         // editor+ / users cap
    { k: "audit", label: "Audit log", icon: "clock", show: canAudit },            // editor+
    { k: "pipeline", label: "Integrations", icon: "database", show: isOwner },     // owner only
  ];
  const sections = allSections.filter(s => s.show);
  const [section, setSection] = useState(() => (sections[0] && sections[0].k) || "access");
  const currentServer = (servers || []).find(s => s.guild_id === currentServerId);
  return (
    <div className="panel settings-root">
      <div className="settings-tabs">
        {sections.map(s => (
          <button key={s.k} className={cx("settings-tab", section === s.k && "active")} onClick={() => setSection(s.k)}>
            {I(s.icon, { size: 15 })} {s.label}
          </button>
        ))}
      </div>
      <div className="settings-content">
        {section === "servers" && canManageServer && <ServersScreen servers={servers} setServers={setServers} onSelectServer={onSelectServer} canProvision={isOwner} />}
        {section === "channels" && canViewConfig && <ChannelsScreen channels={channels} setChannels={setChannels} analysts={analysts} currentServer={currentServer} servers={servers} canEdit={canEditChannels} />}
        {section === "whitelist" && canViewConfig && <WhitelistScreen analysts={analysts} events={events} currentServer={currentServer} servers={servers} setAnalysts={setAnalysts} canEdit={canEditAnalysts} />}
        {section === "access" && canManageUsers && <AccessScreen servers={servers} isOwner={isOwner} role={role} />}
        {section === "audit" && canAudit && <AuditScreen servers={servers} />}
        {section === "pipeline" && isOwner && <PipelineScreen events={events} status={liveStatus} />}
      </div>
    </div>
  );
}

Object.assign(window, { Settings });
