/**
 * folk-data-cloud — 3D force-directed graph of all data objects across spaces.
 *
 * Canvas 2D with perspective-projected 3D force simulation.
 * Three-tier hierarchy: Space → Module → Document nodes.
 * Click space to collapse/expand.
 * Single-click module to focus/zoom into cluster; double-click to open in new tab.
 * Single-click doc to select & show detail; double-click to open in new tab.
 * Drag to orbit, scroll to zoom. Breadcrumb bar for navigation.
 */

import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { rspaceNavUrl } from '../../../shared/url-helpers';

// ── Types ──

interface DocNode {
	docId: string;
	title: string;
	modId: string;
	modName: string;
	modIcon: string;
	tags: string[];
	space: string;
	spaceName: string;
	visibility: string;
}

interface Node3D {
	id: string;
	label: string;
	icon: string;
	type: 'space' | 'module' | 'doc';
	modId?: string;
	space: string;
	parentId: string | null;
	color: string;
	baseRadius: number;
	// 3D position
	x: number; y: number; z: number;
	// Velocity
	vx: number; vy: number; vz: number;
	// Projected 2D (computed each frame)
	px: number; py: number; pr: number; depth: number;
	// Cluster state
	collapsed: boolean;
	childCount: number;
	hidden: boolean;
}

interface Edge3D {
	from: string;
	to: string;
	color: string;
	style: 'solid' | 'dotted' | 'faint';
}

// ── Constants ──

const VIS_COLORS: Record<string, string> = {
	private: '#ef4444',
	permissioned: '#eab308',
	public: '#22c55e',
};

const MOD_COLORS: Record<string, string> = {
	notes: '#f97316', docs: '#f97316', vote: '#a855f7', tasks: '#3b82f6',
	cal: '#06b6d4', wallet: '#eab308', flows: '#14b8a6', pubs: '#ec4899',
	files: '#64748b', forum: '#8b5cf6', inbox: '#f43f5e', network: '#22d3ee',
	trips: '#10b981', tube: '#f59e0b', choices: '#6366f1', cart: '#84cc16',
	data: '#94a3b8', maps: '#059669',
};

function modColor(id: string): string { return MOD_COLORS[id] || '#94a3b8'; }

const DAMPING = 0.92;
const REPULSION = 800;
const SPRING_K = 0.015;
const SPRING_REST = 120;
const CENTER_PULL = 0.002;
const DT = 0.8;
const FOV = 600;
const MIN_ZOOM = 200;
const MAX_ZOOM = 1200;

// ── Demo data ──

