// ============ CITRUS DASHBOARD · ROOT ============
// Owns shared state (active tab, focused agent, modals) and dispatches
// to one section component at a time. Mounts ReactDOM.
//
// Every other component (Sidebar, Topbar, the 13 section components,
// ChatDrawer, ArchitectModal) is loaded as its own <script> and
// attached to window. See dashboard/index.html for load order.

// ---- ACTIVE ENVIRONMENT OVERRIDE ----
// If the user has switched to a non-default environment, override CITRUS_CONFIG
// with that environment's URL and secret before patchFetchAuth runs.
// citrus_active_env_data holds just the active env record so this IIFE can
// apply it synchronously at page load (env list now lives server-side).
(function applyActiveEnv() {
  try {
    // Capture the primary server URL BEFORE it may be overridden below.
    // Environments, auth, and team data always live on the primary server.
    window.__citrusPrimaryUrl = (window.CITRUS_CONFIG && window.CITRUS_CONFIG.SERVER_URL) || window.location.origin;

    // window.name CITRUS_ENV payload always takes priority — it was set
    // explicitly by the master admin customer-env selector and must override
    // any stale env the user had in localStorage from a previous session.
    let env = null;
    if (typeof window.name === "string" && window.name.startsWith("CITRUS_ENV:")) {
      try {
        const payload = JSON.parse(window.name.slice("CITRUS_ENV:".length) || "{}");
        if (payload && payload.env) {
          env = payload.env;
          window.__citrusOpenedWithEnv = true;
        }
        // Restore master token so /auth/me succeeds without a login screen.
        // Master admin stores its token in sessionStorage which is not shared
        // across new tabs, so it must be passed explicitly via window.name.
        if (payload && payload.masterToken) {
          try { localStorage.setItem("citrus_auth_token", payload.masterToken); } catch {}
        }
      } catch {}
      try { window.name = ""; } catch {}
    }
    if (!env) {
      env = JSON.parse(localStorage.getItem("citrus_active_env_data") || "null");
    }
    // Write to localStorage AFTER reading (so window.name wins over stale localStorage).
    if (window.__citrusOpenedWithEnv && env && env.id) {
      try { localStorage.setItem("citrus_active_env", env.id); } catch {}
      try { localStorage.setItem("citrus_active_env_data", JSON.stringify(env)); } catch {}
    }
    if (!env) return;
    const hasTenantRoute = !!window.__citrusTenantSlug;
    const isBaseDashboard = /^\/dashboard\/?$/.test(window.location.pathname);
    // Production envs are the default — never override SERVER_URL or credentials
    // from a production env record. The settings auto-select used to write the
    // production env into localStorage; this guard prevents that stale data from
    // overriding the correct SERVER_URL (e.g. localhost vs. deployed domain).
    // Also clean up the stale localStorage entries so patchFetchAuth doesn't
    // see them on the next fetch either.
    if (env.type === "production") {
      try { localStorage.removeItem("citrus_active_env"); } catch {}
      try { localStorage.removeItem("citrus_active_env_data"); } catch {}
      return;
    }
    // A saved sandbox should not silently override the default production
    // dashboard when the user is on the base /dashboard route.
    if (isBaseDashboard && !hasTenantRoute) {
      try { localStorage.removeItem("citrus_active_env"); } catch {}
      try { localStorage.removeItem("citrus_active_env_data"); } catch {}
      return;
    }
    window.CITRUS_CONFIG = window.CITRUS_CONFIG || {};
    // Only override SERVER_URL when the env lives on a genuinely different
    // origin. Same-origin envs (e.g. /dashboard/gabtic.../sb on the same
    // server) use X-Env-Id for differentiation — setting SERVER_URL to a
    // path-based URL would break all API calls with "Could not reach server".
    if (env.url) {
      try {
        const envOrigin = new URL(env.url).origin;
        if (envOrigin !== window.location.origin) {
          window.CITRUS_CONFIG.SERVER_URL = envOrigin;
        }
      } catch { /* malformed url, skip override */ }
    }
    if (env.secret) window.CITRUS_CONFIG.DASHBOARD_SECRET = env.secret;
    // Expose active env name for the topbar badge
    window.__citrusActiveEnv = env;
  } catch {}
})();

// (DEV_AUTH_USER dev convenience removed)

// ---- TENANT IMPERSONATION (/t/<slug>/ URL) ----
// The URL is the source of truth. ?tenant=<slug> in the URL → impersonate
// that tenant. No URL param → no impersonation, period. We do NOT persist
// the slug in sessionStorage; doing so would silently carry impersonation
// across tabs/sessions and bleed one tenant's data into another's view.
// Patched fetch (below) adds X-Tenant-Slug on every API call; the server's
// applyTenantSlugOverride middleware honours it for MasterAdmin and for
// the tenant's own Owner.
// ---- FRESH-VISITOR FLAG ----
// `?fresh=1` means "treat this tab as if a brand-new visitor arrived" —
// wipe any session/identity so the dashboard's auth gate shows the
// LoginScreen instead of silently honouring whatever token is already in
// localStorage. The master-admin business-detail view appends this when
// the MasterAdmin clicks a tenant's dashboard URL, so they preview the
// owner's first-touch experience instead of accidentally impersonating
// the tenant via their MasterAdmin token + the slug header.
// MUST run BEFORE captureTenantSlug / patchFetchAuth, because those read
// values we're about to nullify.
(function applyFreshVisitor() {
  try {
    const params = new URLSearchParams(window.location.search);
    if (params.get("fresh") !== "1") return;
    const preserveEnv = !!window.__citrusOpenedWithEnv || (typeof window.name === "string" && window.name.startsWith("CITRUS_ENV:"));
    const keysToClear = [
      "citrus_auth_token",
      "citrus_identity",
      "citrus_profile",
      "citrus_voice",
    ];
    if (!preserveEnv) {
      keysToClear.push("citrus_active_env", "citrus_active_env_data");
    }
    keysToClear.forEach((k) => { try { localStorage.removeItem(k); } catch {} });
    // Strip the flag so a reload doesn't re-fire the wipe.
    params.delete("fresh");
    const qs = params.toString();
    window.history.replaceState(null, "", window.location.pathname + (qs ? "?" + qs : "") + window.location.hash);
  } catch { /* ignore */ }
})();

(function captureTenantSlug() {
  try {
    // Defensive: clear any stale sessionStorage slug from older builds
    // that persisted it. Without this, returning users would silently
    // keep impersonating until they manually cleared site data.
    try { sessionStorage.removeItem("citrus_tenant_slug"); } catch { /* ignore */ }
    // Canonical source: /dashboard/<slug> path segment. Fall back to the
    // legacy ?tenant=<slug> query string so old bookmarks keep working
    // (the server now redirects /dashboard/?tenant=... visits, but a
    // hand-typed query param still resolves here).
    let slug = "";
    const m = window.location.pathname.match(/^\/dashboard\/([a-z0-9-]+)\/?$/);
    if (m) slug = m[1].toLowerCase();
    if (!slug) {
      const params = new URLSearchParams(window.location.search);
      slug = (params.get("tenant") || "").trim().toLowerCase();
    }
    window.__citrusTenantSlug = slug || null;
  } catch { window.__citrusTenantSlug = null; }
})();

// ---- DASHBOARD AUTH ----
// Patch global fetch to inject the Authorization header on every same-server request.
// Priority: user session token (citrus_auth_token) > DASHBOARD_SECRET (legacy/master key).
//
// CRITICAL: SERVER_URL is re-read on every call. The active-env switcher
// rewrites window.CITRUS_CONFIG.SERVER_URL at runtime, so if we captured
// envBase once at IIFE time, fetches to the new env would fail the
// same-server check and the Bearer token wouldn't be attached → 401.
(function patchFetchAuth() {
  const origin = window.location.origin;
  const _orig  = window.fetch.bind(window);
  window.fetch = function (url, opts = {}) {
    const cfg     = window.CITRUS_CONFIG || {};
    const envBase = (cfg.SERVER_URL || "").replace(/\/$/, "");
    const urlStr  = typeof url === "string" ? url : (url instanceof URL ? url.href : String(url));
    let reqPath = "";
    try { reqPath = new URL(urlStr, origin).pathname || ""; } catch { reqPath = ""; }
    const isAuthEndpoint = reqPath.startsWith("/auth/");
    // Inject auth for: active env server, primary server origin, or any relative path.
    const isSameServer = urlStr.startsWith("/") ||
      urlStr.startsWith(origin) ||
      (envBase && urlStr.startsWith(envBase));
    if (isSameServer) {
      // Don't override an explicitly-set Authorization header
      if (!(opts.headers && (opts.headers["Authorization"] || opts.headers["authorization"]))) {
        const token = localStorage.getItem("citrus_auth_token") || cfg.DASHBOARD_SECRET;
        if (token) opts = { ...opts, headers: { Authorization: `Bearer ${token}`, ...(opts.headers || {}) } };
      }
      // Inject the tenant slug header so the server scopes data to the
      // impersonated tenant when /t/<slug>/ landed the user here.
      const slug = window.__citrusTenantSlug;
      if (slug && !(opts.headers && opts.headers["X-Tenant-Slug"])) {
        opts = { ...opts, headers: { ...(opts.headers || {}), "X-Tenant-Slug": slug } };
      }
      const activeEnvId = localStorage.getItem("citrus_active_env");
      // Only inject X-Env-Id for non-production envs. A stale production env
      // ID (written by old settings auto-select code) must not be forwarded —
      // the server already defaults to the production env when the header is
      // absent, and sending an explicit production ID is at best redundant.
      const activeEnvData = (() => { try { return JSON.parse(localStorage.getItem("citrus_active_env_data") || "null"); } catch { return null; } })();
      const isProductionEnv = activeEnvData && activeEnvData.type === "production";
      if (!isAuthEndpoint && activeEnvId && !isProductionEnv && !(opts.headers && (opts.headers["X-Env-Id"] || opts.headers["x-env-id"]))) {
        opts = { ...opts, headers: { ...(opts.headers || {}), "X-Env-Id": activeEnvId } };
      }
    }
    return _orig(url, opts);
  };
})();

const CharacterAvatar = window.CharacterAvatar;
const ArchitectModal  = window.ArchitectModal;
const Sidebar         = window.Sidebar;
const Topbar          = window.Topbar;
const ChatDrawer      = window.ChatDrawer;

