// App root — wires everything together
const { useState, useEffect, useRef } = React;

function App() {
  // Auth state FIRST so its hooks run regardless of render branch.
  //   undefined = still booting (splash)
  //   null      = logged out → <Login/>
  //   object    = signed-in user; render the app
  const [currentUser, setCurrentUser] = useState(undefined);
  const [profileOpen, setProfileOpen] = useState(false);

  // Dashboard state — declared at top level (never behind a branch) so
  // hook order stays stable between login-gated and signed-in renders.
  const [nodes, setNodes] = useState(() => window.makeNodes());
  const [alerts, setAlerts] = useState(() => window.makeAlerts(window.makeNodes()));
  const [logs, setLogs] = useState(() => window.seedLogs(nodes));
  const [selected, setSelected] = useState(null);
  const [search, setSearch] = useState('');
  const [tweaks, setTweaks] = useState(window.__TWEAKS__);
  const [tweaksVisible, setTweaksVisible] = useState(false);

  // Initial auth check — GET /api/auth/me. If 401 → logged out.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const r = await fetch('/api/auth/me', { credentials: 'same-origin' });
        if (cancelled) return;
        if (r.status === 401) { setCurrentUser(null); return; }
        if (!r.ok) { setCurrentUser(null); return; }
        setCurrentUser(await r.json());
      } catch (_) { if (!cancelled) setCurrentUser(null); }
    })();
    return () => { cancelled = true; };
  }, []);

  // Session-lost event (apiFetch emits it on 401, or signOut() on logout).
  useEffect(() => {
    function onLost() { setCurrentUser(null); }
    window.addEventListener('pulse:session-lost', onLost);
    return () => window.removeEventListener('pulse:session-lost', onLost);
  }, []);

  // persist selected view across reloads
  useEffect(() => {
    const saved = localStorage.getItem('noc-view');
    if (saved) setTweaks(t => ({ ...t, view: saved }));
  }, []);
  useEffect(() => { localStorage.setItem('noc-view', tweaks.view); }, [tweaks.view]);

  // apply density to body
  useEffect(() => {
    document.body.classList.remove('dense-low', 'dense-high');
    if (tweaks.density <= 4) document.body.classList.add('dense-low');
    else if (tweaks.density >= 8) document.body.classList.add('dense-high');
  }, [tweaks.density]);

  // Live probe polling — merges backend probe results into local state.
  // Real latency/status/httpCode/sslDaysLeft come from the probe agent; we
  // preserve client-side history arrays by appending from the new sample.
  const [probeMeta, setProbeMeta] = useState({ lastCycleMs: 0, lastCycleAt: 0, live: false });
  useEffect(() => {
    // Don't start polling the authed API until we know we're signed in.
    // Otherwise the initial boot would fire /api/nodes while logged out
    // and emit a spurious session-lost event.
    if (!currentUser) return;
    let cancelled = false;
    async function pull() {
      try {
        const body = await window.fetchProbeNodes();
        if (cancelled) return;
        // Full-outer merge: backend is source of truth for membership
        // (so admin add/delete propagate), local copies keep their
        // in-flight ephemeral fields.
        setNodes(prev => {
          const byLocalId = new Map(prev.map(n => [n.id, n]));
          const merged = body.nodes.map(remote => {
            const local = byLocalId.get(remote.id);
            const latHist = remote.latencyHist && remote.latencyHist.length
              ? remote.latencyHist
              : (local ? [...local.latencyHist.slice(1), remote.latency] : []);
            const netHist = remote.netHist && remote.netHist.length
              ? remote.netHist
              : (local ? [...local.netHist.slice(1), (remote.netIn || 0) + (remote.netOut || 0)] : []);
            return {
              ...(local || {}),
              ...remote,
              sslDaysLeft: remote.sslDaysLeft ?? (local ? local.sslDaysLeft : null),
              latencyHist: latHist,
              netHist,
            };
          });
          // Keep window.GROUPS in sync with live data so existing
          // components (threed / topology / overview heatmap) pick up
          // newly-added groups without per-file refactors.
          if (window.deriveGroups) window.GROUPS = window.deriveGroups(merged);
          return merged;
        });
        setProbeMeta({ ...body.meta, live: true });
        // synthesize a log line from the latest probe cycle
        setLogs(prev => {
          const sample = body.nodes[Math.floor(Math.random() * body.nodes.length)];
          if (!sample) return prev;
          const lvl = sample.status === 'ok' ? 'I' : sample.status === 'warn' ? 'W' : sample.status === 'crit' ? 'E' : 'W';
          const msg = sample.error
            ? `HEAD ${sample.path} → ${sample.error}`
            : `HEAD ${sample.path} → ${sample.httpCode ?? '---'} in ${Math.round(sample.latency)}ms`;
          const line = { ts: sample.checkedAt || Date.now(), lvl, src: 'probe', node: sample.id, msg };
          return [...prev.slice(-199), line];
        });
      } catch (e) {
        if (!cancelled) setProbeMeta(m => ({ ...m, live: false }));
      }
    }
    pull();
    const interval = setInterval(pull, tweaks.liveTick ? 8000 : 30000);
    return () => { cancelled = true; clearInterval(interval); };
  }, [tweaks.liveTick, currentUser]);

  // setup edit-mode postMessage
  useEffect(() => {
    function onMsg(e) {
      if (e.data?.type === '__activate_edit_mode') setTweaksVisible(true);
      if (e.data?.type === '__deactivate_edit_mode') setTweaksVisible(false);
    }
    window.addEventListener('message', onMsg);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    return () => window.removeEventListener('message', onMsg);
  }, []);

  // persist tweaks
  useEffect(() => {
    window.parent.postMessage({ type: '__edit_mode_set_keys', edits: tweaks }, '*');
  }, [tweaks]);

  // selected node — always read fresh from nodes array
  const selectedNode = selected ? nodes.find(n => n.id === selected) : null;

  // filtered nodes by search
  const filteredNodes = React.useMemo(() => {
    if (!search.trim()) return nodes;
    const parts = search.toLowerCase().split(/\s+/);
    return nodes.filter(n => parts.every(p => {
      if (p.includes(':')) {
        const [k, v] = p.split(':');
        return String(n[k] || '').toLowerCase().includes(v);
      }
      return n.hostname.toLowerCase().includes(p) || n.displayName.toLowerCase().includes(p) || n.group.toLowerCase().includes(p) || n.url.toLowerCase().includes(p);
    }));
  }, [nodes, search]);

  // actions
  async function rebootNode(n) {
    setLogs(prev => [...prev, { ts: Date.now(), lvl: 'I', src: 'probe', node: n.id, msg: `manual recheck requested by operator` }]);
    try {
      const remote = await window.triggerProbe(n.id);
      setNodes(prev => prev.map(nn => nn.id === n.id ? {
        ...nn,
        status: remote.status,
        httpCode: remote.httpCode,
        latency: remote.latency,
        netIn: remote.netIn,
        netOut: remote.netOut,
        sslDaysLeft: remote.sslDaysLeft ?? nn.sslDaysLeft,
        checkedAt: remote.checkedAt,
        error: remote.error,
      } : nn));
      setLogs(prev => [...prev, {
        ts: Date.now(),
        lvl: remote.status === 'ok' ? 'I' : 'W',
        src: 'probe',
        node: n.id,
        msg: `recheck → ${remote.httpCode ?? remote.error ?? 'err'} in ${Math.round(remote.latency)}ms`,
      }]);
    } catch (e) {
      setLogs(prev => [...prev, { ts: Date.now(), lvl: 'E', src: 'probe', node: n.id, msg: `recheck failed: ${e.message}` }]);
    }
  }
  async function toggleMaint(n) {
    // Optimistic flip — backend mirrors within one poll anyway.
    const nextPaused = !n.paused;
    setNodes(prev => prev.map(nn => nn.id === n.id
      ? { ...nn, paused: nextPaused, status: nextPaused ? 'maint' : nn.status }
      : nn));
    try {
      await window.adminFetch(
        `/api/admin/servers/${n.id}/${nextPaused ? 'pause' : 'resume'}`,
        { method: 'POST' }
      );
    } catch (e) {
      // Revert on auth failure / network error and surface via logs.
      setNodes(prev => prev.map(nn => nn.id === n.id ? { ...nn, paused: !nextPaused } : nn));
      setLogs(prev => [...prev, { ts: Date.now(), lvl: 'E', src: 'admin', node: n.id, msg: `pause toggle failed: ${e.message}` }]);
    }
  }
  function ackAlert(id) {
    setAlerts(prev => prev.map(a => a.id === id ? { ...a, ack: true } : a));
  }

  // expose incident simulation
  useEffect(() => {
    window.__simulateIncident = () => {
      const pick = nodes[Math.floor(Math.random() * nodes.length)];
      setNodes(prev => prev.map(nn => nn.id === pick.id ? { ...nn, status: 'crit', latency: 1500, httpCode: 503 } : nn));
      setAlerts(prev => [{
        id: 'a-' + Math.random().toString(36).slice(2, 7),
        ts: Date.now(),
        severity: 'crit',
        node: pick.id,
        title: 'Simulated latency spike',
        body: 'p99 > 400ms for 30s',
        ack: false,
      }, ...prev]);
    };
  }, [nodes]);

  const viewTitles = {
    overview: 'Overview',
    threed: '3D fleet',
    topology: 'Topology',
    nodes: 'Endpoints',
    alerts: 'Alerts',
    logs: 'Logs',
    admin: 'Admin',
    notifications: 'Notifications',
    users: 'Users',
    history: 'History',
  };
  // Per-user view gate. Admins see everything; non-admins see only what
  // their `views` list allows. The Users view is admin-only (it hits
  // /api/admin/users which requires isAdmin anyway — exposing it to
  // non-admins would just show a useless empty panel).
  function isViewAllowed(v) {
    if (!currentUser) return false;
    if (v === 'users') return !!currentUser.isAdmin;
    if (currentUser.isAdmin) return true;
    return (currentUser.views || []).includes(v);
  }
  const allowedViews = Object.keys(viewTitles).filter(isViewAllowed);
  const view = isViewAllowed(tweaks.view) ? tweaks.view : (allowedViews[0] || 'overview');
  const liveGroups = window.deriveGroups ? window.deriveGroups(nodes) : window.GROUPS;

  // Drag-drop from Overview status grid → move a node to another group.
  // Optimistic local update + PATCH; on success raises an undo toast
  // that reverses the move if clicked within 5 s. On server failure
  // the optimistic change is rolled back silently.
  async function onGroupMove(nodeId, newGroup, { suppressUndo = false } = {}) {
    const cur = nodes.find(n => n.id === nodeId);
    if (!cur) return;
    const prevGroup = cur.group;
    if (prevGroup === newGroup) return;
    setNodes(all => all.map(n => n.id === nodeId ? { ...n, group: newGroup } : n));
    try {
      await window.apiFetch(`/api/admin/servers/${nodeId}`, {
        method: 'PATCH', body: JSON.stringify({ group: newGroup }),
      });
      if (!suppressUndo && window.__pulseShowUndo) {
        window.__pulseShowUndo({
          label: `moved ${cur.displayName || cur.hostname} → ${newGroup}`,
          onUndo: () => onGroupMove(nodeId, prevGroup, { suppressUndo: true }),
        });
      }
    } catch (e) {
      setNodes(all => all.map(n => n.id === nodeId ? { ...n, group: prevGroup } : n));
      alert(`Move failed: ${e.message || e}`);
    }
  }

  // After an admin-side mutation we trigger an immediate poll so the UI
  // reflects the change without waiting for the 8s interval.
  async function refetchNodesNow() {
    try {
      const body = await window.fetchProbeNodes();
      setNodes(prev => {
        const byLocal = new Map(prev.map(n => [n.id, n]));
        const merged = body.nodes.map(remote => {
          const local = byLocal.get(remote.id);
          return { ...(local || {}), ...remote,
            latencyHist: remote.latencyHist || (local ? local.latencyHist : []),
            netHist: remote.netHist || (local ? local.netHist : []) };
        });
        if (window.deriveGroups) window.GROUPS = window.deriveGroups(merged);
        return merged;
      });
    } catch (_) { /* next poll will catch it */ }
  }

  // Auth render gate — hooks above this point run every render.
  if (currentUser === undefined) {
    return <div style={{position:'fixed', inset:0, display:'flex', alignItems:'center',
      justifyContent:'center', background:'var(--bg)', color:'var(--text-mute)', fontSize: 11}}>
        starting up…
      </div>;
  }
  if (currentUser === null) {
    return <window.Login onAuthed={setCurrentUser} />;
  }

  return (
    <React.Fragment>
      <window.Topbar
        nodes={nodes}
        search={search} setSearch={setSearch}
        live={tweaks.liveTick}
        toggleLive={() => setTweaks(t => ({ ...t, liveTick: !t.liveTick }))}
        currentUser={currentUser}
        onOpenProfile={() => setProfileOpen(true)}
      />
      <div className="body">
        <window.Sidebar view={view} setView={v => setTweaks(t => ({ ...t, view: v }))}
          nodes={nodes} alerts={alerts} currentUser={currentUser} isViewAllowed={isViewAllowed} />
        <main className="main">
          <div className="main-head">
            <div className="main-title">
              <span className="path">noc / </span>{viewTitles[view]}
              {search && <span className="path"> · filter: "{search}"</span>}
            </div>
            <span style={{marginLeft:'auto', fontSize:10, color:'var(--text-mute)', letterSpacing:'0.08em', textTransform:'uppercase'}}>
              last sync {new Date().toLocaleTimeString()}
            </span>
          </div>
          <div className="main-body">
            {view === 'overview' && isViewAllowed('overview') && <window.Overview nodes={filteredNodes} alerts={alerts} onSelect={n => setSelected(n.id)} currentUser={currentUser} onGroupMove={onGroupMove} />}
            {view === 'threed' && isViewAllowed('threed') && <window.ThreeD nodes={filteredNodes} onSelect={n => setSelected(n.id)} style3d={tweaks.threedStyle} />}
            {view === 'topology' && isViewAllowed('topology') && <window.Topology nodes={filteredNodes} onSelect={n => setSelected(n.id)} />}
            {view === 'nodes' && isViewAllowed('nodes') && <window.NodesTable nodes={filteredNodes} onSelect={n => setSelected(n.id)} selectedId={selected} />}
            {view === 'alerts' && isViewAllowed('alerts') && <window.Alerts alerts={alerts} nodes={nodes} onSelectNode={n => setSelected(n.id)} onAck={ackAlert} />}
            {view === 'logs' && isViewAllowed('logs') && <window.Logs logs={logs} live={tweaks.liveTick} />}
            {view === 'notifications' && isViewAllowed('notifications') && <window.Notifications runtimeNodes={nodes} />}
            {view === 'admin' && isViewAllowed('admin') && <window.Admin runtimeNodes={nodes} groups={liveGroups} onNodesChanged={refetchNodesNow} currentUser={currentUser} />}
            {view === 'users' && isViewAllowed('users') && (
              <div style={{padding: 12, display:'flex', flexDirection:'column', gap: 12}}>
                <window.UsersAdmin currentUser={currentUser} />
                {window.AuditLog && <window.AuditLog />}
              </div>
            )}
            {view === 'history' && isViewAllowed('history') && <window.History runtimeNodes={nodes} />}

            <window.NodeDetail
              node={selectedNode}
              onClose={() => setSelected(null)}
              onReboot={rebootNode}
              onMaint={toggleMaint}
            />
          </div>
        </main>
      </div>
      <window.TweaksPanel tweaks={tweaks} setTweaks={setTweaks} visible={tweaksVisible} />
      <window.Profile open={profileOpen} onClose={() => setProfileOpen(false)} />
      {window.UndoToastStack && <window.UndoToastStack />}
    </React.Fragment>
  );
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