const DEMO_DOCS: DocNode[] = [
	{ docId: 'demo:notes:notebooks:nb1', title: 'Product Roadmap', modId: 'notes', modName: 'rNotes', modIcon: '📝', tags: ['planning'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:notes:notebooks:nb2', title: 'Meeting Notes', modId: 'notes', modName: 'rNotes', modIcon: '📝', tags: ['team'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:notes:notebooks:nb3', title: 'Research Log', modId: 'notes', modName: 'rNotes', modIcon: '📝', tags: ['research'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:vote:proposals:p1', title: 'Dark mode proposal', modId: 'vote', modName: 'rVote', modIcon: '🗳', tags: ['planning'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:vote:proposals:p2', title: 'Budget Q2', modId: 'vote', modName: 'rVote', modIcon: '🗳', tags: ['team'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:tasks:boards:b1', title: 'Dev Board', modId: 'tasks', modName: 'rTasks', modIcon: '📋', tags: ['planning'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:tasks:boards:b2', title: 'Design Sprint', modId: 'tasks', modName: 'rTasks', modIcon: '📋', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:tasks:boards:b3', title: 'Bug Tracker', modId: 'tasks', modName: 'rTasks', modIcon: '📋', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:cal:calendars:c1', title: 'Team Calendar', modId: 'cal', modName: 'rCal', modIcon: '📅', tags: ['team'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:cal:calendars:c2', title: 'Personal', modId: 'cal', modName: 'rCal', modIcon: '📅', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:wallet:ledgers:l1', title: 'cUSDC Ledger', modId: 'wallet', modName: 'rWallet', modIcon: '💰', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:flows:streams:s1', title: 'Contributor Fund', modId: 'flows', modName: 'rFlows', modIcon: '🌊', tags: ['research'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:flows:streams:s2', title: 'Community Pool', modId: 'flows', modName: 'rFlows', modIcon: '🌊', tags: ['team'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:docs:notebooks:d1', title: 'Onboarding Guide', modId: 'docs', modName: 'rDocs', modIcon: '📓', tags: ['planning'], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
	{ docId: 'demo:pubs:pages:pub1', title: 'Launch Announcement', modId: 'pubs', modName: 'rPubs', modIcon: '📰', tags: [], space: 'demo', spaceName: 'Demo Space', visibility: 'public' },
];

// ── Component ──

class FolkDataCloud extends HTMLElement {
	private shadow: ShadowRoot;
	private canvas!: HTMLCanvasElement;
	private ctx!: CanvasRenderingContext2D;
	private space = 'demo';
	private docs: DocNode[] = [];
	private nodes: Node3D[] = [];
	private edges: Edge3D[] = [];
	private nodeMap = new Map<string, Node3D>();
	private loading = true;
	private isDemo = false;
	private width = 800;
	private height = 600;
	private dpr = 1;

	// Camera
	private camDist = 500;
	private rotX = 0.3;  // pitch
	private rotY = 0.0;  // yaw
	private camOffsetX = 0;
	private camOffsetY = 0;

	// Camera animation targets
	private targetCamDist = 500;
	private targetRotX = 0.3;
	private targetRotY = 0.0;
	private targetOffsetX = 0;
	private targetOffsetY = 0;

	// Focus state
	private focusedNodeId: string | null = null;
	private selectedNodeId: string | null = null;
	private breadcrumbRects: { x: number; y: number; w: number; h: number; action: 'all' | 'space' }[] = [];

	// Interaction
	private dragging = false;
	private dragStartX = 0;
	private dragStartY = 0;
	private dragMoved = false;
	private dragRotX = 0;
	private dragRotY = 0;
	private hoveredNode: Node3D | null = null;
	private tooltipX = 0;
	private tooltipY = 0;
	private clickTimer: ReturnType<typeof setTimeout> | null = null;

	// Animation
	private animFrame = 0;
	private frameCount = 0;
	private settled = false;

	// Edge particles
	private particles: { edge: Edge3D; t: number; speed: number }[] = [];

	private _stopPresence: (() => void) | null = null;
	private _resizeObserver: ResizeObserver | null = null;

	constructor() {
		super();
		this.shadow = this.attachShadow({ mode: 'open' });
	}

	connectedCallback() {
		this.space = this.getAttribute('space') || 'demo';
		this.dpr = Math.min(window.devicePixelRatio || 1, 2);

		this.shadow.innerHTML = `<style>${styles()}</style>
			<div class="dc">
				<div class="dc-loading">Loading your data cloud…</div>
				<canvas class="dc-canvas"></canvas>
				<div class="dc-tooltip" style="display:none"></div>
				<div class="dc-legend"></div>
			</div>`;

		this.canvas = this.shadow.querySelector('.dc-canvas')!;
		this.ctx = this.canvas.getContext('2d')!;

		this._resizeObserver = new ResizeObserver((entries) => {
			const w = entries[0]?.contentRect.width || 800;
			this.width = Math.max(w, 300);
			this.height = Math.max(Math.min(this.width * 0.75, 700), 400);
			this.canvas.width = this.width * this.dpr;
			this.canvas.height = this.height * this.dpr;
			this.canvas.style.width = `${this.width}px`;
			this.canvas.style.height = `${this.height}px`;
			this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
		});
		this._resizeObserver.observe(this);

		this.attachInteraction();
		this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Data Cloud' }));
		this.loadData();
	}

	disconnectedCallback() {
		this._stopPresence?.();
		this._resizeObserver?.disconnect();
		if (this.animFrame) cancelAnimationFrame(this.animFrame);
		window.removeEventListener('mousemove', this.onMouseMove);
		window.removeEventListener('mouseup', this.onMouseUp);
	}

	// ── Data loading ──

	private async loadData() {
		const token = localStorage.getItem('rspace_auth');
		if (!token) {
			this.isDemo = true;
			this.docs = DEMO_DOCS;
			this.finalize();
			return;
		}

		try {
			const spacesResp = await fetch('/api/spaces', {
				headers: { Authorization: `Bearer ${token}` },
				signal: AbortSignal.timeout(8000),
			});
			if (!spacesResp.ok) throw new Error('spaces fetch failed');
			const { spaces } = await spacesResp.json() as { spaces: Array<{ slug: string; name: string; visibility: string }> };

			const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, '');
			const allDocs: DocNode[] = [];

			await Promise.all(spaces.map(async (sp) => {
				try {
					const resp = await fetch(
						`${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`,
						{ signal: AbortSignal.timeout(8000) }
					);
					if (!resp.ok) return;
					const tree = await resp.json();
					for (const mod of (tree.modules || [])) {
						for (const col of (mod.collections || [])) {
							for (const item of (col.items || [])) {
								allDocs.push({
									docId: item.docId,
									title: item.title || col.collection,
									modId: mod.id,
									modName: mod.name,
									modIcon: mod.icon,
									tags: item.tags || [],
									space: sp.slug,
									spaceName: sp.name,
									visibility: sp.visibility || 'private',
								});
							}
						}
					}
				} catch { /* skip space */ }
			}));

			this.docs = allDocs;
			this.isDemo = false;
		} catch {
			this.isDemo = true;
			this.docs = DEMO_DOCS;
		}

		this.finalize();
	}

	private finalize() {
		this.loading = false;
		const loadingEl = this.shadow.querySelector('.dc-loading') as HTMLElement;
		if (loadingEl) loadingEl.style.display = 'none';
		this.buildGraph();
		this.renderLegend();
		this.settled = false;
		this.frameCount = 0;
		this.tick();
	}

	// ── Graph construction ──

	private buildGraph() {
		this.nodes = [];
		this.edges = [];
		this.nodeMap.clear();
		this.particles = [];

		// Group docs by space → module
		const spaceMap = new Map<string, { name: string; vis: string; mods: Map<string, DocNode[]> }>();
		for (const doc of this.docs) {
			if (!spaceMap.has(doc.space)) {
				spaceMap.set(doc.space, { name: doc.spaceName, vis: doc.visibility, mods: new Map() });
			}
			const sp = spaceMap.get(doc.space)!;
			if (!sp.mods.has(doc.modId)) sp.mods.set(doc.modId, []);
			sp.mods.get(doc.modId)!.push(doc);
		}

		const spaceKeys = [...spaceMap.keys()];
		const spaceCount = spaceKeys.length;
		if (spaceCount === 0) return;

		const spaceSpread = spaceCount > 1 ? 200 : 0;

		// Create space nodes
		for (let si = 0; si < spaceCount; si++) {
			const slug = spaceKeys[si];
			const sp = spaceMap.get(slug)!;
			const angle = (2 * Math.PI * si / spaceCount);
			let docTotal = 0;
			for (const docs of sp.mods.values()) docTotal += docs.length;

			const spaceNode: Node3D = {
				id: `space:${slug}`,
				label: sp.name,
				icon: '',
				type: 'space',
				space: slug,
				parentId: null,
				color: VIS_COLORS[sp.vis] || VIS_COLORS.private,
				baseRadius: 18,
				x: spaceSpread * Math.cos(angle) + rand(-20, 20),
				y: spaceSpread * Math.sin(angle) + rand(-20, 20),
				z: rand(-50, 50),
				vx: 0, vy: 0, vz: 0,
				px: 0, py: 0, pr: 0, depth: 0,
				collapsed: false,
				childCount: sp.mods.size + docTotal,
				hidden: false,
			};
			this.nodes.push(spaceNode);
			this.nodeMap.set(spaceNode.id, spaceNode);

			// Module nodes
			const modKeys = [...sp.mods.keys()];
			for (let mi = 0; mi < modKeys.length; mi++) {
				const mId = modKeys[mi];
				const docs = sp.mods.get(mId)!;
				const mAngle = (2 * Math.PI * mi / modKeys.length);
				const mDist = 100 + rand(-15, 15);

				const modNode: Node3D = {
					id: `mod:${slug}:${mId}`,
					label: docs[0].modName,
					icon: docs[0].modIcon,
					type: 'module',
					modId: mId,
					space: slug,
					parentId: spaceNode.id,
					color: modColor(mId),
					baseRadius: 12,
					x: spaceNode.x + mDist * Math.cos(mAngle),
					y: spaceNode.y + mDist * Math.sin(mAngle),
					z: spaceNode.z + rand(-40, 40),
					vx: 0, vy: 0, vz: 0,
					px: 0, py: 0, pr: 0, depth: 0,
					collapsed: false,
					childCount: docs.length,
					hidden: false,
				};
				this.nodes.push(modNode);
				this.nodeMap.set(modNode.id, modNode);

				this.edges.push({ from: spaceNode.id, to: modNode.id, color: spaceNode.color, style: 'solid' });

				// Doc nodes
				for (let di = 0; di < docs.length; di++) {
					const doc = docs[di];
					const dAngle = (2 * Math.PI * di / docs.length);
					const dDist = 45 + rand(-10, 10);

					const docNode: Node3D = {
						id: `doc:${doc.docId}`,
						label: doc.title,
						icon: '',
						type: 'doc',
						modId: mId,
						space: slug,
						parentId: modNode.id,
						color: modColor(mId),
						baseRadius: 5,
						x: modNode.x + dDist * Math.cos(dAngle),
						y: modNode.y + dDist * Math.sin(dAngle),
						z: modNode.z + rand(-25, 25),
						vx: 0, vy: 0, vz: 0,
						px: 0, py: 0, pr: 0, depth: 0,
						collapsed: false,
						childCount: 0,
						hidden: false,
					};
					this.nodes.push(docNode);
					this.nodeMap.set(docNode.id, docNode);

					this.edges.push({ from: modNode.id, to: docNode.id, color: modNode.color, style: 'solid' });
				}
			}
		}

		// Cross-space module links (same module type across different spaces)
		if (spaceCount > 1) {
			const modByType = new Map<string, string[]>();
			for (const n of this.nodes) {
				if (n.type !== 'module' || !n.modId) continue;
				if (!modByType.has(n.modId)) modByType.set(n.modId, []);
				modByType.get(n.modId)!.push(n.id);
			}
			for (const [, ids] of modByType) {
				for (let i = 0; i < ids.length; i++) {
					for (let j = i + 1; j < ids.length; j++) {
						const a = this.nodeMap.get(ids[i])!;
						const b = this.nodeMap.get(ids[j])!;
						if (a.space !== b.space) {
							this.edges.push({ from: ids[i], to: ids[j], color: a.color, style: 'dotted' });
						}
					}
				}
			}
		}

		// Cross-module tag links (documents sharing tags)
		const tagMap = new Map<string, string[]>();
		for (const doc of this.docs) {
			for (const tag of doc.tags) {
				if (!tagMap.has(tag)) tagMap.set(tag, []);
				tagMap.get(tag)!.push(`doc:${doc.docId}`);
			}
		}
		for (const [, ids] of tagMap) {
			if (ids.length < 2 || ids.length > 8) continue;
			for (let i = 0; i < ids.length; i++) {
				for (let j = i + 1; j < ids.length; j++) {
					const a = this.nodeMap.get(ids[i]);
					const b = this.nodeMap.get(ids[j]);
					if (a && b && a.parentId !== b.parentId) {
						this.edges.push({ from: ids[i], to: ids[j], color: '#ffffff', style: 'faint' });
					}
				}
			}
		}

		// Seed edge particles on solid edges
		for (const e of this.edges) {
			if (e.style === 'solid') {
				this.particles.push({ edge: e, t: Math.random(), speed: 0.002 + Math.random() * 0.003 });
			}
		}
	}

	// ── Force simulation ──

	private simulate() {
		const visible = this.nodes.filter(n => !n.hidden);
		const len = visible.length;

		// Repulsion (O(n²) — fine for <500 nodes)
		for (let i = 0; i < len; i++) {
			const a = visible[i];
			for (let j = i + 1; j < len; j++) {
				const b = visible[j];
				let dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
				let dist2 = dx * dx + dy * dy + dz * dz;
				if (dist2 < 1) dist2 = 1;
				const force = REPULSION / dist2;
				const dist = Math.sqrt(dist2);
				const fx = (dx / dist) * force;
				const fy = (dy / dist) * force;
				const fz = (dz / dist) * force;
				a.vx += fx * DT; a.vy += fy * DT; a.vz += fz * DT;
				b.vx -= fx * DT; b.vy -= fy * DT; b.vz -= fz * DT;
			}
		}

		// Spring attraction along edges
		for (const e of this.edges) {
			const a = this.nodeMap.get(e.from);
			const b = this.nodeMap.get(e.to);
			if (!a || !b || a.hidden || b.hidden) continue;

			const rest = e.style === 'solid'
				? (a.type === 'space' || b.type === 'space' ? SPRING_REST * 1.2 : SPRING_REST * 0.7)
				: SPRING_REST * 2;
			const k = e.style === 'faint' ? SPRING_K * 0.3 : SPRING_K;

			const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
			const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1;
			const displacement = dist - rest;
			const fx = k * displacement * (dx / dist);
			const fy = k * displacement * (dy / dist);
			const fz = k * displacement * (dz / dist);
			a.vx += fx * DT; a.vy += fy * DT; a.vz += fz * DT;
			b.vx -= fx * DT; b.vy -= fy * DT; b.vz -= fz * DT;
		}

		// Center pull + damping + integrate
		let totalV = 0;
		for (const n of visible) {
			n.vx -= n.x * CENTER_PULL;
			n.vy -= n.y * CENTER_PULL;
			n.vz -= n.z * CENTER_PULL;

			n.vx *= DAMPING;
			n.vy *= DAMPING;
			n.vz *= DAMPING;

			n.x += n.vx;
			n.y += n.vy;
			n.z += n.vz;

			totalV += Math.abs(n.vx) + Math.abs(n.vy) + Math.abs(n.vz);
		}

		// Settle after enough frames with low velocity
		if (this.frameCount > 200 && totalV / Math.max(len, 1) < 0.05) {
			this.settled = true;
		}
	}

	// ── 3D → 2D projection ──

	private project() {
		const cosX = Math.cos(this.rotX), sinX = Math.sin(this.rotX);
		const cosY = Math.cos(this.rotY), sinY = Math.sin(this.rotY);
		const cx = this.width / 2, cy = this.height / 2;

		for (const n of this.nodes) {
			if (n.hidden) continue;

			// Rotate around Y axis (yaw)
			const x1 = n.x * cosY - n.z * sinY;
			const z1 = n.x * sinY + n.z * cosY;
			// Rotate around X axis (pitch)
			const y2 = n.y * cosX - z1 * sinX;
			const z2 = n.y * sinX + z1 * cosX;

			// Perspective
			const d = this.camDist + z2;
			const scale = d > 50 ? FOV / d : FOV / 50;

			n.px = cx + x1 * scale + this.camOffsetX;
			n.py = cy + y2 * scale + this.camOffsetY;
			n.pr = n.baseRadius * scale;
			n.depth = z2;
		}
	}

	// ── Rendering ──

	private draw() {
		const ctx = this.ctx;
		const w = this.width, h = this.height;
		ctx.clearRect(0, 0, w, h);

		// Background gradient
		const bg = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w * 0.7);
		bg.addColorStop(0, 'rgba(30, 30, 50, 1)');
		bg.addColorStop(1, 'rgba(10, 10, 20, 1)');
		ctx.fillStyle = bg;
		ctx.fillRect(0, 0, w, h);

		// Focus-mode helpers
		const focused = this.focusedNodeId;
		const focusedNode = focused ? this.nodeMap.get(focused) : null;
		const isFocusChild = (n: Node3D) => focused && (n.id === focused || n.parentId === focused);
		const isFocusFamily = (n: Node3D) => {
			if (!focused || !focusedNode) return false;
			return n.id === focused || n.parentId === focused || n.id === focusedNode.parentId;
		};

		// Draw edges
		for (const e of this.edges) {
			const a = this.nodeMap.get(e.from);
			const b = this.nodeMap.get(e.to);
			if (!a || !b || a.hidden || b.hidden) continue;

			const avgDepth = (a.depth + b.depth) / 2;
			const depthFade = Math.max(0.03, Math.min(0.4, 1 - (avgDepth + this.camDist) / (this.camDist * 2.5)));
			let alpha = e.style === 'faint' ? depthFade * 0.3 : e.style === 'dotted' ? depthFade * 0.5 : depthFade;

			// Dim non-focused edges
			if (focused && !isFocusFamily(a) && !isFocusFamily(b)) alpha *= 0.15;

			ctx.beginPath();
			ctx.strokeStyle = e.color;
			ctx.globalAlpha = alpha;
			ctx.lineWidth = e.style === 'solid' ? 1.2 : 0.8;
			if (e.style === 'dotted') ctx.setLineDash([4, 6]);
			else if (e.style === 'faint') ctx.setLineDash([2, 4]);
			else ctx.setLineDash([]);
			ctx.moveTo(a.px, a.py);
			ctx.lineTo(b.px, b.py);
			ctx.stroke();
			ctx.setLineDash([]);
		}
		ctx.globalAlpha = 1;

		// Draw edge particles
		for (const p of this.particles) {
			const a = this.nodeMap.get(p.edge.from);
			const b = this.nodeMap.get(p.edge.to);
			if (!a || !b || a.hidden || b.hidden) continue;
			const px = a.px + (b.px - a.px) * p.t;
			const py = a.py + (b.py - a.py) * p.t;
			const avgDepth = (a.depth + b.depth) / 2;
			let depthAlpha = Math.max(0.1, Math.min(0.7, 1 - (avgDepth + this.camDist) / (this.camDist * 2.5)));
			if (focused && !isFocusFamily(a) && !isFocusFamily(b)) depthAlpha *= 0.15;
			ctx.beginPath();
			ctx.arc(px, py, 1.5, 0, Math.PI * 2);
			ctx.fillStyle = p.edge.color;
			ctx.globalAlpha = depthAlpha;
			ctx.fill();
		}
		ctx.globalAlpha = 1;

		// Sort nodes by depth for z-ordering (far first)
		const visible = this.nodes.filter(n => !n.hidden);
		visible.sort((a, b) => b.depth - a.depth);

		// Hovered node's connected edges
		const hoveredEdges = new Set<string>();
		if (this.hoveredNode) {
			for (const e of this.edges) {
				if (e.from === this.hoveredNode.id || e.to === this.hoveredNode.id) {
					hoveredEdges.add(e.from);
					hoveredEdges.add(e.to);
				}
			}
		}

		// Draw nodes
		for (const n of visible) {
			const depthFade = Math.max(0.15, Math.min(1.0, 1 - (n.depth + this.camDist * 0.3) / (this.camDist * 1.8)));
			const isHovered = this.hoveredNode?.id === n.id;
			const isConnected = this.hoveredNode && hoveredEdges.has(n.id);
			const isSelected = this.selectedNodeId === n.id;
			const inFocus = isFocusFamily(n);
			const focusDim = focused && !inFocus ? 0.15 : 1;

			// Glow for space nodes
			if (n.type === 'space') {
				const glow = ctx.createRadialGradient(n.px, n.py, n.pr * 0.5, n.px, n.py, n.pr * 2.5);
				glow.addColorStop(0, n.color + '40');
				glow.addColorStop(1, n.color + '00');
				ctx.fillStyle = glow;
				ctx.globalAlpha = depthFade * (isHovered ? 1 : 0.6) * focusDim;
				ctx.beginPath();
				ctx.arc(n.px, n.py, n.pr * 2.5, 0, Math.PI * 2);
				ctx.fill();
			}

			// Node circle
			const fillAlpha = n.type === 'doc' ? 0.6 : n.type === 'module' ? 0.5 : 0.4;
			const nodeAlpha = depthFade * (isHovered || isSelected ? 1 : (isConnected ? 0.9 : fillAlpha)) * focusDim;
			ctx.beginPath();
			ctx.arc(n.px, n.py, n.pr, 0, Math.PI * 2);
			ctx.fillStyle = n.color;
			ctx.globalAlpha = nodeAlpha;
			ctx.fill();

			// Stroke
			ctx.strokeStyle = isHovered || isSelected ? '#ffffff' : n.color;
			ctx.lineWidth = isHovered || isSelected ? 2.5 : (n.type === 'space' ? 1.8 : 1);
			ctx.globalAlpha = depthFade * (isHovered || isSelected ? 1 : 0.8) * focusDim;
			ctx.stroke();

			ctx.globalAlpha = 1;

			// Labels
			if (n.type === 'space' && n.pr > 6) {
				const label = n.collapsed ? `${truncate(n.label, 10)} (${n.childCount})` : truncate(n.label, 14);
				ctx.font = `600 ${Math.max(9, Math.min(12, n.pr * 0.7))}px system-ui, sans-serif`;
				ctx.fillStyle = '#ffffff';
				ctx.textAlign = 'center';
				ctx.globalAlpha = depthFade * focusDim;
				ctx.fillText(label, n.px, n.py + n.pr + 14);
				ctx.globalAlpha = 1;
			} else if (n.type === 'module' && n.pr > 4) {
				ctx.font = `${Math.max(8, Math.min(14, n.pr * 1.1))}px system-ui, sans-serif`;
				ctx.textAlign = 'center';
				ctx.textBaseline = 'middle';
				ctx.globalAlpha = depthFade * focusDim;
				ctx.fillStyle = '#ffffff';
				ctx.fillText(n.icon, n.px, n.py);
				if (n.pr > 7) {
					ctx.font = `${Math.max(7, Math.min(10, n.pr * 0.6))}px system-ui, sans-serif`;
					ctx.fillStyle = 'rgba(255,255,255,0.7)';
					ctx.fillText(truncate(n.label, 12), n.px, n.py + n.pr + 10);
				}
				ctx.textBaseline = 'alphabetic';
				ctx.globalAlpha = 1;
			} else if (n.type === 'doc' && focused && isFocusChild(n)) {
				// Show doc title labels when parent module is focused
				ctx.font = `500 ${Math.max(8, Math.min(11, n.pr * 0.9))}px system-ui, sans-serif`;
				ctx.textAlign = 'center';
				ctx.fillStyle = 'rgba(255,255,255,0.85)';
				ctx.globalAlpha = depthFade;
				ctx.fillText(truncate(n.label, 18), n.px, n.py + n.pr + 12);
				ctx.globalAlpha = 1;
			}
		}

		// Draw breadcrumb bar when focused
		if (focusedNode) {
			this.drawBreadcrumb(ctx, focusedNode, w);
		} else {
			this.breadcrumbRects = [];
		}

		// Tooltip
		if (this.hoveredNode && !this.dragging) {
			this.showTooltip(this.hoveredNode);
		} else {
			this.hideTooltip();
		}
	}

	private drawBreadcrumb(ctx: CanvasRenderingContext2D, focusedNode: Node3D, w: number) {
		this.breadcrumbRects = [];
		const barH = 28;
		const barY = 8;
		const padX = 12;

		// Build segments: ☁ All > {SpaceName} > {ModuleName}
		const spaceNode = focusedNode.parentId ? this.nodeMap.get(focusedNode.parentId) : null;
		const segments = ['☁ All'];
		if (spaceNode) segments.push(spaceNode.label);
		segments.push(`${focusedNode.icon} ${focusedNode.label}`);

		// Measure total width
		ctx.font = '600 11px system-ui, sans-serif';
		const sepW = ctx.measureText(' › ').width;
		const segWidths = segments.map(s => ctx.measureText(s).width);
		const totalW = segWidths.reduce((a, b) => a + b, 0) + sepW * (segments.length - 1) + padX * 2;
		const barX = (w - totalW) / 2;

		// Background pill
		ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
		ctx.globalAlpha = 1;
		ctx.beginPath();
		const r = barH / 2;
		ctx.moveTo(barX + r, barY);
		ctx.lineTo(barX + totalW - r, barY);
		ctx.arcTo(barX + totalW, barY, barX + totalW, barY + r, r);
		ctx.arcTo(barX + totalW, barY + barH, barX + totalW - r, barY + barH, r);
		ctx.lineTo(barX + r, barY + barH);
		ctx.arcTo(barX, barY + barH, barX, barY + barH - r, r);
		ctx.arcTo(barX, barY, barX + r, barY, r);
		ctx.fill();

		// Border
		ctx.strokeStyle = 'rgba(255,255,255,0.15)';
		ctx.lineWidth = 1;
		ctx.stroke();

		// Draw text segments
		let x = barX + padX;
		const textY = barY + barH / 2 + 4;
		for (let i = 0; i < segments.length; i++) {
			const isClickable = i < segments.length - 1; // All and Space are clickable
			const isLast = i === segments.length - 1;

			ctx.fillStyle = isLast ? '#ffffff' : 'rgba(140, 180, 255, 0.9)';
			ctx.globalAlpha = 1;
			ctx.font = isLast ? '600 11px system-ui, sans-serif' : '500 11px system-ui, sans-serif';
			ctx.textAlign = 'left';
			ctx.fillText(segments[i], x, textY);

			// Store clickable rect for hit detection
			if (isClickable) {
				this.breadcrumbRects.push({
					x, y: barY, w: segWidths[i], h: barH,
					action: i === 0 ? 'all' : 'space',
				});
			}

			x += segWidths[i];
			if (i < segments.length - 1) {
				ctx.fillStyle = 'rgba(255,255,255,0.3)';
				ctx.fillText(' › ', x, textY);
				x += sepW;
			}
		}
	}

	// ── Animation loop ──

	private tick = () => {
		if (!this.isConnected) return;
		this.frameCount++;

		if (!this.settled || this.dragging) {
			this.simulate();
		}

		// Camera lerp (smooth spring animation toward targets)
		const LERP = 0.08;
		let camMoving = false;
		const lerpField = (cur: number, tgt: number): [number, boolean] => {
			const delta = tgt - cur;
			if (Math.abs(delta) < 0.01) return [tgt, false];
			return [cur + delta * LERP, true];
		};
		let v: number; let m: boolean;
		[v, m] = lerpField(this.camDist, this.targetCamDist); this.camDist = v; camMoving ||= m;
		[v, m] = lerpField(this.rotX, this.targetRotX); this.rotX = v; camMoving ||= m;
		[v, m] = lerpField(this.rotY, this.targetRotY); this.rotY = v; camMoving ||= m;
		[v, m] = lerpField(this.camOffsetX, this.targetOffsetX); this.camOffsetX = v; camMoving ||= m;
		[v, m] = lerpField(this.camOffsetY, this.targetOffsetY); this.camOffsetY = v; camMoving ||= m;
		if (camMoving) this.settled = false;

		// Animate particles
		for (const p of this.particles) {
			p.t += p.speed;
			if (p.t > 1) p.t -= 1;
		}

		this.project();
		this.draw();
		this.animFrame = requestAnimationFrame(this.tick);
	};

	// ── Interaction ──

	private attachInteraction() {
		this.canvas.addEventListener('mousedown', (e) => {
			this.dragging = true;
			this.dragMoved = false;
			this.dragStartX = e.clientX;
			this.dragStartY = e.clientY;
			this.dragRotX = this.targetRotX;
			this.dragRotY = this.targetRotY;
			this.canvas.style.cursor = 'grabbing';
		});

		window.addEventListener('mousemove', this.onMouseMove);
		window.addEventListener('mouseup', this.onMouseUp);

		this.canvas.addEventListener('wheel', (e) => {
			e.preventDefault();
			this.targetCamDist = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.targetCamDist + e.deltaY * 0.5));
			this.settled = false;
		}, { passive: false });

		this.canvas.addEventListener('mousemove', (e) => {
			if (this.dragging) return;
			const rect = this.canvas.getBoundingClientRect();
			const mx = e.clientX - rect.left;
			const my = e.clientY - rect.top;
			this.tooltipX = mx;
			this.tooltipY = my;

			let found: Node3D | null = null;
			let bestDist = Infinity;
			const visible = this.nodes.filter(n => !n.hidden);
			visible.sort((a, b) => a.depth - b.depth);
			for (const n of visible) {
				const dx = mx - n.px, dy = my - n.py;
				const dist = Math.sqrt(dx * dx + dy * dy);
				const hitR = Math.max(n.pr, 8);
				if (dist < hitR && dist < bestDist) {
					bestDist = dist;
					found = n;
				}
			}
			this.hoveredNode = found;
			this.canvas.style.cursor = found ? 'pointer' : 'grab';
		});

		// Single click — focus module, select doc, collapse space, breadcrumb, or unfocus
		this.canvas.addEventListener('click', (e) => {
			if (this.dragMoved) return;

			// Check breadcrumb hit first
			const rect = this.canvas.getBoundingClientRect();
			const mx = e.clientX - rect.left;
			const my = e.clientY - rect.top;
			for (const br of this.breadcrumbRects) {
				if (mx >= br.x && mx <= br.x + br.w && my >= br.y && my <= br.y + br.h) {
					this.unfocus();
					return;
				}
			}

			// Debounce for double-click detection
			if (this.clickTimer) { clearTimeout(this.clickTimer); this.clickTimer = null; }

			const n = this.hoveredNode;
			this.clickTimer = setTimeout(() => {
				this.clickTimer = null;
				if (!n) {
					// Click empty space → exit focus
					if (this.focusedNodeId) this.unfocus();
					return;
				}
				if (n.type === 'space') {
					this.toggleCollapse(n);
				} else if (n.type === 'module') {
					this.focusOnNode(n);
				} else if (n.type === 'doc') {
					this.selectedNodeId = this.selectedNodeId === n.id ? null : n.id;
					this.settled = false;
				}
			}, 250);
		});

		// Double click — open in new tab
		this.canvas.addEventListener('dblclick', (e) => {
			if (this.clickTimer) { clearTimeout(this.clickTimer); this.clickTimer = null; }
			const n = this.hoveredNode;
			if (!n || n.type === 'space') return;
			const space = n.space || this.space;
			const modPath = n.modId ? (n.modId.startsWith('r') ? n.modId : `r${n.modId}`) : 'rspace';
			window.open(rspaceNavUrl(space, modPath), '_blank');
		});

		// Touch support
		let touchStart: { x: number; y: number; dist: number } | null = null;
		this.canvas.addEventListener('touchstart', (e) => {
			if (e.touches.length === 1) {
				const t = e.touches[0];
				touchStart = { x: t.clientX, y: t.clientY, dist: this.camDist };
				this.dragRotX = this.targetRotX;
				this.dragRotY = this.targetRotY;
				this.dragging = true;
				this.dragMoved = false;
			} else if (e.touches.length === 2) {
				const dx = e.touches[1].clientX - e.touches[0].clientX;
				const dy = e.touches[1].clientY - e.touches[0].clientY;
				touchStart = { x: 0, y: 0, dist: Math.sqrt(dx * dx + dy * dy) };
			}
			e.preventDefault();
		}, { passive: false });

		this.canvas.addEventListener('touchmove', (e) => {
			if (!touchStart) return;
			this.dragMoved = true;
			if (e.touches.length === 1) {
				const t = e.touches[0];
				this.targetRotY = this.dragRotY + (t.clientX - touchStart.x) * 0.005;
				this.targetRotX = this.dragRotX + (t.clientY - touchStart.y) * 0.005;
				this.targetRotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.targetRotX));
				this.settled = false;
			} else if (e.touches.length === 2) {
				const dx = e.touches[1].clientX - e.touches[0].clientX;
				const dy = e.touches[1].clientY - e.touches[0].clientY;
				const pinchDist = Math.sqrt(dx * dx + dy * dy);
				const ratio = touchStart.dist / pinchDist;
				this.targetCamDist = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, 500 * ratio));
				this.settled = false;
			}
			e.preventDefault();
		}, { passive: false });

		this.canvas.addEventListener('touchend', () => {
			touchStart = null;
			this.dragging = false;
		});
	}

	private onMouseMove = (e: MouseEvent) => {
		if (!this.dragging) return;
		const dx = e.clientX - this.dragStartX;
		const dy = e.clientY - this.dragStartY;
		if (Math.abs(dx) > 3 || Math.abs(dy) > 3) this.dragMoved = true;
		this.targetRotY = this.dragRotY + dx * 0.005;
		this.targetRotX = this.dragRotX + dy * 0.005;
		this.targetRotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, this.targetRotX));
		// Snap rotation immediately during drag for responsiveness
		this.rotX = this.targetRotX;
		this.rotY = this.targetRotY;
		this.settled = false;
	};

	private onMouseUp = () => {
		if (this.dragging) {
			this.dragging = false;
			this.canvas.style.cursor = 'grab';
		}
	};

	// ── Collapse/Expand ──

	private toggleCollapse(spaceNode: Node3D) {
		spaceNode.collapsed = !spaceNode.collapsed;
		this.settled = false;
		this.frameCount = 0;

		for (const n of this.nodes) {
			if (n.type === 'module' && n.space === spaceNode.space) {
				n.hidden = spaceNode.collapsed;
				if (spaceNode.collapsed) {
					n.x = spaceNode.x + rand(-5, 5);
					n.y = spaceNode.y + rand(-5, 5);
					n.z = spaceNode.z + rand(-5, 5);
				}
			}
			if (n.type === 'doc' && n.space === spaceNode.space) {
				n.hidden = spaceNode.collapsed;
				if (spaceNode.collapsed) {
					n.x = spaceNode.x + rand(-3, 3);
					n.y = spaceNode.y + rand(-3, 3);
					n.z = spaceNode.z + rand(-3, 3);
				}
			}
		}
	}

	// ── Focus / Unfocus ──

	private focusOnNode(node: Node3D) {
		this.focusedNodeId = node.id;
		this.selectedNodeId = null;

		// Target zoom in
		this.targetCamDist = 250;

		// Compute where node projects at target cam dist + current rotation (without offsets)
		const cosX = Math.cos(this.rotX), sinX = Math.sin(this.rotX);
		const cosY = Math.cos(this.rotY), sinY = Math.sin(this.rotY);
		const x1 = node.x * cosY - node.z * sinY;
		const z1 = node.x * sinY + node.z * cosY;
		const y2 = node.y * cosX - z1 * sinX;
		const z2 = node.y * sinX + z1 * cosX;
		const d = this.targetCamDist + z2;
		const scale = d > 50 ? FOV / d : FOV / 50;
		this.targetOffsetX = -x1 * scale;
		this.targetOffsetY = -y2 * scale;

		// Enlarge focused module's doc children
		for (const n of this.nodes) {
			if (n.type === 'doc') {
				n.baseRadius = n.parentId === node.id ? 8 : 5;
			}
		}

		this.settled = false;
		this.frameCount = 0;
	}

	private unfocus() {
		this.focusedNodeId = null;
		this.selectedNodeId = null;

		// Reset camera to defaults
		this.targetCamDist = 500;
		this.targetOffsetX = 0;
		this.targetOffsetY = 0;

		// Restore doc node radii
		for (const n of this.nodes) {
			if (n.type === 'doc') n.baseRadius = 5;
		}

		this.settled = false;
		this.frameCount = 0;
	}

	// ── Tooltip ──

	private showTooltip(n: Node3D) {
		const tip = this.shadow.querySelector('.dc-tooltip') as HTMLElement;
		if (!tip) return;

		let text = '';
		if (n.type === 'space') {
			const docCount = this.nodes.filter(nd => nd.space === n.space && nd.type === 'doc').length;
			const modCount = this.nodes.filter(nd => nd.space === n.space && nd.type === 'module').length;
			text = `<b>${esc(n.label)}</b><br>${modCount} modules, ${docCount} docs<br><em>Click to ${n.collapsed ? 'expand' : 'collapse'}</em>`;
		} else if (n.type === 'module') {
			const docCount = this.nodes.filter(nd => nd.parentId === n.id && nd.type === 'doc').length;
			text = `<b>${n.icon} ${esc(n.label)}</b><br>${docCount} documents<br><em>Click to focus · Double-click to open</em>`;
		} else {
			const tags = this.docs.find(d => `doc:${d.docId}` === n.id)?.tags;
			const tagStr = tags?.length ? `<br>Tags: ${tags.map(t => esc(t)).join(', ')}` : '';
			text = `<b>${esc(n.label)}</b><br>${esc(n.modId || 'module')}${tagStr}<br><em>Click to select · Double-click to open</em>`;
		}

		tip.innerHTML = text;
		tip.style.display = 'block';

		let tx = this.tooltipX + 12;
		let ty = this.tooltipY - 10;
		if (tx + 180 > this.width) tx = this.tooltipX - 180;
		if (ty < 10) ty = 10;
		tip.style.left = `${tx}px`;
		tip.style.top = `${ty}px`;
	}

	private hideTooltip() {
		const tip = this.shadow.querySelector('.dc-tooltip') as HTMLElement;
		if (tip) tip.style.display = 'none';
	}

	// ── Legend ──

	private renderLegend() {
		const legend = this.shadow.querySelector('.dc-legend') as HTMLElement;
		if (!legend) return;

		const mods = new Map<string, { name: string; icon: string; color: string }>();
		for (const doc of this.docs) {
			if (!mods.has(doc.modId)) mods.set(doc.modId, { name: doc.modName, icon: doc.modIcon, color: modColor(doc.modId) });
		}
		const visLevels = new Set<string>();
		for (const doc of this.docs) visLevels.add(doc.visibility);

		legend.innerHTML = `
			<div class="dc-legend__section">
				<span class="dc-legend__label">Visibility:</span>
				${[...visLevels].map(v => `
					<span class="dc-legend__item">
						<span class="dc-legend__dot" style="background:${VIS_COLORS[v] || VIS_COLORS.private}"></span>
						${esc(v)}
					</span>
				`).join('')}
			</div>
			<div class="dc-legend__section">
				<span class="dc-legend__label">Modules:</span>
				${[...mods.entries()].map(([, m]) => `
					<span class="dc-legend__item">${m.icon} ${esc(m.name)}</span>
				`).join('')}
			</div>
			<div class="dc-legend__hint">Drag to orbit · Scroll to zoom · Click module to focus · Double-click to open</div>
		`;
	}
}