// Section dispatch table: tab id → component (read off window).
const SECTIONS = {
  home:      window.CitrusHomeTab,
  agents:    window.AgentsTab,
  inbox:     window.InboxTab,
  leads:     window.LeadsTab,
  activity:  window.ActivityTab,
  approvals: window.ApprovalsTab,
  insights:  window.InsightsTab,
  templates: window.TemplatesTab,
  personas:  window.PersonasTab,
  knowledge: window.KnowledgeTab,
  effectiveness: window.EffectivenessTab,
  tools:     window.ToolsTab,
  team:      window.TeamTab,
  billing:   window.BillingTab,
  settings:  window.SettingsTab,
};

// Placeholder rendered when the user tries to access a section that's been
// disabled for their tenant via master-admin → Section access. URL hand-typing
// or stale bookmarks would otherwise crash render (Section component missing)
// or silently show an empty pane. This keeps the experience explicit and
// gives the user a way out.
function DisabledTabPlaceholder({ onSwitchTab }) {
  return (
    <div style={{ padding: 48, maxWidth: 520, margin: "40px auto", textAlign: "center" }}>
      <div style={{ fontSize: 36, marginBottom: 12, opacity: 0.5 }}>◍</div>
      <h2 style={{ fontSize: 18, fontWeight: 600, margin: "0 0 8px", color: "var(--ink)" }}>
        Section not available
      </h2>
      <p style={{ fontSize: 14, color: "var(--ink-2)", lineHeight: 1.5, margin: "0 0 24px" }}>
        This section is not enabled for your account. Contact your administrator.
      </p>
      <div style={{ display: "flex", gap: 8, justifyContent: "center" }}>
        <button
          type="button"
          className="btn btn-primary"
          style={{ padding: "10px 18px", borderRadius: 10, fontSize: 13, fontWeight: 600 }}
          onClick={() => onSwitchTab("inbox")}
        >
          Go to Inbox
        </button>
        <button
          type="button"
          className="btn btn-ghost"
          style={{ padding: "10px 18px", borderRadius: 10, fontSize: 13, fontWeight: 600 }}
          onClick={() => onSwitchTab("settings")}
        >
          Go to Settings
        </button>
      </div>
    </div>
  );
}

function nowStamp() {
  const d = new Date();
  const h = d.getHours() % 12 || 12;
  const m = String(d.getMinutes()).padStart(2, "0");
  const ampm = d.getHours() < 12 ? "AM" : "PM";
  return `${h}:${m} ${ampm}`;
}
function shortTime(iso) {
  const d = iso ? new Date(iso) : new Date();
  const h = d.getHours() % 12 || 12;
  const m = String(d.getMinutes()).padStart(2, "0");
  return `${h}:${m}`;
}
function relativeTime(iso) {
  if (!iso) return "now";
  const diffMs = Date.now() - new Date(iso).getTime();
  const min = Math.floor(diffMs / 60000);
  if (min < 1) return "now";
  if (min < 60) return `${min}m`;
  const hr = Math.floor(min / 60);
  if (hr < 24) return `${hr}h`;
  return `${Math.floor(hr / 24)}d`;
}

function CitrusFloatingLauncher({ onOpen, storageKey }) {
  const [pos, setPos] = useState(null);
  const dragRef = useRef({ dragging: false, moved: false, dx: 0, dy: 0, startX: 0, startY: 0 });
  const suppressClickRef = useRef(false);

  const computeDefaultPos = () => {
    const rightPad = 16;
    // account for iOS home-bar safe area if available
    const safeBottom = (() => {
      try {
        const val = getComputedStyle(document.documentElement).getPropertyValue("--sab");
        return parseInt(val, 10) || 0;
      } catch { return 0; }
    })();
    const bottomPad = 20 + safeBottom;
    return {
      x: Math.max(8, window.innerWidth - (52 + rightPad)),
      y: Math.max(8, window.innerHeight - (52 + bottomPad)),
    };
  };

  const clampPos = (raw) => ({
    x: Math.min(Math.max(8, raw.x), window.innerWidth - 56),
    y: Math.min(Math.max(8, raw.y), window.innerHeight - 56),
  });

  useEffect(() => {
    const defaultPos = computeDefaultPos();
    try {
      const raw = storageKey ? localStorage.getItem(storageKey) : null;
      if (!raw) { setPos(defaultPos); return; }
      const saved = JSON.parse(raw);
      if (!saved || !Number.isFinite(saved.x) || !Number.isFinite(saved.y)) {
        setPos(defaultPos);
        return;
      }
      setPos(clampPos({ x: saved.x, y: saved.y }));
    } catch {
      setPos(defaultPos);
    }
  }, [storageKey]);

  useEffect(() => {
    const onResize = () => {
      setPos((prev) => {
        if (!prev) return computeDefaultPos();
        return clampPos(prev);
      });
    };
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);

  useEffect(() => {
    if (!storageKey || !pos) return;
    try { localStorage.setItem(storageKey, JSON.stringify(pos)); } catch {}
  }, [storageKey, pos]);

  const beginDrag = (clientX, clientY) => {
    if (!pos) return;
    dragRef.current.dragging = true;
    dragRef.current.moved = false;
    dragRef.current.startX = clientX;
    dragRef.current.startY = clientY;
    dragRef.current.dx = clientX - pos.x;
    dragRef.current.dy = clientY - pos.y;
  };

  useEffect(() => {
    const onMove = (e) => {
      if (!dragRef.current.dragging) return;
      const clientX = Number.isFinite(e.clientX) ? e.clientX : (e.touches && e.touches[0] && e.touches[0].clientX);
      const clientY = Number.isFinite(e.clientY) ? e.clientY : (e.touches && e.touches[0] && e.touches[0].clientY);
      if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
      const movedPx = Math.hypot(clientX - dragRef.current.startX, clientY - dragRef.current.startY);
      if (movedPx >= 6) dragRef.current.moved = true;
      setPos(clampPos({
        x: clientX - dragRef.current.dx,
        y: clientY - dragRef.current.dy,
      }));
    };
    const onUp = () => {
      if (!dragRef.current.dragging) return;
      dragRef.current.dragging = false;
      if (dragRef.current.moved) {
        suppressClickRef.current = true;
        setTimeout(() => { suppressClickRef.current = false; }, 120);
      }
    };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    window.addEventListener("touchmove", onMove, { passive: true });
    window.addEventListener("touchend", onUp);
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      window.removeEventListener("touchmove", onMove);
      window.removeEventListener("touchend", onUp);
    };
  }, []);

  if (!pos) return null;

  return (
    <button
      type="button"
      title="Open Citrus"
      onMouseDown={(e) => beginDrag(e.clientX, e.clientY)}
      onTouchStart={(e) => {
        const t = e.touches && e.touches[0];
        if (t) beginDrag(t.clientX, t.clientY);
      }}
      onClick={() => {
        if (suppressClickRef.current) return;
        onOpen && onOpen();
      }}
      style={{
        position: "fixed",
        left: pos.x,
        top: pos.y,
        zIndex: 80,
        width: 52,
        height: 52,
        borderRadius: 999,
        border: "1px solid #B84410",
        background: "radial-gradient(circle at 30% 25%, #FFB07A, #E85D1A 60%, #B84410)",
        boxShadow: "0 10px 28px rgba(0,0,0,0.18)",
        cursor: dragRef.current.dragging ? "grabbing" : "grab",
        userSelect: "none",
      }}
    >
      <span style={{ display: "grid", placeItems: "center", lineHeight: 1 }}>
        <span style={{ fontSize: 18, color: "#fff" }}>◍</span>
        <span style={{ fontSize: 9, fontWeight: 700, letterSpacing: 0.06, textTransform: "uppercase", marginTop: 2, color: "#fff" }}>Citrus</span>
      </span>
    </button>
  );
}

// Display label for a server-side `from` identifier.
//   "whatsapp:+14155..."  → "+14155..."
function customerLabel(from) {
  if (!from) return "Unknown";
  if (from.startsWith("persona:")) return `🎭 Test persona`;
  return from.replace(/^whatsapp:/i, "");
}

// Build a dashboard-shaped agent from a server agent record. Used when the
// server has agents the dashboard hasn't seen yet (e.g., the seed ORI).
function dashboardAgentName(s, fallback = "AGENT") {
  if (s && s.type === "internal") return "CITRUS";
  return (s?.name || fallback || "AGENT").toUpperCase();
}

function stubAgentFromServer(s) {
  const palettes = Object.values(window.PALETTES || {});
  const idx = Math.abs((s.id || "").split("").reduce((a, c) => a + c.charCodeAt(0), 0)) % Math.max(1, palettes.length);
  const palette = palettes[idx] || { skin: "#E85D1A", skinDark: "#B84410", accent: "#FFCA7D", visor: "#1A120B" };
  return {
    id: s.id,
    type: s.type || null,
    name: dashboardAgentName(s),
    role: s.role || "Assistant",
    status: "live",
    rating: 0,
    palette,
    glyph: "🤖",
    brief: s.brief || "",
    tools: s.tools || [],
    metrics: { "Tasks done": "0", "Avg reply": "—", "Hours saved": "0" },
    spark: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    sparkLabel: "Tasks / day",
    accuracy: 0,
    runs: 0,
    personality: s.personality || "warm",
    approvalGate: s.approvalGate || "10000",
    channels: s.channels || {},
    learnings: s.learnings || [],
    learnedAt: s.learnedAt || null,
    status: s.status || "live",
    rating: s.rating || 0,
  };
}

