import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import ForceGraph3D, { type ForceGraphMethods, type NodeObject } from "react-force-graph-3d";
import SpriteText from "three-spritetext";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { Vector2 } from "three";
import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
import { renderMicron } from "@/components/editor/micronRenderer";
import { useWindowManager } from "@/hooks/useWindowManager";
import { getThemeStatusColors } from "@/components/browse/graphColors";
import { buildGraphData, type GraphNode, type GraphData } from "@/components/browse/buildGraph";
import type { BrowseWinData, HistoryEntry } from "@/components/browse/types";
import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow";
import BrowseSearchBar from "@/components/browse/BrowseSearchBar";
import Loader from "@/components/shared/Loader";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

type FGNode = NodeObject<GraphNode>;

function cssVar(name: string): string {
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}

/** Resolve a CSS variable to a normalised hex string (#rrggbb) */
function cssVarToHex(name: string): string {
  const raw = cssVar(name);
  if (!raw) return "#888888";
  const ctx = document.createElement("canvas").getContext("2d")!;
  ctx.fillStyle = raw;
  return ctx.fillStyle; // always "#rrggbb"
}

const DIM_COLOR = "rgba(60,60,60,0.15)";
const DIM_LINK = "rgba(60,60,60,0.03)";

// ---------------------------------------------------------------------------
// Retro post-processing shader: pixelation + posterize + scanlines + vignette
// ---------------------------------------------------------------------------