// ── Helpers ──

function rand(min: number, max: number): number {
	return min + Math.random() * (max - min);
}

function truncate(s: string, max: number): string {
	return s.length > max ? s.slice(0, max - 1) + '…' : s;
}

function esc(s: string): string {
	return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// ── Styles ──

function styles(): string {
	return `
		:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; }

		.dc {
			position: relative;
			max-width: 100%; margin: 0 auto;
			display: flex; flex-direction: column; align-items: center;
		}

		.dc-loading {
			text-align: center; padding: 3rem 1rem;
			color: rgba(255,255,255,0.5); font-size: 0.9rem;
		}

		.dc-canvas {
			display: block; border-radius: 12px;
			cursor: grab; max-width: 100%;
			background: #0a0a14;
		}

		.dc-tooltip {
			position: absolute; pointer-events: none;
			background: rgba(0, 0, 0, 0.85); color: #fff;
			padding: 6px 10px; border-radius: 6px;
			font-size: 0.78rem; line-height: 1.4;
			max-width: 200px; z-index: 10;
			border: 1px solid rgba(255,255,255,0.1);
			backdrop-filter: blur(4px);
		}
		.dc-tooltip b { font-weight: 600; }
		.dc-tooltip em { color: rgba(255,255,255,0.5); font-style: normal; font-size: 0.7rem; }

		.dc-legend {
			width: 100%; display: flex; flex-wrap: wrap; gap: 0.75rem;
			justify-content: center; align-items: center;
			padding: 0.75rem; margin-top: 0.5rem;
			border-top: 1px solid rgba(255,255,255,0.1);
		}
		.dc-legend__section {
			display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
		}
		.dc-legend__label {
			font-size: 0.7rem; color: rgba(255,255,255,0.4); font-weight: 600;
		}
		.dc-legend__item {
			display: flex; align-items: center; gap: 0.25rem;
			font-size: 0.73rem; color: rgba(255,255,255,0.6);
		}
		.dc-legend__dot {
			width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
		}
		.dc-legend__hint {
			width: 100%; text-align: center;
			font-size: 0.68rem; color: rgba(255,255,255,0.3); margin-top: 0.25rem;
		}

		@media (max-width: 500px) {
			.dc-legend { gap: 0.4rem; padding: 0.5rem; }
			.dc-legend__item { font-size: 0.63rem; }
			.dc-legend__hint { font-size: 0.6rem; }
		}
	`;
}

customElements.define('folk-data-cloud', FolkDataCloud);