// Convert a server conversation (with full messages array) into a dashboard
// thread. The `from` field doubles as a stable thread id. Each transcript
// entry keeps the ISO timestamp (`iso`) alongside the display string (`t`)
// so per-day metrics can bucket precisely.
function threadFromConversation(c) {
  const msgs = c.messages || [];
  const last = msgs[msgs.length - 1];
  const transcript = msgs.map((m) => ({
    id: m.id || null,
    from: m.role === "customer" ? "customer" : "agent",
    text: m.content,
    t: shortTime(m.t),
    iso: m.t,
    reasoning: m.reasoning || null,
    provenance: m.provenance || null,
  }));
  // Server-tracked unread wins when present (`c.unread === true/false`);
  // fall back to "last message was from the customer" heuristic only when
  // the server omits the field, which keeps legacy rows working until
  // they get a real flag stamped on the next inbound. lastMessageAt is
  // preserved so the inbox can sort by recency client-side too — the
  // server already returns the list sorted, but SSE-driven updates
  // need a stable timestamp to re-sort against.
  const serverUnread = (typeof c.unread === "boolean") ? c.unread : (!!last && last.role === "customer");
  return {
    id: c.from,
    agent: c.agentId,
    customer: customerLabel(c.contact || c.from),
    // Captured customer name (same source the Leads list uses) so the inbox
    // can show "Adham" instead of the raw phone/Telegram id. Null until an
    // agent extracts a name; the inbox falls back to customer-memory then id.
    customerName: (c.lead && c.lead.name) || null,
    // Agent-assigned classification tags (multi-label) — the inbox renders
    // them as small read-only chips on each thread row.
    categories: (c.lead && Array.isArray(c.lead.categories)) ? c.lead.categories : [],
    channel: c.channel || "WhatsApp",
    last: last ? last.content : "",
    time: last ? relativeTime(last.t) : "now",
    unread: serverUnread,
    lastMessageAt: c.lastMessageAt || (last ? last.t : null),
    status: c.wrappedUp ? "handled" : (c.takenOver ? "needs-you" : "active"),
    tag: c.wrappedUp ? "Wrapped up" : (c.takenOver ? "You're replying" : "Live"),
    // Real conversation outcome from the server-side summary
    // ('booked'|'lead'|'closed'|'escalated'|'spam'|'open'). Drives the
    // per-agent conversion stat; null until the summarizer classifies it.
    outcome: c.summary?.status || null,
    transcript,
    routingHistory: Array.isArray(c.routingHistory) ? c.routingHistory : [],
  };
}
// ============ LOGIN SCREEN ============
function LoginScreen({ onLogin, serverUrl, initialPhase, initialError }) {
  const S   = serverUrl || (window.CITRUS_CONFIG && window.CITRUS_CONFIG.SERVER_URL) || "";
  // Always authenticate against the PRIMARY server. applyActiveEnv() may have
  // overwritten CITRUS_CONFIG.SERVER_URL with a sandbox URL, but
  // window.__citrusPrimaryUrl is captured before that override occurs.
  const authBase = window.__citrusPrimaryUrl || S;
  const authBases = (() => {
    const uniq = [];
    const add = (v) => {
      const s = String(v || "").trim().replace(/\/$/, "");
      if (!s) return;
      if (!uniq.includes(s)) uniq.push(s);
    };
    add(authBase);
    add(window.location.origin || "");
    add(S);
    return uniq;
  })();
  const authBasePrimary = authBases[0] || "";
  const cfg = window.CITRUS_CONFIG || {};
  const hasGoogle = !!cfg.GOOGLE_CLIENT_ID;

  const fetchWithAuthBaseFallback = async (path, options) => {
    let lastErr = null;
    for (const base of authBases) {
      try {
        return await fetch(`${base}${path}`, options);
      } catch (err) {
        lastErr = err;
      }
    }
    throw lastErr || new Error("auth endpoint unreachable");
  };

  // Detect a password-reset token in the URL on mount — that's how the
  // email link lands the user here. If present, we open into the reset
  // phase directly so the owner can pick a new password.
  const _initialResetToken = (() => {
    try {
      const params = new URLSearchParams(window.location.search);
      return (params.get("reset") || "").trim();
    } catch { return ""; }
  })();

  // phase: "login" | "setup" | "forgot" | "forgot-sent" | "reset" | "reset-done" | "force-change"
  const [phase, setPhase]     = useState(_initialResetToken ? "reset" : (initialPhase || "login"));
  const [email, setEmail]     = useState("");
  const [password, setPass]   = useState("");
  const [name, setName]       = useState("");
  const [resetToken, setResetToken] = useState(_initialResetToken);
  const [forceToken, setForceToken] = useState("");
  const [temporaryPassword, setTemporaryPassword] = useState("");
  const [error, setError]     = useState(initialError || "");
  const [info,  setInfo]      = useState("");
  const [loading, setLoading] = useState(false);

  const submit = async (e) => {
    e.preventDefault();
    setError(""); setInfo(""); setLoading(true);
    try {
      const path = phase === "setup" ? "/auth/setup" : "/auth/login";
      const body = phase === "setup" ? { email, password, name } : { email, password };
      const r = await fetchWithAuthBaseFallback(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
      const j = await r.json();
      if (!r.ok) { setError(j.error || "Something went wrong"); return; }
      if (j.user?.forcePasswordChange) {
        setForceToken(j.token);
        setTemporaryPassword(password);
        setPass("");
        setPhase("force-change");
        setInfo("Please choose a new password before opening your dashboard.");
        return;
      }
      localStorage.setItem("citrus_auth_token", j.token);
      onLogin(j.user);
    } catch {
      setError("Could not reach server. Check your connection.");
    } finally { setLoading(false); }
  };

  const submitForgot = async (e) => {
    e.preventDefault();
    setError(""); setInfo(""); setLoading(true);
    try {
      const r = await fetchWithAuthBaseFallback("/auth/password-reset/start", {
        method: "POST", headers: { "content-type": "application/json" },
        body: JSON.stringify({ email }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) { setError(j.error || "Couldn't send reset email"); return; }
      setPhase("forgot-sent");
    } catch {
      setError("Could not reach server. Check your connection.");
    } finally { setLoading(false); }
  };

  const submitReset = async (e) => {
    e.preventDefault();
    setError(""); setInfo(""); setLoading(true);
    try {
      const r = await fetchWithAuthBaseFallback("/auth/password-reset/complete", {
        method: "POST", headers: { "content-type": "application/json" },
        body: JSON.stringify({ token: resetToken, newPassword: password }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) { setError(j.error || "Reset failed — the link may have expired."); return; }
      // Strip the ?reset= param from the URL so a reload doesn't re-enter
      // the reset phase with an already-used token.
      try {
        const u = new URL(window.location.href);
        u.searchParams.delete("reset");
        window.history.replaceState(null, "", u.pathname + (u.search ? "?" + u.searchParams.toString() : "") + u.hash);
      } catch { /* ignore */ }
      setPass(""); setPhase("reset-done");
    } catch {
      setError("Could not reach server. Check your connection.");
    } finally { setLoading(false); }
  };


  const submitForcedPasswordChange = async (e) => {
    e.preventDefault();
    setError(""); setInfo(""); setLoading(true);
    try {
      const r = await fetchWithAuthBaseFallback("/auth/set-password", {
        method: "POST",
        headers: { "content-type": "application/json", Authorization: `Bearer ${forceToken}` },
        body: JSON.stringify({ currentPassword: temporaryPassword, newPassword: password }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) { setError(j.error || "Could not update password"); return; }
      const loginR = await fetchWithAuthBaseFallback("/auth/login", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ email, password }),
      });
      const loginJ = await loginR.json().catch(() => ({}));
      if (!loginR.ok) {
        setPhase("login");
        setInfo("Password updated. Sign in with your new password.");
        return;
      }
      localStorage.setItem("citrus_auth_token", loginJ.token);
      setForceToken(""); setTemporaryPassword(""); setPass("");
      onLogin(loginJ.user);
    } catch {
      setError("Could not reach server. Check your connection.");
    } finally { setLoading(false); }
  };

  const signInWithGoogle = () => {
    window.location.href = `${authBasePrimary}/auth/google`;
  };

  const inputStyle = { padding: "10px 14px", borderRadius: 10, border: "1px solid var(--border)", fontSize: 14, background: "var(--paper)", color: "var(--ink)", outline: "none", width: "100%", boxSizing: "border-box" };

  const linkStyle = { background: "none", border: "none", color: "var(--accent)", fontSize: 12, fontWeight: 500, cursor: "pointer", padding: 0, textDecoration: "underline", textUnderlineOffset: 2 };

  const titleFor = (p) => {
    if (p === "setup")        return "Set up your account";
    if (p === "forgot" ||
        p === "forgot-sent")  return "Reset your password";
    if (p === "reset" ||
        p === "reset-done" ||
        p === "force-change") return "Choose a new password";
    return "Welcome back";
  };
  const subFor = (p) => {
    if (p === "setup")        return "Create the first owner account to get started.";
    if (p === "forgot")       return "Enter your email and we'll send a reset link.";
    if (p === "forgot-sent")  return "Check your inbox. The link expires in 30 minutes.";
    if (p === "reset")        return "Pick a new password to finish resetting your account.";
    if (p === "force-change") return "For security, replace your temporary invite password.";
    if (p === "reset-done")   return "Password updated. Sign in with your new password.";
    return "Sign in to your Citrus dashboard.";
  };

  return (
    <div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg)", padding: 24 }}>
      <div style={{ width: "100%", maxWidth: 380, background: "var(--paper)", border: "1px solid var(--border)", borderRadius: 20, padding: "40px 36px", boxShadow: "0 8px 40px rgba(0,0,0,0.08)" }}>
        <div style={{ fontSize: 28, marginBottom: 8, textAlign: "center" }}>◍</div>
        <h2 style={{ fontSize: 20, fontWeight: 700, textAlign: "center", margin: "0 0 4px" }}>{titleFor(phase)}</h2>
        <p style={{ fontSize: 13, color: "var(--ink-2)", textAlign: "center", marginBottom: 28 }}>{subFor(phase)}</p>

        {error && <div style={{ fontSize: 13, color: "#C04545", background: "color-mix(in oklab, #C04545 8%, transparent)", border: "1px solid color-mix(in oklab, #C04545 20%, transparent)", borderRadius: 8, padding: "8px 12px", marginBottom: 16 }}>{error}</div>}
        {info  && <div style={{ fontSize: 13, color: "#3F8C3A", background: "color-mix(in oklab, #3F8C3A 8%, transparent)", border: "1px solid color-mix(in oklab, #3F8C3A 20%, transparent)", borderRadius: 8, padding: "8px 12px", marginBottom: 16 }}>{info}</div>}

        {/* ---- LOGIN / SETUP ---- */}
        {(phase === "login" || phase === "setup") && (
          <>
            {hasGoogle && (
              <>
                <button
                  type="button"
                  onClick={signInWithGoogle}
                  style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, width: "100%", padding: "10px 14px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--paper)", color: "var(--ink)", fontSize: 14, fontWeight: 500, cursor: "pointer" }}
                >
                  <svg width="18" height="18" viewBox="0 0 18 18">
                    <path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.874 2.684-6.615z"/>
                    <path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
                    <path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/>
                    <path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
                  </svg>
                  {phase === "setup" ? "Sign up with Google" : "Sign in with Google"}
                </button>
                <div style={{ display: "flex", alignItems: "center", gap: 12, margin: "20px 0" }}>
                  <div style={{ flex: 1, height: 1, background: "var(--border)" }} />
                  <span style={{ fontSize: 12, color: "var(--ink-3)" }}>or</span>
                  <div style={{ flex: 1, height: 1, background: "var(--border)" }} />
                </div>
              </>
            )}

            <form onSubmit={submit} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
              {phase === "setup" && (
                <input className="st-input" type="text" placeholder="Your name" value={name} autoFocus onChange={(e) => setName(e.target.value)} style={inputStyle} />
              )}
              <input className="st-input" type="email" placeholder="Email address" value={email} required autoFocus={phase !== "setup" && !hasGoogle} onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
              <input className="st-input" type="password" placeholder="Password" value={password} required onChange={(e) => setPass(e.target.value)} style={inputStyle} />
              <button type="submit" className="btn btn-primary" disabled={loading} style={{ marginTop: 4, padding: "11px", fontSize: 14, fontWeight: 600, borderRadius: 10 }}>
                {loading ? "…" : phase === "setup" ? "Create account" : "Sign in"}
              </button>
            </form>

            {phase === "login" && (
              <div style={{ marginTop: 16, textAlign: "center" }}>
                <button type="button" style={linkStyle}
                  onClick={() => { setPhase("forgot"); setError(""); setPass(""); }}>
                  Forgot your password?
                </button>
              </div>
            )}
          </>
        )}

        {/* ---- FORGOT: enter email ---- */}
        {phase === "forgot" && (
          <>
            <form onSubmit={submitForgot} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
              <input className="st-input" type="email" placeholder="Your email" value={email} required autoFocus
                onChange={(e) => setEmail(e.target.value)} style={inputStyle} />
              <button type="submit" className="btn btn-primary" disabled={loading || !email.trim()}
                style={{ marginTop: 4, padding: "11px", fontSize: 14, fontWeight: 600, borderRadius: 10 }}>
                {loading ? "Sending…" : "Send reset link"}
              </button>
            </form>
            <div style={{ marginTop: 16, textAlign: "center" }}>
              <button type="button" style={linkStyle} onClick={() => { setPhase("login"); setError(""); }}>
                Back to sign in
              </button>
            </div>
          </>
        )}

        {/* ---- FORGOT-SENT: confirmation ---- */}
        {phase === "forgot-sent" && (
          <>
            <p style={{ fontSize: 13, color: "var(--ink-2)", textAlign: "center", lineHeight: 1.5, marginBottom: 20 }}>
              We sent a reset link to <b>{email}</b> if an account exists for that address. Check your inbox (and spam folder). The link expires in 30 minutes.
            </p>
            <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
              <button type="button" className="btn btn-ghost"
                style={{ padding: "10px", fontSize: 13, borderRadius: 10 }}
                onClick={() => setPhase("forgot")}>
                Send to a different email
              </button>
              <button type="button" className="btn btn-primary"
                style={{ padding: "11px", fontSize: 14, fontWeight: 600, borderRadius: 10 }}
                onClick={() => { setPhase("login"); setError(""); setInfo(""); }}>
                Back to sign in
              </button>
            </div>
          </>
        )}

        {/* ---- RESET: token from email + new password ---- */}
        {phase === "reset" && (
          <>
            <form onSubmit={submitReset} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
              <input className="st-input" type="password" placeholder="New password" value={password} required autoFocus
                onChange={(e) => setPass(e.target.value)} style={inputStyle} minLength={8} />
              <button type="submit" className="btn btn-primary" disabled={loading || password.length < 8}
                style={{ marginTop: 4, padding: "11px", fontSize: 14, fontWeight: 600, borderRadius: 10 }}>
                {loading ? "Saving…" : "Save new password"}
              </button>
            </form>
            <p style={{ fontSize: 11, color: "var(--ink-3)", textAlign: "center", marginTop: 12, lineHeight: 1.4 }}>
              Minimum 8 characters. After resetting, all your existing sessions are invalidated — you'll need to sign in again on every device.
            </p>
          </>
        )}


        {/* ---- FORCE CHANGE: invited user temporary password ---- */}
        {phase === "force-change" && (
          <>
            <form onSubmit={submitForcedPasswordChange} style={{ display: "flex", flexDirection: "column", gap: 12 }}>
              <input className="st-input" type="password" placeholder="New password" value={password} required autoFocus
                onChange={(e) => setPass(e.target.value)} style={inputStyle} minLength={8} />
              <button type="submit" className="btn btn-primary" disabled={loading || password.length < 8}
                style={{ marginTop: 4, padding: "11px", fontSize: 14, fontWeight: 600, borderRadius: 10 }}>
                {loading ? "Saving…" : "Save new password"}
              </button>
            </form>
            <p style={{ fontSize: 11, color: "var(--ink-3)", textAlign: "center", marginTop: 12, lineHeight: 1.4 }}>
              Minimum 8 characters and at least one number or symbol.
            </p>
          </>
        )}

        {/* ---- RESET-DONE: bounce to login ---- */}
        {phase === "reset-done" && (
          <>
            <div style={{ textAlign: "center", marginBottom: 20 }}>
              <div style={{ width: 48, height: 48, borderRadius: "50%", background: "color-mix(in oklab, #3F8C3A 18%, transparent)", color: "#3F8C3A", display: "inline-grid", placeItems: "center", fontSize: 22, fontWeight: 700 }}>✓</div>
            </div>
            <button type="button" className="btn btn-primary" style={{ width: "100%", padding: "11px", fontSize: 14, fontWeight: 600, borderRadius: 10 }}
              onClick={() => { setPhase("login"); setError(""); setInfo(""); setPass(""); }}>
              Sign in with new password
            </button>
          </>
        )}
      </div>
    </div>
  );
}

// ============ AUTH GATE ============
function AuthGate({ children }) {
  const cfg = window.CITRUS_CONFIG || {};
  const S   = cfg.SERVER_URL || "";
  const authBase = window.__citrusPrimaryUrl || S;
  // "login" | "setup" | "ready" | "checking"
  const [authState, setAuthState] = useState("checking");
  const [authUser,  setAuthUser]  = useState(null);
  // null = not loaded yet; undefined-from-server (or missing) = "all visible"
  // (backwards-compat for existing tenants). Array = exact allowlist.
  // Cache the last-known allowlist in localStorage and seed state from it, so
  // the sidebar paints the correct (possibly restricted) menu on the FIRST
  // render. Without this, a deploy/re-login transiently returns "all visible"
  // before the real allowlist lands — so a master-admin-disabled item flashes
  // in, then vanishes. Every set writes through to the cache.
  const ENABLED_SECTIONS_KEY = "citrus_enabled_sections";
  const readCachedSections = () => {
    try {
      const raw = localStorage.getItem(ENABLED_SECTIONS_KEY);
      if (raw === null) return null;          // never cached → unknown
      if (raw === "all") return undefined;    // cached "no restriction"
      const arr = JSON.parse(raw);
      return Array.isArray(arr) ? arr : null;
    } catch { return null; }
  };
  const writeCachedSections = (val) => {
    try { localStorage.setItem(ENABLED_SECTIONS_KEY, Array.isArray(val) ? JSON.stringify(val) : "all"); }
    catch { /* ignore */ }
  };
  const [enabledSections, setEnabledSectionsRaw] = useState(readCachedSections);
  const setEnabledSections = (val) => { setEnabledSectionsRaw(val); writeCachedSections(val); };
  const [allowAgentCreation, setAllowAgentCreation] = useState(true);
  const [oauthError, setOauthError] = useState("");

  useEffect(() => {
    // Handle Google OAuth redirect: ?citrus_token=xxx or ?auth_error=xxx
    const params = new URLSearchParams(window.location.search);
    const oauthToken = params.get("citrus_token");
    const authErr    = params.get("auth_error");
    if (oauthToken || authErr) {
      // Clean up URL without a page reload
      const clean = window.location.pathname + window.location.hash;
      window.history.replaceState(null, "", clean);
      if (authErr) { setOauthError(decodeURIComponent(authErr)); }
      if (oauthToken) {
        localStorage.setItem("citrus_auth_token", oauthToken);
        // Fall through — the token verification below will pick it up
      }
    }

    // If AUTH_REQUIRED is false (no users set up yet) AND we have DASHBOARD_SECRET, skip login
    if (cfg.AUTH_REQUIRED === false && cfg.DASHBOARD_SECRET) {
      setAuthState("ready"); return;
    }
    // If no auth required at all (dev mode)
    if (!cfg.AUTH_REQUIRED && !cfg.DASHBOARD_SECRET) {
      setAuthState("ready"); return;
    }
    const token = localStorage.getItem("citrus_auth_token");
    if (!token) {
      // Check if this is first boot (no users) → show setup screen
      fetch(`${authBase}/auth/me`, { headers: { Authorization: `Bearer __probe__` } })
        .then((r) => r.status === 403 ? "setup" : "login")
        .catch(() => "login")
        .then((next) => setAuthState(next));
      return;
    }
    // Verify existing token
    fetch(`${authBase}/auth/me`, { headers: { Authorization: `Bearer ${token}` } })
      .then((r) => r.ok ? r.json() : null)
      .then((data) => {
        if (data?.user) {
          if (data.user.forcePasswordChange) {
            localStorage.removeItem("citrus_auth_token");
            setOauthError("Please sign in with your temporary password and choose a new password.");
            setAuthState("login");
            return;
          }
          // Capture per-tenant section access. `enabledSections` is optional
          // on the response — undefined/missing means "all sections visible"
          // (backwards-compat). An array means the explicit allowlist.
          if (Array.isArray(data.user.enabledSections)) {
            setEnabledSections(data.user.enabledSections);
          } else {
            // Response carried no allowlist — ambiguous between a genuinely
            // unrestricted tenant and a transient post-deploy gap before the
            // tenant config reloads. Keep the last-known cached allowlist
            // rather than flashing "all visible"; default to all only if we've
            // truly never cached one.
            const cached = readCachedSections();
            setEnabledSections(cached === null ? undefined : cached);
          }
          // Defensive cache clear: if the identity behind this token differs
          // from the one whose profile is sitting in localStorage, wipe the
          // tenant cache. Without this, users who re-login through a path
          // that bypasses handleLogin (e.g. silent token refresh, manual
          // localStorage swap, browser back/forward) see the previous
          // tenant's name/logo/voice until they touch Settings.
          try {
            const stored = localStorage.getItem("citrus_identity") || "";
            const ident  = `${data.user.id || data.user.email}|${data.user.businessId || "self"}`;
            if (stored && stored !== ident) {
              localStorage.removeItem("citrus_profile");
              localStorage.removeItem("citrus_voice");
              localStorage.removeItem("citrus_active_env");
              localStorage.removeItem("citrus_active_env_data");
              localStorage.removeItem(ENABLED_SECTIONS_KEY); // don't inherit prior tenant's allowlist
              window.__citrusTenantSlug = null;
            }
            localStorage.setItem("citrus_identity", ident);
          } catch { /* ignore */ }
          setAllowAgentCreation(data.user.allowAgentCreation !== false);
          setAuthUser(data.user); setAuthState("ready");
          // Keep the URL pinned to this user's canonical tenant. Non-master
          // Owners cannot impersonate, so if the URL says /t/<some-other>/
          // they're shown stale data with a misleading URL — redirect them
          // home. MasterAdmin keeps whatever URL they're at (impersonation
          // is intentional for them).
          try {
            const slug = data.user.businessSlug;
            const isMaster = data.user.role === "MasterAdmin";
            const urlSlug = (new URLSearchParams(window.location.search)).get("tenant") || null;
            const path    = window.location.pathname;
            const pathSlug = (path.match(/^\/dashboard\/([a-z0-9-]+)\/?$/) || [])[1] || null;
            const requestedSlug = (pathSlug || urlSlug || "").toLowerCase() || null;
            const grantedTenantSlugs = Array.isArray(data.user.grantedTenantSlugs)
              ? data.user.grantedTenantSlugs.map((s) => String(s || "").toLowerCase()).filter(Boolean)
              : [];

            if (!isMaster) {
              // Non-master users may access another tenant only when an
              // explicit support grant exists. If the current URL carries a
              // granted slug, keep it; otherwise pin to their own tenant.
              if (requestedSlug && grantedTenantSlugs.includes(requestedSlug)) {
                window.__citrusTenantSlug = requestedSlug === "self" ? null : requestedSlug;
                return;
              }

              // Default: force to /dashboard/<their-slug> (or /dashboard/
              // if they don't have one yet — shouldn't happen, defensive).
              const target = slug && slug !== "self"
                ? `/dashboard/${encodeURIComponent(slug)}`
                : "/dashboard/";
              const onTarget = path === target || (pathSlug && pathSlug === slug);
              if (!onTarget) {
                // Hard navigation so any in-memory tenant context is reset.
                window.location.replace(target);
                return;
              }
              window.__citrusTenantSlug = slug && slug !== "self" ? slug : null;
            } else if (requestedSlug) {
              // MasterAdmin with an explicit tenant in the URL (path or query):
              // honour it so /dashboard/<slug> does not fall back to self.
              window.__citrusTenantSlug = requestedSlug === "self" ? null : requestedSlug;
            } else {
              // MasterAdmin without a slug: ensure no leftover impersonation.
              window.__citrusTenantSlug = null;
            }
          } catch { /* ignore */ }
        }
        else { localStorage.removeItem("citrus_auth_token"); setAuthState("login"); }
      })
      .catch(() => setAuthState("login"));
  }, []);

  // Check if server has no users (needs setup)
  useEffect(() => {
    if (authState !== "login") return;
    fetch(`${authBase}/auth/setup`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) })
      .then((r) => r.json())
      .then((j) => { if (j.error === "Setup already completed. Use login.") return; /* already has users */ })
      .catch(() => {});
  }, [authState]);

  // Wipe any cached per-tenant UI state when the active identity changes.
  // citrus_profile / citrus_voice are browser-wide caches that previously
  // bled between tenants — signing into Gabtic but showing Bahr's name
  // because the prior session had saved Bahr's profile to localStorage.
  // Clear on every login transition AND every logout; server is truth.
  const clearLocalTenantCache = () => {
    try {
      localStorage.removeItem("citrus_profile");
      localStorage.removeItem("citrus_voice");
      localStorage.removeItem("citrus_active_env");
      localStorage.removeItem("citrus_active_env_data"); // env override
    } catch { /* ignore */ }
    window.__citrusTenantSlug = null;
  };
  // When master-admin saves a new enabledSections array for THIS tenant,
  // the server broadcasts a "section_access_updated" SSE event (see
  // server/routes/master-admin.js PATCH /businesses/:id). Dashboard's
  // SSE bridge rebroadcasts it as a window CustomEvent so this gate can
  // update state without holding its own EventSource. Effect: a tenant
  // whose dashboard is open right now sees the sidebar re-filter
  // instantly when the operator toggles a checkbox — no hard refresh.
  React.useEffect(() => {
    const onChange = (e) => {
      try {
        const next = e && e.detail && e.detail.enabledSections;
        // Server emits `null` to mean "field cleared → fall back to all
        // visible". Store as undefined locally so the sidebar / blocker
        // logic treats it as the all-visible default.
        if (Array.isArray(next)) setEnabledSections(next);
        else setEnabledSections(undefined);
        if (e.detail && "allowAgentCreation" in e.detail) setAllowAgentCreation(e.detail.allowAgentCreation !== false);
        if (e.detail && "showCompanyRecommendations" in e.detail) {
          window.dispatchEvent(new CustomEvent("citrus-company-recommendations-updated", { detail: { showCompanyRecommendations: e.detail.showCompanyRecommendations === true } }));
        }
      } catch { /* ignore malformed payloads */ }
    };
    window.addEventListener("citrus-section-access-updated", onChange);
    return () => window.removeEventListener("citrus-section-access-updated", onChange);
  }, []);

  const handleLogin = (user) => {
    const preserveEnvOnLogin = !!window.__citrusOpenedWithEnv;
    const keptEnvId = preserveEnvOnLogin ? localStorage.getItem("citrus_active_env") : null;
    const keptEnvDataRaw = preserveEnvOnLogin ? localStorage.getItem("citrus_active_env_data") : null;
    clearLocalTenantCache();
    if (preserveEnvOnLogin && keptEnvId && keptEnvDataRaw) {
      try {
        localStorage.setItem("citrus_active_env", keptEnvId);
        localStorage.setItem("citrus_active_env_data", keptEnvDataRaw);
        window.__citrusActiveEnv = JSON.parse(keptEnvDataRaw);
      } catch { /* ignore restore errors */ }
    }
    // If this login is a different identity than the cached one, drop the
    // cached allowlist so the fallback below can't apply the previous tenant's
    // section restrictions (the no-flicker login does NOT reload, so /auth/me's
    // identity-clear won't run here).
    try {
      const ident = `${user?.id || user?.email}|${user?.businessId || "self"}`;
      const prev  = localStorage.getItem("citrus_identity");
      if (prev && prev !== ident) localStorage.removeItem(ENABLED_SECTIONS_KEY);
    } catch { /* ignore */ }
    // Capture section-access allowlist on fresh login too (the /auth/me
    // effect won't run for an in-memory transition from "login" → "ready").
    if (user && Array.isArray(user.enabledSections)) {
      setEnabledSections(user.enabledSections);
    } else {
      // No allowlist in the login response — keep the last-known cached one
      // (transient post-deploy gap) instead of flashing "all visible".
      const cached = readCachedSections();
      setEnabledSections(cached === null ? undefined : cached);
    }
    if (user) setAllowAgentCreation(user.allowAgentCreation !== false);
    // Re-apply the tenant slug immediately after clearing the cache.
    // clearLocalTenantCache() just nulled window.__citrusTenantSlug, but if
    // the user is already at their canonical URL (e.g. visited
    // /dashboard/wiss-ngrok and logged in there), no redirect fires below —
    // the slug stays null for the whole session and every API call lacks
    // X-Tenant-Slug, causing the server to return host-tenant data instead.
    // If a redirect DOES fire, the page reloads and captureTenantSlug()
    // re-runs from the URL, so this is harmless in that path too.
    if (user && user.role !== "MasterAdmin" && user.businessSlug && user.businessSlug !== "self") {
      window.__citrusTenantSlug = user.businessSlug;
    }
    setAuthUser(user);
    setAuthState("ready");
    // Force the URL to the canonical place for this identity, ALWAYS —
    // regardless of where the login form was rendered from. Without this,
    // a stale /t/<other-slug>/ URL would survive sign-in: my middleware
    // would refuse the slug impersonation for the new user, but the
    // visual URL would still say the wrong tenant and the X-Tenant-Slug
    // header on outbound fetches would keep getting silently ignored —
    // hugely confusing. window.location.replace forces a full page load
    // so every piece of in-memory state from the previous session is gone.
    try {
      const slug = user && user.businessSlug;
      const isMaster = user && user.role === "MasterAdmin";
      const target = isMaster || !slug || slug === "self"
        ? "/dashboard/"
        : `/dashboard/${encodeURIComponent(slug)}`;
      const here = window.location.pathname + window.location.search;
      if (here !== target) {
        // Fresh open+login: rewrite the URL WITHOUT a full reload. The tenant
        // slug is already on window.__citrusTenantSlug (set above) and the
        // patched fetch reads it per-request, so API scoping stays correct with
        // no reload — this removes the "dashboard opens then suddenly
        // refreshes" flicker. Hard-reload ONLY when an earlier identity was
        // active in THIS page session (switching accounts), where a prior
        // user's in-memory state must be discarded.
        if (window.__citrusHadSession) window.location.replace(target);
        else window.history.replaceState(null, "", target);
      }
      window.__citrusHadSession = true;
    } catch { /* ignore */ }
  };
  const handleLogout = () => {
    const token = localStorage.getItem("citrus_auth_token");
    if (token) fetch(`${authBase}/auth/logout`, { method: "POST", headers: { Authorization: `Bearer ${token}` } }).catch(() => {});
    localStorage.removeItem("citrus_auth_token");
    localStorage.removeItem("citrus_identity");
    clearLocalTenantCache();
    setAuthUser(null);
    setAuthState("login");
    // Strip any /t/<slug>/ path or ?tenant= query param. Without this,
    // the URL still says "tenant=bahr" on the login screen, and the
    // next sign-in's captureTenantSlug picks it back up → the new user
    // silently inherits the previous tenant's impersonation context.
    try {
      window.history.replaceState(null, "", "/dashboard/");
    } catch { /* ignore */ }
  };

  if (authState === "checking") return (
    <div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--bg)" }}>
      <div style={{ fontSize: 28, opacity: 0.4 }}>◍</div>
    </div>
  );

  if (authState === "login" || authState === "setup") {
    return <LoginScreen onLogin={handleLogin} serverUrl={S} initialPhase={authState} initialError={oauthError} />;
  }

  return React.cloneElement(children, { authUser, enabledSections, allowAgentCreation, onLogout: handleLogout });
}