const RetroShader = {
  uniforms: {
    tDiffuse: { value: null },
    resolution: { value: new Vector2(800, 600) },
    pixelSize: { value: 2.0 },
    colorLevels: { value: 48.0 },
    scanlineIntensity: { value: 0.03 },
    scanlineDensity: { value: 1.0 },
    vignetteIntensity: { value: 0.15 },
    tintColor: { value: [1.0, 0.95, 0.85] },
  },
  vertexShader: /* glsl */ `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */ `
    uniform sampler2D tDiffuse;
    uniform vec2 resolution;
    uniform float pixelSize;
    uniform float colorLevels;
    uniform float scanlineIntensity;
    uniform float scanlineDensity;
    uniform float vignetteIntensity;
    uniform vec3 tintColor;
    varying vec2 vUv;

    void main() {
      // Pixelation
      vec2 dxy = pixelSize / resolution;
      vec2 coord = dxy * floor(vUv / dxy) + dxy * 0.5;
      vec4 color = texture2D(tDiffuse, coord);

      // Posterize (reduce color depth)
      color.rgb = floor(color.rgb * colorLevels + 0.5) / colorLevels;

      // Subtle tint towards theme color
      color.rgb *= tintColor;

      // Scanlines
      float scanline = sin(vUv.y * resolution.y * scanlineDensity) * 0.5 + 0.5;
      color.rgb -= scanlineIntensity * (1.0 - scanline);

      // Vignette
      vec2 vig = vUv * (1.0 - vUv);
      float vigFactor = pow(vig.x * vig.y * 15.0, vignetteIntensity);
      color.rgb *= vigFactor;

      gl_FragColor = color;
    }
  `,
};

// ---------------------------------------------------------------------------
// Memoized 3D graph — isolated from window/UI state changes
// ---------------------------------------------------------------------------

type FlyToFn = (pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => void;

interface Graph3DProps {
  graphData: GraphData;
  searchMatchIds: Set<string> | null;
  themeRev: number;
  width: number;
  height: number;
  onNodeClick: (node: NetworkNode) => void;
  fgRef: React.MutableRefObject<ForceGraphMethods<FGNode> | undefined>;
  flyToRef: React.MutableRefObject<FlyToFn | undefined>;
  containerRef: React.RefObject<HTMLDivElement | null>;
}

const Graph3D = memo(function Graph3D({ graphData, searchMatchIds, themeRev, width, height, onNodeClick, fgRef, flyToRef, containerRef }: Graph3DProps) {
  // react-kapsule diffs props during every render — graphData triggers a full
  // simulation restart (alpha=1). Stabilise the reference.
  const stableDataRef = useRef(graphData);
  const prevNodeIds = useRef("");
  const nodeIds = graphData.nodes.map(n => n.id).join(",");
  if (nodeIds !== prevNodeIds.current) {
    stableDataRef.current = graphData;
    prevNodeIds.current = nodeIds;
  }

  // Dim non-matching nodes/links during search
  const nodeColor = useCallback((node: FGNode) => {
    if (!searchMatchIds) return (node as GraphNode).color;
    return searchMatchIds.has(node.id as string) ? (node as GraphNode).color : DIM_COLOR;
  }, [searchMatchIds]);

  const linkColor = useCallback((link: any) => {
    if (!searchMatchIds) return "rgba(100,100,100,0.15)";
    const srcId = typeof link.source === "object" ? (link.source.id as string) : link.source;
    const tgtId = typeof link.target === "object" ? (link.target.id as string) : link.target;
    if (searchMatchIds.has(srcId) || searchMatchIds.has(tgtId)) return "rgba(100,100,100,0.15)";
    return DIM_LINK;
  }, [searchMatchIds]);

  // Fly camera to search matches
  useEffect(() => {
    const fg = fgRef.current;
    if (!fg || !searchMatchIds) return;
    if (searchMatchIds.size <= 10) {
      fg.zoomToFit(600, 80, (n: FGNode) => searchMatchIds.has(n.id as string));
    }
  }, [searchMatchIds, fgRef]);

  // ── Camera auto-orbit with smooth ease-in / ease-out ──
  const orbitAngleRef = useRef(0);
  const orbitTargetSpeed = useRef(1.0); // 1 = full speed, 0 = stopped
  const orbitCurrentSpeed = useRef(0.0); // smoothed value
  const hoveringNodeRef = useRef(false);
  const flyingRef = useRef(false); // true while cameraPosition transition is active — hard-blocks orbit
  const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const flyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const IDLE_RESUME_MS = 3000;
  const BASE_SPEED = Math.PI / 600;
  const HOVER_FACTOR = 0.1; // 10% speed when hovering
  const EASE_RATE = 0.02; // lerp factor per tick — smaller = smoother

  /** Fly camera to a position. Completely blocks orbit during the transition. */
  const flyTo = useCallback((pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => {
    const fg = fgRef.current;
    if (!fg) return;
    flyingRef.current = true;
    orbitCurrentSpeed.current = 0;
    orbitTargetSpeed.current = 0;
    if (flyTimerRef.current) clearTimeout(flyTimerRef.current);
    if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
    fg.cameraPosition(pos, lookAt, durationMs);
    flyTimerRef.current = setTimeout(() => {
      flyingRef.current = false;
      const cam = fg.camera();
      orbitAngleRef.current = Math.atan2(cam.position.x, cam.position.z);
      idleTimerRef.current = setTimeout(() => { orbitTargetSpeed.current = 1; }, IDLE_RESUME_MS);
    }, durationMs);
  }, [fgRef]);

  // Expose flyTo to parent
  flyToRef.current = flyTo;

  const pauseOrbit = useCallback(() => {
    orbitTargetSpeed.current = 0;
    if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
    idleTimerRef.current = setTimeout(() => { orbitTargetSpeed.current = 1; }, IDLE_RESUME_MS);
  }, []);

  const onNodeHover = useCallback((node: FGNode | null) => {
    hoveringNodeRef.current = !!node;
  }, []);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const events = ["mousedown", "wheel", "touchstart"] as const;
    for (const evt of events) el.addEventListener(evt, pauseOrbit, { passive: true });
    return () => { for (const evt of events) el.removeEventListener(evt, pauseOrbit); };
  }, [pauseOrbit, containerRef]);

  useEffect(() => {
    const interval = setInterval(() => {
      const fg = fgRef.current;
      if (!fg || flyingRef.current) return;

      // Smooth target: full speed or hover-reduced
      const target = orbitTargetSpeed.current * (hoveringNodeRef.current ? HOVER_FACTOR : 1);
      // Ease towards target
      orbitCurrentSpeed.current += (target - orbitCurrentSpeed.current) * EASE_RATE;
      // Skip negligible movement
      if (Math.abs(orbitCurrentSpeed.current) < 0.001) return;

      const cam = fg.camera();
      const distance = Math.sqrt(cam.position.x ** 2 + cam.position.z ** 2) || 400;
      orbitAngleRef.current += BASE_SPEED * orbitCurrentSpeed.current;
      fg.cameraPosition({
        x: distance * Math.sin(orbitAngleRef.current),
        z: distance * Math.cos(orbitAngleRef.current),
      });
    }, 20);
    return () => { clearInterval(interval); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [fgRef]);

  // ── Post-processing: bloom + retro ──
  const postProcInitRef = useRef(false);
  const retroPassRef = useRef<ShaderPass | null>(null);

  useEffect(() => {
    const fg = fgRef.current;
    if (!fg || postProcInitRef.current) return;
    // Wait a tick for the renderer to be ready
    const timer = setTimeout(() => {
      try {
        const composer = fg.postProcessingComposer();
        // Bloom — subtle glow
        const bloom = new UnrealBloomPass(new Vector2(width, height), 0.3, 0.3, 0.9);
        composer.addPass(bloom);
        // Retro shader
        const retro = new ShaderPass(RetroShader);
        retro.uniforms.resolution.value.set(width, height);
        // Tint towards theme primary
        const hex = cssVarToHex("--primary");
        const r = parseInt(hex.slice(1, 3), 16) / 255;
        const g = parseInt(hex.slice(3, 5), 16) / 255;
        const b = parseInt(hex.slice(5, 7), 16) / 255;
        // Blend towards white so the tint is subtle
        retro.uniforms.tintColor.value = [0.7 + r * 0.3, 0.7 + g * 0.3, 0.7 + b * 0.3];
        composer.addPass(retro);
        retroPassRef.current = retro;
        postProcInitRef.current = true;
      } catch { /* renderer not ready yet, will retry */ }
    }, 500);
    return () => clearTimeout(timer);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [fgRef, width, height]);

  // Update retro tint when theme changes
  useEffect(() => {
    const retro = retroPassRef.current;
    if (!retro) return;
    const hex = cssVarToHex("--primary");
    const r = parseInt(hex.slice(1, 3), 16) / 255;
    const g = parseInt(hex.slice(3, 5), 16) / 255;
    const b = parseInt(hex.slice(5, 7), 16) / 255;
    retro.uniforms.tintColor.value = [0.7 + r * 0.3, 0.7 + g * 0.3, 0.7 + b * 0.3];
  }, [themeRev]);

  // Update resolution uniform on resize
  useEffect(() => {
    const retro = retroPassRef.current;
    if (retro) retro.uniforms.resolution.value.set(width, height);
  }, [width, height]);

  // ── Custom node objects: text labels above every node ──
  const nodeThreeObject = useCallback((node: FGNode) => {
    const gn = node as GraphNode;
    const sprite = new SpriteText(gn.name);
    (sprite as any).material.depthWrite = false;
    (sprite as any).renderOrder = 999;
    sprite.color = gn.type === "interface"
      ? (cssVar("--foreground") || "#888")
      : gn.color;
    sprite.textHeight = gn.type === "interface" ? 4 : 3;
    sprite.fontFace = "JetBrains Mono, monospace";
    sprite.fontWeight = gn.type === "interface" ? "700" : "400";
    sprite.backgroundColor = "transparent";
    if (gn.type === "interface") {
      (sprite as any).center.set(0.5, 0.5);
    } else {
      (sprite as any).center.set(0.5, 2.5);
    }
    return sprite;
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [themeRev]);

  const nodeVisibility = useCallback((node: FGNode) => {
    return (node as GraphNode).type !== "interface";
  }, []);

  const nodeThreeObjectExtend = useCallback((node: FGNode) => {
    return (node as GraphNode).type !== "interface";
  }, []);

  const showPointerCursor = useCallback((obj: any) => {
    if (!obj || !("type" in obj)) return false;
    return (obj as GraphNode).type !== "interface";
  }, []);

  const handleClick = useCallback((node: FGNode) => {
    const gn = node as GraphNode;
    if (gn.type === "interface") return;

    if (node.x !== undefined && node.y !== undefined && node.z !== undefined) {
      const distance = 40;
      const dist = Math.hypot(node.x, node.y, node.z);
      const newPos = dist > 0
        ? { x: node.x * (1 + distance / dist), y: node.y * (1 + distance / dist), z: node.z * (1 + distance / dist) }
        : { x: 0, y: 0, z: distance };
      flyTo(newPos, node as any, 1500);
    }

    onNodeClick(gn.entry);
  }, [onNodeClick, flyTo]);

  return (
    <ForceGraph3D
      ref={fgRef}
      graphData={stableDataRef.current}
      width={width}
      height={height}
      backgroundColor="rgba(0,0,0,0)"
      nodeId="id"
      nodeVal="size"
      nodeColor={nodeColor}
      nodeLabel=""
      nodeOpacity={0.9}
      nodeResolution={12}
      nodeVisibility={nodeVisibility}
      nodeThreeObject={nodeThreeObject}
      nodeThreeObjectExtend={nodeThreeObjectExtend}
      onNodeClick={handleClick}
      onNodeHover={onNodeHover}
      showPointerCursor={showPointerCursor}
      linkColor={linkColor}
      linkWidth={0.3}
      linkOpacity={0.12}
      cooldownTicks={150}
      warmupTicks={0}
      enableNodeDrag={true}
      enableNavigationControls={true}
      showNavInfo={false}
    />
  );
});

// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------

export default function BrowseView() {
  const [nodes, setNodes] = useState<NetworkNode[]>([]);
  const [filter, setFilter] = useState("");
  const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();

  const [themeRev, setThemeRev] = useState(0);

  // Watch for theme changes (class on <html>)
  useEffect(() => {
    const observer = new MutationObserver(() => setThemeRev(r => r + 1));
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    return () => observer.disconnect();
  }, []);

  const containerRef = useRef<HTMLDivElement>(null);
  const fgRef = useRef<ForceGraphMethods<FGNode>>(undefined);
  const flyToRef = useRef<FlyToFn>(undefined);
  const prevPositionsRef = useRef<Map<string, { x: number; y: number; z: number }>>(new Map());

  // Container sizing
  const [dims, setDims] = useState<{ w: number; h: number }>({ w: 800, h: 600 });
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      if (!entry) return;
      setDims({ w: entry.contentRect.width, h: entry.contentRect.height });
    });
    ro.observe(el);
    setDims({ w: el.clientWidth, h: el.clientHeight });
    return () => ro.disconnect();
  }, []);

  // Build graph data, preserving existing positions
  const graphData = useMemo(() => {
    const fg = fgRef.current;
    if (fg) {
      try {
        // @ts-expect-error — graphData() is on the underlying instance
        const live = fg.graphData?.() as { nodes: FGNode[] } | undefined;
        if (live?.nodes) {
          const map = new Map<string, { x: number; y: number; z: number }>();
          for (const n of live.nodes) {
            if (n.x !== undefined && n.y !== undefined && n.z !== undefined) {
              map.set(n.id as string, { x: n.x, y: n.y, z: n.z });
            }
          }
          prevPositionsRef.current = map;
        }
      } catch { /* ignore */ }
    }
    return buildGraphData(nodes, prevPositionsRef.current, getThemeStatusColors());
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [nodes, themeRev]);

  // Search — matches + autocomplete suggestions
  const searchMatchIds = useMemo(() => {
    if (!filter.trim()) return null;
    const q = filter.trim().toLowerCase();
    const ids = new Set<string>();
    for (const n of graphData.nodes) {
      if (n.name.toLowerCase().includes(q)) ids.add(n.id);
    }
    return ids.size > 0 ? ids : null;
  }, [filter, graphData]);

  const allPeerNodes = useMemo(() => {
    return graphData.nodes
      .filter(e => e.type !== "interface")
      .map(e => e.entry);
  }, [graphData]);

  const suggestions = useMemo(() => {
    if (!filter.trim()) return [];
    const q = filter.trim().toLowerCase();
    return allPeerNodes.filter(e => e.name.toLowerCase().includes(q));
  }, [filter, allPeerNodes]);

  // Fly camera to a highlighted search suggestion.
  // ForceGraph3D mutates graphData.nodes in-place with x/y/z after simulation,
  // so we read positions directly from the graph data nodes.
  const onHighlightNode = useCallback((node: NetworkNode | null) => {
    if (!node) return;
    const fly = flyToRef.current;
    if (!fly) return;
    const match = graphData.nodes.find(n => n.id === node.hash) as FGNode | undefined;
    if (!match || match.x === undefined || match.y === undefined || match.z === undefined) return;
    const distance = 60;
    const dist = Math.hypot(match.x, match.y, match.z);
    const newPos = dist > 0
      ? { x: match.x * (1 + distance / dist), y: match.y * (1 + distance / dist), z: match.z * (1 + distance / dist) }
      : { x: 0, y: 0, z: distance };
    fly(newPos, match as any, 800);
  }, [graphData]);

  const onSearchFocusChange = useCallback((_focused: boolean) => {
    // Could be used to dim graph when search is active
  }, []);

  // ── Fly camera to focused window's node ──
  useEffect(() => {
    if (!focusedWinId) return;
    const fly = flyToRef.current;
    if (!fly) return;
    const match = graphData.nodes.find(n => n.id === focusedWinId) as FGNode | undefined;
    if (!match || match.x === undefined || match.y === undefined || match.z === undefined) return;
    const distance = 60;
    const dist = Math.hypot(match.x, match.y, match.z);
    const newPos = dist > 0
      ? { x: match.x * (1 + distance / dist), y: match.y * (1 + distance / dist), z: match.z * (1 + distance / dist) }
      : { x: 0, y: 0, z: distance };
    fly(newPos, match as any, 1000);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [focusedWinId]);

  // ── SSE stream ──
  const nodeMapRef = useRef<Map<string, NetworkNode>>(new Map());
  useEffect(() => {
    let pending: NetworkNode[] = [];
    let batchTimer: ReturnType<typeof setTimeout> | null = null;
    const flush = () => {
      batchTimer = null;
      if (pending.length === 0) return;
      const batch = pending; pending = [];
      const map = nodeMapRef.current;
      let changed = false;
      for (const node of batch) {
        if (!map.has(node.hash)) changed = true;
        map.set(node.hash, node);
      }
      if (changed) {
        setNodes(Array.from(map.values()));
      }
    };
    const unsub = subscribeBrowseNodes((node) => { pending.push(node); if (!batchTimer) batchTimer = setTimeout(flush, 200); });
    return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); };
  }, []);

  // ── Navigation helpers ──
  const navigateTo = useCallback((winId: string, node: NetworkNode, path: string, prevData?: BrowseWinData) => {
    const loading: BrowseWinData = {
      node, pageHtml: null, pageLoading: true, pageError: null,
      currentPath: path,
      history: prevData?.history ?? [],
      historyIndex: prevData?.historyIndex ?? -1,
    };
    updateWindow(winId, { data: loading });

    fetchRemotePage(node.hash, path)
      .then((res) => {
        const html = res.content ? renderMicron(res.content, true) : null;
        const error = res.content ? null : (res.error ?? "No content");
        const entry: HistoryEntry = { path, html, error };

        const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
        const newHistory = [...prevHistory, entry];
        const newIndex = newHistory.length - 1;

        updateWindow(winId, { data: { node, pageHtml: html, pageError: error, pageLoading: false, currentPath: path, history: newHistory, historyIndex: newIndex } });
      })
      .catch((e) => {
        const error = String(e);
        const entry: HistoryEntry = { path, html: null, error };
        const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
        const newHistory = [...prevHistory, entry];
        const newIndex = newHistory.length - 1;
        updateWindow(winId, { data: { node, pageError: error, pageLoading: false, pageHtml: null, currentPath: path, history: newHistory, historyIndex: newIndex } });
      });
  }, [updateWindow]);

  const navBack = useCallback((winId: string, data: BrowseWinData) => {
    const newIndex = data.historyIndex - 1;
    if (newIndex < 0) return;
    const entry = data.history[newIndex]!;
    updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } });
  }, [updateWindow]);

  const navForward = useCallback((winId: string, data: BrowseWinData) => {
    const newIndex = data.historyIndex + 1;
    if (newIndex >= data.history.length) return;
    const entry = data.history[newIndex]!;
    updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } });
  }, [updateWindow]);

  const navReload = useCallback((winId: string, data: BrowseWinData) => {
    navigateTo(winId, data.node, data.currentPath, { ...data, historyIndex: data.historyIndex - 1 });
  }, [navigateTo]);

  // ── Node click ──
  const handleNodeClick = useCallback((node: NetworkNode) => {
    const id = node.hash;
    const initData: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null, currentPath: "index.mu", history: [], historyIndex: -1 };
    openWindow(id, initData);
    navigateTo(id, node, "index.mu");
  }, [openWindow, navigateTo]);

  // ── Handle micron link clicks via event delegation ──
  const handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => {
    const anchor = (e.target as HTMLElement).closest("a");
    if (!anchor) return;
    e.preventDefault();

    const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? "";
    if (!dest) return;

    let raw = dest
      .replace(/^nomadnetwork:\/\//, "")
      .replace(/^:/, "")
      .replace(/^\/page\//, "")
      .replace(/^\/+/, "");

    if (/^[0-9a-f]{32}$/i.test(raw)) return;

    let targetNode = data.node;
    let path = raw;
    const crossNodeMatch = raw.match(/^([0-9a-f]{32})\/(.+)$/i);
    if (crossNodeMatch) {
      const targetHash = crossNodeMatch[1]!;
      path = crossNodeMatch[2]!;
      path = path.replace(/^\/page\//, "").replace(/^\/+/, "");
      const known = nodes.find(n => n.hash === targetHash);
      if (known) targetNode = known;
    }

    if (!path || path === "/") return;
    if (!path.endsWith(".mu")) path += ".mu";

    navigateTo(winId, targetNode, path, data);
  }, [navigateTo, nodes]);

  const clearSearch = useCallback(() => {
    setFilter("");
    fgRef.current?.zoomToFit(400, 60);
  }, []);

  return (
    <div ref={containerRef} className="relative overflow-hidden" style={{ height: "100%" }}>
      <Graph3D
        graphData={graphData}
        searchMatchIds={searchMatchIds}
        themeRev={themeRev}
        width={dims.w}
        height={dims.h}
        onNodeClick={handleNodeClick}
        fgRef={fgRef}
        flyToRef={flyToRef}
        containerRef={containerRef}
      />

      <BrowseSearchBar
        filter={filter}
        onFilterChange={setFilter}
        onClear={clearSearch}
        allNodes={allPeerNodes}
        suggestions={suggestions}
        onSelectNode={handleNodeClick}
        onHighlightNode={onHighlightNode}
        onSearchFocusChange={onSearchFocusChange}
        focusedWinId={focusedWinId}
        windowCount={windows.length}
      />

      {nodes.length === 0 && (
        <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm pointer-events-none">
          <Loader />
          Connecting...
        </div>
      )}

      {/* Stop React synthetic events from portaled windows bubbling into the
          graph — portals bubble through the React tree, not the DOM tree. */}
      {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
      <div onMouseDown={e => e.stopPropagation()} onPointerDown={e => e.stopPropagation()}>
        {windows.map((win) => (
          <BrowseNodeWindow
            key={win.id}
            win={win}
            focused={focusedWinId === win.id}
            onUpdate={updateWindow}
            onClose={closeWindowById}
            onFocus={focusWindow}
            onNavBack={navBack}
            onNavForward={navForward}
            onNavReload={navReload}
            onContentClick={handleContentClick}
          />
        ))}
      </div>
    </div>
  );
}