function Dashboard({ authUser, enabledSections, allowAgentCreation = true, onLogout }) {
  const hasTenantContext = !!window.__citrusTenantSlug;
  const [tab, setTab]           = useState("home");
  const [archOpen, setArchOpen] = useState(false);
  const [archPreset, setArchPreset] = useState(null); // { flow, name } — seeds the architect
  const [agents, setAgents]     = useState(() => (hasTenantContext ? [] : AGENTS));
  const [approvalCount, setApprovalCount] = useState(0);
  const [activity, setActivity] = useState(ACTIVITY);
  const [threads, setThreads]   = useState(() => (hasTenantContext ? [] : INBOX_THREADS));
  const [focused, setFocused]   = useState(null); // agent id, for detail view
  const [chatId, setChatId]     = useState(null);
  const [chatStartTab, setChatStartTab] = useState("live");
  const [chatInitialPrompt, setChatInitialPrompt] = useState("");
  const citrusPrefKey = useMemo(() => {
    const userKey = authUser?.id || authUser?.email || "anon";
    const tenantKey = (window.__citrusTenantSlug || authUser?.businessId || "self");
    return `citrus_internal_launcher_enabled:${userKey}:${tenantKey}`;
  }, [authUser?.id, authUser?.email, authUser?.businessId]);
  const citrusPosKey = useMemo(() => {
    const userKey = authUser?.id || authUser?.email || "anon";
    const tenantKey = (window.__citrusTenantSlug || authUser?.businessId || "self");
    return `citrus_internal_launcher_pos:${userKey}:${tenantKey}`;
  }, [authUser?.id, authUser?.email, authUser?.businessId]);
  const [citrusLauncherEnabled, setCitrusLauncherEnabled] = useState(false);
  const [agentLogs, setAgentLogs] = useState({}); // { [agentId]: [{level, text, ts}] }
  const [conflicts, setConflicts] = useState([]);
  const [globalLearnings, setGlobalLearnings] = useState(null); // null = not yet loaded; set on first SSE
  const [tourOpen, setTourOpen] = useState(false);
  const [onboardingOpen, setOnboardingOpen] = useState(false);
  // Listen for "Re-run onboarding" clicks from Settings → Company so the
  // user can re-open the wizard mid-session. Same dispatcher pattern the
  // landing page uses (citrus:open-signin etc.).
  useEffect(() => {
    const handler = () => setOnboardingOpen(true);
    window.addEventListener("citrus-open-onboarding", handler);
    return () => window.removeEventListener("citrus-open-onboarding", handler);
  }, []);
  const [mobileNavOpen, setMobileNavOpen] = useState(false); // sidebar drawer on mobile
  const [envs, setEnvs] = useState([]);
  const [activeEnvId, setActiveEnvId] = useState(() => localStorage.getItem("citrus_active_env") || "");

  useEffect(() => {
    try { setCitrusLauncherEnabled(localStorage.getItem(citrusPrefKey) === "1"); }
    catch { setCitrusLauncherEnabled(false); }
  }, [citrusPrefKey]);

  const setCitrusLauncherPreference = (enabled) => {
    setCitrusLauncherEnabled(!!enabled);
    try {
      localStorage.setItem(citrusPrefKey, enabled ? "1" : "0");
      if (enabled) localStorage.removeItem(citrusPosKey);
    } catch {}
  };
  const openArchitect = (preset) => {
    if (!allowAgentCreation) {
      window.toast && window.toast("New agent hiring is currently disabled for this business.", "warn");
      return;
    }
    // Diagnostic — surfaces in the browser console so we can confirm the
    // click reached app.jsx when the user reports "Hire isn't working".
    try { console.debug("[citrus] openArchitect", preset || null); } catch {}
    setArchPreset(preset || null);
    setArchOpen(true);
  };

  // Close the mobile drawer whenever the user picks a tab or focuses an agent
  useEffect(() => { setMobileNavOpen(false); }, [tab, focused]);

  // Monotonic id for newly-injected activity entries
  const idRef = useRef(1000);
  const nextId = () => ++idRef.current;

  const updateRating = (id, rating) => {
    setAgents((prev) => prev.map((a) => (a.id === id ? { ...a, rating } : a)));
    const SERVER_URL = (typeof window !== "undefined" && window.CITRUS_CONFIG && window.CITRUS_CONFIG.SERVER_URL) || "";
    if (!SERVER_URL) return;
    fetch(`${SERVER_URL}/agents/${id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ rating }),
    }).catch(() => {});
  };

  // Update arbitrary agent fields (channel bindings, brief edits, etc.) and
  // re-sync the agent to the runtime server so /whatsapp picks up the new
  // routing immediately.
  const updateAgent = (id, patch) => {
    // Only sync when the patch changes runtime-owned configuration fields.
    // Local/UI-only updates (e.g. learnings pin/unpin, memory previews)
    // should not trigger POST /agents, which is admin-gated server-side.
    const syncKeys = new Set([
      "name", "role", "brief", "tools", "personality", "approvalGate",
      "quietHours", "escalateTo", "channels", "status", "voice", "avoid",
      "analyticsEnabled", "orchestratorEnabled", "rankedLearningInjection",
    ]);
    const shouldSync = Object.keys(patch || {}).some((k) => syncKeys.has(k));

    setAgents((prev) => prev.map((a) => {
      if (a.id !== id) return a;
      const next = { ...a, ...patch, channels: { ...(a.channels || {}), ...(patch.channels || {}) } };
      if (shouldSync) syncAgentToServer(next);
      return next;
    }));
  };

  const addActivity = (entry) => {
    setActivity((prev) => [entry, ...prev].slice(0, 60));
    const SERVER_URL = (typeof window !== "undefined" && window.CITRUS_CONFIG && window.CITRUS_CONFIG.SERVER_URL) || "";
    if (!SERVER_URL) return;
    fetch(`${SERVER_URL}/activity`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ type: entry.k || "action", agentId: entry.agent, text: entry.text, k: entry.k }),
    }).catch(() => {});
  };

  // ---- LIVE INBOX BRIDGE ----
  // Mirror the runtime server's conversations into the dashboard. On mount:
  //   1. Pull the server's agents and merge any the dashboard hasn't seen.
  //   2. Pull every conversation + its full transcript and turn each into
  //      an Inbox thread.
  //   3. Open an SSE stream on /events for live updates: new messages get
  //      appended to the matching thread, new conversations get prepended,
  //      takeover/wrap-up flips status.
  useEffect(() => {
    const SERVER_URL = (typeof window !== "undefined" && window.CITRUS_CONFIG && window.CITRUS_CONFIG.SERVER_URL) || "";
    if (!SERVER_URL) return; // no runtime server → keep the local-only experience

    let cancelled = false;
    let es = null;

    // Functional updates — we never want to read stale `agents`/`threads` here.
    const upsertAgent = (server) => {
      setAgents((prev) => {
        const idx = prev.findIndex((a) => a.id === server.id);
        if (idx === -1) return [...prev, stubAgentFromServer(server)];
        const next = prev.slice();
        next[idx] = {
          ...next[idx],
          type:      server.type      ?? next[idx].type,
          name:      dashboardAgentName(server, next[idx].name),
          businessId: server.businessId || next[idx].businessId,
          businessName: server.businessName || next[idx].businessName,
          role:      server.role      || next[idx].role,
          brief:     server.brief     || next[idx].brief,
          tools:     server.tools     || next[idx].tools,
          channels:  server.channels  || next[idx].channels,
          learnings: server.learnings ?? next[idx].learnings,
          learnedAt: server.learnedAt ?? next[idx].learnedAt,
          status:    server.status    ?? next[idx].status,
          rating:    server.rating    ?? next[idx].rating,
        };
        return next;
      });
    };

    const upsertThread = (thread) => {
      // Remember which agent owns this thread so subsequent SSE events
      // (message/lead/wrapup) can credit the right agent without refetching.
      const map = (window.__citrusThreadAgent ||= {});
      if (thread.agent) map[thread.id] = thread.agent;
      setThreads((prev) => {
        const idx = prev.findIndex((t) => t.id === thread.id);
        if (idx === -1) return [thread, ...prev];
        const next = prev.slice();
        next[idx] = { ...next[idx], ...thread };
        return next;
      });
    };

    const appendMessage = (from, message) => {
      setThreads((prev) => {
        const idx = prev.findIndex((t) => t.id === from);
        if (idx === -1) return prev; // thread arrives via new_conversation first
        const next = prev.slice();
        const t = next[idx];
        const isCustomer = message.role === "customer";
        const nowIso = new Date().toISOString();
        const updated = {
          ...t,
          last: message.content,
          time: "now",
          // Inbound from the customer flips unread on; outbound (agent/operator)
          // never marks unread. The server marks read state at POST mark-read,
          // so a refresh re-syncs the badge from the authoritative source.
          unread: isCustomer ? true : t.unread,
          lastMessageAt: message.t || nowIso,
          status: isCustomer && t.status === "active" ? "active" : t.status,
          transcript: [
            ...t.transcript,
            {
              id: message.id || null,
              from: isCustomer ? "customer" : "agent",
              text: message.content,
              t: shortTime(message.t || nowIso),
              iso: message.t || nowIso,
              reasoning: message.reasoning || null,
              provenance: message.provenance || null,
            },
          ],
        };
        next.splice(idx, 1);
        // Newest activity goes to the top of the list — matches the server's
        // initial GET /conversations ordering so the visible order is
        // consistent before and after the SSE update.
        return [updated, ...next];
      });
    };

    const setThreadFlag = (from, patch) => {
      setThreads((prev) => prev.map((t) => t.id === from ? { ...t, ...patch } : t));
    };

    // Initial hydrate: agents + every conversation.
    (async () => {
      try {
        const agentsRes = await fetch(`${SERVER_URL}/agents`);
        if (agentsRes.ok) {
          const list = await agentsRes.json();
          if (cancelled) return;
          const safeList = Array.isArray(list) ? list : [];
          setAgents((prev) => {
            const prevById = new Map((prev || []).map((a) => [a.id, a]));
            return safeList.map((server) => {
              const existing = prevById.get(server.id);
              const base = existing || stubAgentFromServer(server);
              return {
                ...base,
                id: server.id,
                type: server.type ?? base.type,
                name: dashboardAgentName(server, base.name),
                businessId: server.businessId || base.businessId,
                businessName: server.businessName || base.businessName,
                role: server.role || base.role,
                brief: server.brief || base.brief,
                tools: server.tools || base.tools || [],
                channels: server.channels || base.channels || {},
                learnings: server.learnings ?? base.learnings,
                learnedAt: server.learnedAt ?? base.learnedAt,
                status: server.status ?? base.status,
                rating: server.rating ?? base.rating,
              };
            });
          });
        }
      } catch { /* server may be down — fall through */ }

      try {
        const convosRes = await fetch(`${SERVER_URL}/conversations`);
        if (convosRes.ok) {
          const list = await convosRes.json();
          if (cancelled) return;
          // Fetch each full transcript in parallel, oldest first so prepend
          // ordering matches recency when we drop them in.
          const fulls = await Promise.all(
            list.map((c) => fetch(`${SERVER_URL}/conversations/${encodeURIComponent(c.from)}`).then((r) => r.ok ? r.json() : null).catch(() => null))
          );
          if (cancelled) return;
          for (const c of fulls.filter(Boolean)) upsertThread(threadFromConversation(c));
        }
      } catch { /* tolerate */ }

      try {
        const actRes = await fetch(`${SERVER_URL}/activity`);
        if (actRes.ok && !cancelled) {
          const list = await actRes.json();
          if (list.length > 0) setActivity(list);
        }
      } catch { /* tolerate */ }

      try {
        const cfRes = await fetch(`${SERVER_URL}/conflicts`);
        if (cfRes.ok && !cancelled) setConflicts(await cfRes.json());
      } catch { /* tolerate */ }

      try {
        // Use a relative URL — always hits the primary server regardless of
        // which environment's SERVER_URL is active.
        const envRes = await fetch("/admin/environments");
        if (envRes.ok && !cancelled) {
          const list = (await envRes.json()).environments || [];
          setEnvs(list);
          // Keep the select state aligned with persisted selection.
          // Do NOT auto-clear here: env availability can be temporarily stale,
          // and clearing forces an unintended fallback to production.
          const chosenEnvId = localStorage.getItem("citrus_active_env") || "";
          setActiveEnvId(chosenEnvId);

          // If the chosen environment belongs to another tenant, jump to that
          // tenant route so X-Tenant-Slug and X-Env-Id stay aligned.
          if (chosenEnvId) {
            const chosen = list.find((e) => e.id === chosenEnvId) || null;
            if (chosen) {
              try { localStorage.setItem("citrus_active_env_data", JSON.stringify(chosen)); } catch {}
              const current = (window.__citrusTenantSlug || "self").toLowerCase();
              const target = String(chosen.businessSlug || (chosen.businessId === "self" ? "self" : "")).toLowerCase();
              if (target && target !== current) {
                const targetPath = target === "self" ? "/dashboard" : `/dashboard/${encodeURIComponent(target)}`;
                if (window.location.pathname !== targetPath) {
                  window.location.assign(targetPath + window.location.search + window.location.hash);
                  return;
                }
              }
            }
          }
        }
      } catch { /* tolerate */ }

      // Fresh-tenant first-run flow. Now two-stage:
      //   1. OnboardingWizard — six-question intake → Claude turns it
      //      into a structured profile + recommended agents. Triggered
      //      when the tenant has no profile yet (server-side check via
      //      /onboarding/status). Wins over the spotlight tour because
      //      it captures real data instead of just narrating the UI.
      //   2. The legacy spotlight tour stays available from the topbar
      //      "Take a tour" button but no longer auto-fires.
      try {
        const me = window.__citrusUser || {};
        const dismissKey = `citrus_onboarding_dismissed:${me.email || "anon"}`;
        if (!localStorage.getItem(dismissKey)) {
          const r = await fetch(`${SERVER_URL}/onboarding/status`);
          if (r.ok) {
            const { needs } = await r.json();
            if (needs && !cancelled) setOnboardingOpen(true);
          }
        }
      } catch { /* tolerate */ }
    })();

    // Live updates.
    //
    // EventSource can't set Authorization headers or X-Tenant-Slug, so we
    // pass the session token and the tenant slug as query params. The auth
    // middleware honours ?token= ONLY on /events (auth.js), and
    // applyTenantSlugOverride reads ?_tenant= as a fallback for the slug
    // header. Without this, production deployments that have user accounts
    // (DASHBOARD_SECRET is then null in CITRUS_CONFIG) silently 401 on
    // /events — the inbox falls back to whatever loaded on page refresh
    // and never receives live `message` / `new_conversation` events.
    try {
      const _cfg   = window.CITRUS_CONFIG || {};
      const _token = (typeof localStorage !== "undefined" && localStorage.getItem("citrus_auth_token"))
                  || _cfg.DASHBOARD_SECRET || "";
      const _slug  = window.__citrusTenantSlug || "";
      const _qs = new URLSearchParams();
      if (_token) _qs.set("token",   _token);
      if (_slug)  _qs.set("_tenant", _slug);
      const _eventsUrl = `${SERVER_URL}/events${_qs.toString() ? "?" + _qs.toString() : ""}`;
      es = new EventSource(_eventsUrl);
      es.addEventListener("agent", (e) => {
        try { upsertAgent(JSON.parse(e.data).agent); } catch {}
      });
      es.addEventListener("conflicts_updated", (e) => {
        try { setConflicts(JSON.parse(e.data).conflicts || []); } catch {}
      });
      es.addEventListener("global_learnings", (e) => {
        try { setGlobalLearnings(JSON.parse(e.data).learnings || []); } catch {}
      });
      es.addEventListener("new_conversation", async (e) => {
        try {
          const { from, agentId } = JSON.parse(e.data);
          const r = await fetch(`${SERVER_URL}/conversations/${encodeURIComponent(from)}`);
          if (!r.ok) return;
          const c = await r.json();
          upsertThread(threadFromConversation(c));
          addActivity({
            id: nextId(), t: nowStamp(), agent: agentId,
            text: `New customer started a chat on ${c.channel}`,
            k: "ok",
          });
          window.toast && window.toast(`New conversation on ${c.channel}`, "good");
        } catch {}
      });
      es.addEventListener("message", (e) => {
        try {
          const { from, message } = JSON.parse(e.data);
          appendMessage(from, message);
          // Each agent reply counts as a run so the agent's stat cards (and
          // billing) reflect real volume. The agent id comes from the thread
          // lookup populated when threads hydrate.
          if (message.role === "agent") {
            const agentId = (window.__citrusThreadAgent ||= {})[from];
            if (agentId) {
              setAgents((prev) => prev.map((a) => a.id === agentId ? {
                ...a,
                runs: (a.runs || 0) + 1,
                spark: [...a.spark.slice(1), Math.min(20, (a.runs || 0) + 1)],
              } : a));
            }
          }
        } catch {}
      });
      es.addEventListener("lead", (e) => {
        try {
          const { from, lead } = JSON.parse(e.data);
          const lookup = (window.__citrusThreadAgent ||= {});
          const agentId = lookup[from];
          // Live-update the thread row so the inbox shows the captured name and
          // classification tags without waiting for a reload.
          const threadPatch = {};
          if (lead.name) threadPatch.customerName = lead.name;
          if (Array.isArray(lead.categories)) threadPatch.categories = lead.categories;
          if (Object.keys(threadPatch).length) setThreadFlag(from, threadPatch);
          const bits = [];
          if (lead.name)    bits.push(`name: ${lead.name}`);
          if (lead.company) bits.push(`company: ${lead.company}`);
          if (bits.length === 0) return;
          addActivity({
            id: nextId(), t: nowStamp(), agent: agentId || null,
            text: `Captured lead — ${bits.join(", ")}`,
            k: "lead",
          });
        } catch {}
      });
      es.addEventListener("takeover", (e) => {
        try {
          const { from, takenOver } = JSON.parse(e.data);
          setThreadFlag(from, { status: takenOver ? "needs-you" : "active", tag: takenOver ? "You're replying" : "Live" });
        } catch {}
      });
      // Persona sessions clean up their conversation when stopped — drop it from the Inbox too.
      es.addEventListener("conversation_deleted", (e) => {
        try {
          const { from } = JSON.parse(e.data);
          setThreads((prev) => prev.filter((t) => t.from !== from));
        } catch {}
      });
      es.addEventListener("agent_log", (e) => {
        try {
          const d = JSON.parse(e.data);
          setAgentLogs((prev) => {
            const cur = prev[d.agentId] || [];
            return { ...prev, [d.agentId]: [...cur.slice(-199), { level: d.level, text: d.text, ts: d.ts }] };
          });
        } catch {}
      });
      es.addEventListener("learning", (e) => {
        try {
          const { agentId, agentName, lesson } = JSON.parse(e.data);
          // Push new lesson onto the agent's learnings array so the
          // "What I've learned" panel updates without a page refresh.
          setAgents((prev) => prev.map((a) => a.id === agentId ? {
            ...a,
            learnings: [...(a.learnings || []), lesson].slice(-30),
            learnedAt: lesson.at || new Date().toISOString(),
          } : a));
          addActivity({
            id: nextId(), t: nowStamp(), agent: agentId,
            text: `${agentName} learned: ${lesson.what}`,
            why: lesson.why || null,
            lessonId: lesson.id || null,
            k: "ok",
          });
          window.toast && window.toast(`${agentName} just got smarter`, "good");
        } catch {}
      });
      // Leads tab listens for this as a window event so we don't multiplex
      // a second EventSource off the same /events stream. Payload mirrors
      // the PATCH /leads-conv/:from response shape (from, lifecycleStatus,
      // notes, businessId).
      es.addEventListener("lead_updated", (e) => {
        try {
          const payload = JSON.parse(e.data);
          window.dispatchEvent(new CustomEvent("citrus-lead-updated", { detail: payload }));
        } catch {}
      });
      // Section-access live update — emitted when master-admin saves a
      // new enabledSections array for this tenant. Rebroadcast as a
      // window CustomEvent so AuthGate (which holds the state) can
      // update without holding its own EventSource. The dashboard
      // re-renders with the new sidebar filter / disabled-tab gating
      // on the next React tick — operators no longer have to tell
      // customers "clear your cache" when they change access.
      es.addEventListener("section_access_updated", (e) => {
        try {
          const payload = JSON.parse(e.data);
          window.dispatchEvent(new CustomEvent("citrus-section-access-updated", { detail: payload }));
        } catch {}
      });
      es.addEventListener("knowledge_review", (e) => {
        try {
          const payload = JSON.parse(e.data);
          window.dispatchEvent(new CustomEvent("citrus-knowledge-review", { detail: payload }));
        } catch {}
      });
      es.addEventListener("wrapup", (e) => {
        try {
          const { from, summary } = JSON.parse(e.data);
          setThreadFlag(from, { status: "handled", tag: "Wrapped up" });
          const lookup = (window.__citrusThreadAgent ||= {});
          const agentId = lookup[from];
          const status = summary?.status || "closed";
          const need = (summary?.need || "").slice(0, 80);
          addActivity({
            id: nextId(), t: nowStamp(), agent: agentId || null,
            text: `Wrapped up · ${status}${need ? " — " + need : ""}`,
            k: status === "lead" || status === "booked" ? "lead" : "ok",
          });
        } catch {}
      });
      es.onerror = () => { /* browser will auto-reconnect */ };
    } catch { /* EventSource unavailable — tolerate */ }

    return () => {
      cancelled = true;
      if (es) es.close();
    };
  }, []);

  // Called by the architect modal when the user clicks "Put it on shift".
  // The runtime server takes over from here — every real customer message
  // and wrap-up flows back as an activity entry via SSE. No fake demo data.
  const addAgent = (newAgent) => {
    if (!allowAgentCreation) {
      window.toast && window.toast("New agent hiring is currently disabled for this business.", "warn");
      return;
    }
    const live = { ...newAgent, status: "live" };
    setAgents((prev) => [...prev, live]);
    setTab("agents");
    setFocused(null);
    addActivity({
      id: nextId(),
      t: nowStamp(),
      agent: newAgent.id,
      text: `${newAgent.name} joined the team`,
      k: "ok",
    });
    syncAgentToServer(live);
  };

  // Push an agent config to the runtime server. Voice rules come from
  // localStorage because they're per-tenant settings the architect doesn't
  // produce, and they're harmless if stale (worst case: tone drift).
  //
  // We deliberately DO NOT send `company` anymore. That field used to be
  // read from localStorage("citrus_profile").name, which silently went stale
  // whenever the operator switched tenants without a hard reload — turning
  // every subsequent agent save into a cross-tenant identity write (e.g.
  // saving any Gabtic agent right after viewing BMW Egypt would stamp
  // company:"BMW Egypt" onto a Gabtic-owned record). The server now derives
  // identity from the resolved tenant's profile.name in system-prompt.js.
  // Sending `company` from here can ONLY reintroduce the bug.
  const syncAgentToServer = (agent) => {
    const SERVER_URL = (typeof window !== "undefined" && window.CITRUS_CONFIG && window.CITRUS_CONFIG.SERVER_URL) || "";
    if (!SERVER_URL) return;
    let voice = {};
    try { voice = JSON.parse(localStorage.getItem("citrus_voice") || "{}"); } catch {}
    const payload = {
      id: agent.id,
      name: agent.name,
      role: agent.role,
      brief: agent.brief,
      tools: agent.tools,
      personality: agent.personality,
      approvalGate: agent.approvalGate,
      quietHours: agent.quietHours || "",
      escalateTo: agent.escalateTo || "",
      channels: agent.channels || {},
      voice: voice.style || "Warm, direct, short sentences.",
      avoid: voice.avoid || "",
      rankedLearningInjection: agent.rankedLearningInjection !== false,
    };
    const { id, ...patch } = payload;
    fetch(`${SERVER_URL}/agents/${encodeURIComponent(agent.id)}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(patch),
    }).then((r) => {
      if (!r.ok) throw new Error(`status ${r.status}`);
      window.toast && window.toast(`${agent.name} synced to runtime server`, "good");
    }).catch((err) => {
      window.toast && window.toast(`Couldn't reach runtime server (${String(err.message || err).slice(0, 60)})`, "warn");
    });
  };

  const focusedAgent = focused ? agents.find((a) => a.id === focused) : null;
  const citrusAgent = agents.find((a) => a.type === "internal" && a.status === "live") || null;
  const citrusChatOpen = !!citrusAgent && chatId === citrusAgent.id;
  const showCitrusLauncher = !!citrusAgent && citrusLauncherEnabled && !citrusChatOpen;
  const visibleTab = tab === "effectiveness" ? "knowledge" : tab;
  const Section = SECTIONS[visibleTab];
  const switchTab = (nextTab) => {
    const mapped = nextTab === "effectiveness" ? "knowledge" : nextTab;
    setTab(mapped);
    setFocused(null);
  };

  // Defensive route gating: if the user lands on a disabled tab via URL
  // hand-typing or a stale bookmark, render the placeholder instead. Settings
  // is always allowed regardless of the allowlist. An array-shaped
  // `enabledSections` is the only case that gates — null (not loaded) and
  // undefined (server didn't set it = all enabled) both allow everything.
  const sectionBlocked = Array.isArray(enabledSections)
    && visibleTab !== "settings"
    && visibleTab !== "home"
    && !enabledSections.includes(visibleTab);

  // Section components receive whatever props they need; extras are ignored.
  const sectionProps = {
    agents,
    activity,
    threads,
    conflicts,
    globalLearnings,
    setThreads,
    onOpen:  setFocused,
    onNew:   openArchitect,
    onRate:  updateRating,
    onChat:  (id) => { setChatInitialPrompt(""); setChatStartTab("live"); setChatId(id); },
    onAskCitrus: (prompt) => {
      if (!citrusAgent) { window.toast && window.toast("Citrus is not available yet.", "warn"); return; }
      setChatInitialPrompt(String(prompt || ""));
      setChatStartTab("ask");
      setChatId(citrusAgent.id);
    },
    onHire:  openArchitect,
    onSwitchTab: switchTab,
    approvalsBadge: approvalCount,
    onCountChange: setApprovalCount,
    onEnvsChange: setEnvs,
    userRole: authUser?.role,
    citrusLauncherEnabled,
    onCitrusLauncherToggle: setCitrusLauncherPreference,
    citrusAvailable: !!citrusAgent,
    allowAgentCreation,
  };

  return (
    <div className={`dash${mobileNavOpen ? " dash-nav-open" : ""}`}>
      {mobileNavOpen ? (
        <div className="dash-nav-backdrop" onClick={() => setMobileNavOpen(false)} />
      ) : null}
      <Sidebar
        tab={visibleTab}
        setTab={switchTab}
        onNew={openArchitect}
        inboxBadge={threads.filter((t) => t.status === "needs-you" && t.unread).length}
        approvalsBadge={approvalCount}
        authUser={authUser}
        enabledSections={enabledSections}
        allowAgentCreation={allowAgentCreation}
      />
      <div className="dash-main">
        <Topbar
          tab={visibleTab}
          focused={focusedAgent}
          agents={agents}
          onBack={() => setFocused(null)}
          onNew={openArchitect}
          onStartTour={() => setTourOpen(true)}
          onOpenAgent={(id) => setFocused(id)}
          onSwitchTab={switchTab}
          onMenu={() => setMobileNavOpen((v) => !v)}
          activeEnvName={window.__citrusActiveEnv ? window.__citrusActiveEnv.name : null}
          envs={envs}
          activeEnvId={activeEnvId}
          authUser={authUser}
          onLogout={onLogout}
          allowAgentCreation={allowAgentCreation}
        />
        <div className="dash-body">
          {focusedAgent
            ? <window.AgentDetail
                agent={focusedAgent}
                activity={activity}
                threads={threads}
                setThreads={setThreads}
                agentLogs={agentLogs[focusedAgent.id] || []}
                conflicts={conflicts}
                authUser={authUser}
                onRate={(r) => updateRating(focusedAgent.id, r)}
                onUpdate={(patch) => updateAgent(focusedAgent.id, patch)}
              />
            : sectionBlocked
              ? <DisabledTabPlaceholder onSwitchTab={switchTab} />
              : Section ? <Section {...sectionProps} /> : null}
        </div>
      </div>

      <ArchitectModal
        open={archOpen}
        preset={archPreset}
        onClose={() => { setArchOpen(false); setArchPreset(null); }}
        onCreate={addAgent}
      />
      <ChatDrawer
        agent={chatId ? agents.find(a => a.id === chatId) : null}
        threads={threads}
        initialTab={chatStartTab}
        initialPrompt={chatInitialPrompt}
        onClose={() => { setChatId(null); setChatInitialPrompt(""); }}
      />
      {showCitrusLauncher ? (
        <CitrusFloatingLauncher
          storageKey={citrusPosKey}
          onOpen={() => { setChatInitialPrompt(""); setChatStartTab("ask"); setChatId(citrusAgent.id); }}
        />
      ) : null}
      <window.Walkthrough
        open={tourOpen}
        onClose={() => {
          setTourOpen(false);
          // Remember dismissal so the auto-open trigger doesn't reopen on
          // every refresh. Per-user, so signing in as a different tenant
          // owner still gets their own first-run tour.
          try {
            const me = window.__citrusUser || {};
            localStorage.setItem(`citrus_tour_dismissed:${me.email || "anon"}`, new Date().toISOString());
          } catch {}
        }}
        onSwitchTab={switchTab}
      />
      {window.OnboardingWizard && (
        <window.OnboardingWizard
          open={onboardingOpen}
          onClose={() => {
            setOnboardingOpen(false);
            // Per-user dismissal — same pattern as the tour. Re-run from
            // Settings → Danger zone if the owner wants to redo it.
            try {
              const me = window.__citrusUser || {};
              localStorage.setItem(`citrus_onboarding_dismissed:${me.email || "anon"}`, new Date().toISOString());
            } catch {}
          }}
          onComplete={() => {
            setOnboardingOpen(false);
            // Same dismissal flag — server has the canonical
            // onboardingCompletedAt timestamp so /onboarding/status
            // returns needs:false on the next mount.
            try {
              const me = window.__citrusUser || {};
              localStorage.setItem(`citrus_onboarding_dismissed:${me.email || "anon"}`, new Date().toISOString());
            } catch {}
            // Switch to Agents tab so the "Hire your first agent" CTA
            // is immediately in view, now with the saved profile.
            setTab("agents");
          }}
        />
      )}
      <window.ToastHost />
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(
  <AuthGate><Dashboard /></AuthGate>
);
