<script lang="ts">
	import { onDestroy } from 'svelte';
	import { getGraphData } from '$lib/api';
	import { activeNotePath, appConfig } from '$lib/stores/app';

	let { onclose, onnavigate }: {
		onclose: () => void;
		onnavigate: (path: string, title: string) => void;
	} = $props();

	const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);

	let canvas = $state<HTMLCanvasElement>(null!);
	let loading = $state(true);
	let searchQuery = $state('');
	let localMode = $state(true); // default: show only active note's neighborhood

	const dataPromise = getGraphData();

	interface GraphNode { id: string; title: string; path: string; x: number; y: number; vx: number; vy: number; }
	interface GraphEdge { sourceIdx: number; targetIdx: number; bidirectional: boolean; }
	interface LabelRect { x: number; y: number; w: number; h: number; }

	let nodes: GraphNode[] = [];
	let edges: GraphEdge[] = [];
	let nodeIndexMap: Map<string, number> = new Map();
	let connectedSet: Set<number> = new Set();
	let nodeDegree: number[] = [];
	let searchMatchSet: Set<number> | null = null;
	let folderColorMap = new Map<string, string>();

	let pan = { x: 0, y: 0 };
	let zoom = 1;
	let dragging: GraphNode | null = null;
	let dragMoved = false;
	let mouseDownPos = { x: 0, y: 0 };
	let panning = false;
	let panStart = { x: 0, y: 0 };
	let hoveredNode: GraphNode | null = null;
	let hoveredIdx = -1;
	let hoveredNeighborSet: Set<number> = new Set();
	let hoveredEdgeSet: Set<number> = new Set();
	let glowPhase = 0;
	let activeNodeIdx = -1;
	let navigatedFromGraph = false;

	// Combined animation loop
	let physicsRemaining = 0;
	let animFrameId = 0;
	let lastGlowTs = 0;

	// DPI fix
	let dpr = 1;
	let canvasW = 0;
	let canvasH = 0;

	// Style cache - cleared on theme change
	let cachedStyles: { border: string; text: string; textSec: string; accent: string; bg: string } | null = null;

	const FOLDER_PALETTE = [
		'#60a5fa', '#34d399', '#f59e0b', '#a78bfa',
		'#fb7185', '#22d3ee', '#fb923c', '#86efac',
	];

	function getStyles() {
		if (cachedStyles) return cachedStyles;
		const s = getComputedStyle(document.documentElement);
		cachedStyles = {
			border:  s.getPropertyValue('--border-color').trim() || '#444',
			text:    s.getPropertyValue('--text-primary').trim() || '#eee',
			textSec: s.getPropertyValue('--text-secondary').trim() || '#aaa',
			accent:  s.getPropertyValue('--accent').trim() || '#7b9bd4',
			bg:      s.getPropertyValue('--bg-primary').trim() || '#1a1a1a',
		};
		return cachedStyles;
	}

	function getFolderKey(path: string): string {
		// Normalize Windows backslashes so folder grouping/colours work there too.
		const p = (path || '').replace(/\\/g, '/');
		const vaultRoot = ($appConfig?.active_vault || '').replace(/\\/g, '/');
		const rel = vaultRoot && p.startsWith(vaultRoot + '/') ? p.slice(vaultRoot.length + 1) : p;
		const slash = rel.indexOf('/');
		return slash >= 0 ? rel.slice(0, slash) : '';
	}

	function buildFolderColors() {
		const seen = new Map<string, string>();
		let colorIdx = 0;
		for (const n of nodes) {
			const key = getFolderKey(n.path);
			if (key && !seen.has(key)) {
				seen.set(key, FOLDER_PALETTE[colorIdx % FOLDER_PALETTE.length]);
				colorIdx++;
			}
		}
		folderColorMap = seen;
	}

	function getNodeBaseColor(i: number): string {
		const { textSec, border } = getStyles();
		if ((nodeDegree[i] || 0) === 0) return border;
		const key = getFolderKey(nodes[i].path);
		return folderColorMap.get(key) || textSec;
	}

	function getNodeRadius(i: number, isActive: boolean): number {
		if (isActive) return 8;
		const deg = nodeDegree[i] || 0;
		if (deg === 0) return 3;
		return Math.min(4.5 + deg * 0.7, 9);
	}

	function updateHoveredState(node: GraphNode | null) {
		hoveredNode = node;
		hoveredIdx = node ? nodes.indexOf(node) : -1;
		hoveredNeighborSet = new Set();
		hoveredEdgeSet = new Set();
		if (hoveredIdx < 0) return;
		hoveredNeighborSet.add(hoveredIdx);
		for (let i = 0; i < edges.length; i++) {
			const e = edges[i];
			if (e.sourceIdx === hoveredIdx || e.targetIdx === hoveredIdx) {
				hoveredEdgeSet.add(i);
				hoveredNeighborSet.add(e.sourceIdx);
				hoveredNeighborSet.add(e.targetIdx);
			}
		}
	}

	// Nodes/edges visible in local mode (active note + direct neighbours)
	function computeLocalSets(): { nodeSet: Set<number>; edgeSet: Set<number> } {
		const nodeSet = new Set<number>();
		const edgeSet = new Set<number>();
		if (activeNodeIdx < 0) return { nodeSet, edgeSet };
		nodeSet.add(activeNodeIdx);
		for (let i = 0; i < edges.length; i++) {
			const e = edges[i];
			if (e.sourceIdx === activeNodeIdx || e.targetIdx === activeNodeIdx) {
				edgeSet.add(i);
				nodeSet.add(e.sourceIdx);
				nodeSet.add(e.targetIdx);
			}
		}
		return { nodeSet, edgeSet };
	}

	function updateSearchMatch() {
		const q = searchQuery.trim().toLowerCase();
		if (!q) { searchMatchSet = null; return; }
		const matched = new Set<number>();
		nodes.forEach((n, i) => { if (n.title.toLowerCase().includes(q)) matched.add(i); });
		searchMatchSet = matched;
	}

	async function buildGraph() {
		loading = true;
		try {
			const data = await dataPromise;
			const w = canvasW || 800;
			const h = canvasH || 600;

			nodeIndexMap = new Map();
			nodes = data.nodes.map((n, i) => {
				nodeIndexMap.set(n.title.toLowerCase(), i);
				return {
					id: n.title.toLowerCase(),
					title: n.title,
					path: n.path,
					x: w / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.5,
					y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.5,
					vx: 0, vy: 0,
				};
			});

			connectedSet = new Set();
			nodeDegree = new Array(nodes.length).fill(0);
			edges = data.edges.map(e => {
				connectedSet.add(e.source);
				connectedSet.add(e.target);
				nodeDegree[e.source] = (nodeDegree[e.source] || 0) + 1;
				nodeDegree[e.target] = (nodeDegree[e.target] || 0) + 1;
				return { sourceIdx: e.source, targetIdx: e.target, bidirectional: e.bidirectional };
			});

			buildFolderColors();
			updateSearchMatch();
			activeNodeIdx = nodes.findIndex(n => n.path === ($activeNotePath || ''));
		} catch (e) {
			console.error('Failed to build graph:', e);
		}
		loading = false;
		startSimulation();
	}

	function centerOnActiveNote() {
		if (!canvas || nodes.length === 0 || activeNodeIdx < 0) return;
		const activeNode = nodes[activeNodeIdx];
		const neighborhood: GraphNode[] = [activeNode];
		for (const edge of edges) {
			if (edge.sourceIdx === activeNodeIdx) neighborhood.push(nodes[edge.targetIdx]);
			else if (edge.targetIdx === activeNodeIdx) neighborhood.push(nodes[edge.sourceIdx]);
		}
		const w = canvasW, h = canvasH, padding = 100;
		let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
		for (const n of neighborhood) {
			if (n.x < minX) minX = n.x; if (n.y < minY) minY = n.y;
			if (n.x > maxX) maxX = n.x; if (n.y > maxY) maxY = n.y;
		}
		const graphW = maxX - minX || 1, graphH = maxY - minY || 1;
		zoom = Math.max(Math.min((w - padding * 2) / graphW, (h - padding * 2) / graphH, 2), 0.4);
		pan.x = w / 2 - ((minX + maxX) / 2) * zoom;
		pan.y = h / 2 - ((minY + maxY) / 2) * zoom;
	}

	function fitToView() {
		if (!canvas || nodes.length === 0) return;
		const w = canvasW, h = canvasH, padding = 70;
		let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
		for (const n of nodes) {
			if (n.x < minX) minX = n.x; if (n.y < minY) minY = n.y;
			if (n.x > maxX) maxX = n.x; if (n.y > maxY) maxY = n.y;
		}
		const graphW = maxX - minX || 1, graphH = maxY - minY || 1;
		zoom = Math.max(Math.min((w - padding * 2) / graphW, (h - padding * 2) / graphH, 2), 0.2);
		pan.x = w / 2 - ((minX + maxX) / 2) * zoom;
		pan.y = h / 2 - ((minY + maxY) / 2) * zoom;
	}

	function focusActive() {
		if (activeNodeIdx >= 0) centerOnActiveNote(); else fitToView();
		draw();
	}

	function startSimulation() {
		// Initial settle - enough for a rough layout, camera set after full physics
		for (let i = 0; i < 80; i++) simulate();

		// Show the full graph while physics finishes settling
		fitToView();
		draw();

		// Queue remaining physics - camera will snap to active note when done
		physicsRemaining = Math.min(400, Math.max(150, nodes.length * 3));
		startAnimLoop();
	}

	function startAnimLoop() {
		if (animFrameId) cancelAnimationFrame(animFrameId);
		lastGlowTs = 0;

		function loop(ts: number) {
			let needsDraw = false;

			// Physics batch
			if (physicsRemaining > 0) {
				const batch = Math.min(20, physicsRemaining);
				for (let i = 0; i < batch; i++) simulate();
				physicsRemaining -= batch;
				// When physics is fully settled, snap camera to active note
				if (physicsRemaining === 0) {
					if (activeNodeIdx >= 0) centerOnActiveNote(); else fitToView();
				}
				needsDraw = true;
			}

			// Glow pulse for active node - throttled to ~30fps
			if (activeNodeIdx >= 0) {
				const elapsed = lastGlowTs > 0 ? Math.min(ts - lastGlowTs, 100) : 16.67;
				if (lastGlowTs === 0 || ts - lastGlowTs >= 33) {
					glowPhase += 0.04 * (elapsed / 16.67);
					lastGlowTs = ts;
					needsDraw = true;
				}
			}

			if (needsDraw) draw();

			if (physicsRemaining > 0 || activeNodeIdx >= 0) {
				animFrameId = requestAnimationFrame(loop);
			} else {
				animFrameId = 0;
			}
		}
		animFrameId = requestAnimationFrame(loop);
	}

	function simulate() {
		const nodeCount = nodes.length;
		if (nodeCount === 0) return;
		const w = canvasW || 800, h = canvasH || 600;
		const centerX = w / 2, centerY = h / 2;

		// Repulsion
		for (let i = 0; i < nodeCount; i++) {
			const a = nodes[i];
			for (let j = i + 1; j < nodeCount; j++) {
				const b = nodes[j];
				const dx = b.x - a.x, dy = b.y - a.y;
				const distSq = dx * dx + dy * dy;
				if (distSq > 360000) continue;
				const d = distSq || 1;
				const force = 1500 / d;
				const dist = Math.sqrt(d);
				const fx = (dx / dist) * force, fy = (dy / dist) * force;
				a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy;
			}
		}

		// Spring attraction along edges
		for (const edge of edges) {
			const a = nodes[edge.sourceIdx], b = nodes[edge.targetIdx];
			const dx = b.x - a.x, dy = b.y - a.y;
			const dist = Math.sqrt(dx * dx + dy * dy) || 1;
			const force = (dist - 130) * 0.012;
			const fx = (dx / dist) * force, fy = (dy / dist) * force;
			a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
		}

		// Center gravity - weaker for isolated nodes so they spread instead of clustering
		for (let i = 0; i < nodeCount; i++) {
			const node = nodes[i];
			const gravity = (nodeDegree[i] || 0) === 0 ? 0.0002 : 0.0008;
			node.vx += (centerX - node.x) * gravity;
			node.vy += (centerY - node.y) * gravity;
		}

		// Damping + integration
		for (const node of nodes) {
			if (node === dragging) continue;
			node.vx *= 0.85; node.vy *= 0.85;
			node.x += node.vx; node.y += node.vy;
		}
	}

	function draw() {
		if (!canvas) return;
		const ctx = canvas.getContext('2d');
		if (!ctx) return;

		ctx.clearRect(0, 0, canvas.width, canvas.height);
		ctx.save();
		ctx.scale(dpr, dpr);
		ctx.translate(pan.x, pan.y);
		ctx.scale(zoom, zoom);

		const { border: borderColor, text: textColor, textSec, accent, bg: bgColor } = getStyles();
		const activePath = $activeNotePath || '';
		const pulse = 0.5 + 0.5 * Math.sin(glowPhase);
		const isHovering = hoveredIdx >= 0;
		const isSearching = searchMatchSet !== null;

		// Local mode: restrict which nodes/edges are visible
		const { nodeSet: localNodeSet, edgeSet: localEdgeSet } = localMode ? computeLocalSets() : { nodeSet: null as Set<number> | null, edgeSet: null as Set<number> | null };

		// ── Edges ──────────────────────────────────────────────────────────────
		if (isHovering) {
			// Dimmed non-connected edges
			ctx.globalAlpha = 0.06;
			ctx.strokeStyle = borderColor;
			ctx.lineWidth = 1;
			ctx.beginPath();
			for (let i = 0; i < edges.length; i++) {
				if (hoveredEdgeSet.has(i)) continue;
				if (localEdgeSet && !localEdgeSet.has(i)) continue;
				ctx.moveTo(nodes[edges[i].sourceIdx].x, nodes[edges[i].sourceIdx].y);
				ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
			}
			ctx.stroke();

			// Highlighted connected edges
			ctx.globalAlpha = 0.8;
			ctx.strokeStyle = accent;
			ctx.lineWidth = 1.5;
			ctx.beginPath();
			for (const i of hoveredEdgeSet) {
				ctx.moveTo(nodes[edges[i].sourceIdx].x, nodes[edges[i].sourceIdx].y);
				ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
			}
			ctx.stroke();

			// Arrowheads on highlighted edges
			ctx.fillStyle = accent;
			ctx.globalAlpha = 0.8;
			const drawArrow = (tipX: number, tipY: number, ux: number, uy: number) => {
				const bx = tipX - ux * 7, by = tipY - uy * 7;
				ctx.beginPath();
				ctx.moveTo(tipX, tipY);
				ctx.lineTo(bx - uy * 3.5, by + ux * 3.5);
				ctx.lineTo(bx + uy * 3.5, by - ux * 3.5);
				ctx.closePath();
				ctx.fill();
			};
			for (const i of hoveredEdgeSet) {
				const e = edges[i];
				const src = nodes[e.sourceIdx];
				const tgt = nodes[e.targetIdx];
				const dx = tgt.x - src.x, dy = tgt.y - src.y;
				const dist = Math.sqrt(dx * dx + dy * dy);
				if (dist < 1) continue;
				const ux = dx / dist, uy = dy / dist;
				// Arrow at target end (source → target)
				const tgtR = getNodeRadius(e.targetIdx, tgt.path === activePath);
				drawArrow(tgt.x - ux * tgtR, tgt.y - uy * tgtR, ux, uy);
				// If bidirectional, also draw arrow at source end (target → source)
				if (e.bidirectional) {
					const srcR = getNodeRadius(e.sourceIdx, src.path === activePath);
					drawArrow(src.x + ux * srcR, src.y + uy * srcR, -ux, -uy);
				}
			}
		} else if (isSearching) {
			// All visible edges faint
			ctx.strokeStyle = borderColor;
			ctx.lineWidth = 1;
			ctx.globalAlpha = 0.07;
			ctx.beginPath();
			for (let i = 0; i < edges.length; i++) {
				if (localEdgeSet && !localEdgeSet.has(i)) continue;
				ctx.moveTo(nodes[edges[i].sourceIdx].x, nodes[edges[i].sourceIdx].y);
				ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
			}
			ctx.stroke();
			ctx.globalAlpha = 0.4;
			ctx.beginPath();
			for (let i = 0; i < edges.length; i++) {
				if (localEdgeSet && !localEdgeSet.has(i)) continue;
				const e = edges[i];
				if (searchMatchSet!.has(e.sourceIdx) || searchMatchSet!.has(e.targetIdx)) {
					ctx.moveTo(nodes[e.sourceIdx].x, nodes[e.sourceIdx].y);
					ctx.lineTo(nodes[e.targetIdx].x, nodes[e.targetIdx].y);
				}
			}
			ctx.stroke();
		} else {
			ctx.strokeStyle = borderColor;
			ctx.lineWidth = isMobile ? 1.5 : 1;
			ctx.globalAlpha = isMobile ? 0.65 : 0.35;
			ctx.beginPath();
			for (let i = 0; i < edges.length; i++) {
				if (localEdgeSet && !localEdgeSet.has(i)) continue;
				ctx.moveTo(nodes[edges[i].sourceIdx].x, nodes[edges[i].sourceIdx].y);
				ctx.lineTo(nodes[edges[i].targetIdx].x, nodes[edges[i].targetIdx].y);
			}
			ctx.stroke();

			// On mobile draw arrowheads always (no hover available)
			// Sizes divided by zoom so they stay fixed screen size regardless of zoom level
			if (isMobile) {
				ctx.fillStyle = borderColor;
				ctx.globalAlpha = 0.6;
				const al = 8 / zoom, aw = 4 / zoom;
				const drawArrow = (tipX: number, tipY: number, ux: number, uy: number) => {
					const bx = tipX - ux * al, by = tipY - uy * al;
					ctx.beginPath();
					ctx.moveTo(tipX, tipY);
					ctx.lineTo(bx - uy * aw, by + ux * aw);
					ctx.lineTo(bx + uy * aw, by - ux * aw);
					ctx.closePath();
					ctx.fill();
				};
				for (let i = 0; i < edges.length; i++) {
					if (localEdgeSet && !localEdgeSet.has(i)) continue;
					const e = edges[i];
					const src = nodes[e.sourceIdx], tgt = nodes[e.targetIdx];
					const dx = tgt.x - src.x, dy = tgt.y - src.y;
					const dist = Math.sqrt(dx * dx + dy * dy);
					if (dist < 1) continue;
					const ux = dx / dist, uy = dy / dist;
					const tgtR = getNodeRadius(e.targetIdx, tgt.path === activePath);
					drawArrow(tgt.x - ux * tgtR, tgt.y - uy * tgtR, ux, uy);
					if (e.bidirectional) {
						const srcR = getNodeRadius(e.sourceIdx, src.path === activePath);
						drawArrow(src.x + ux * srcR, src.y + uy * srcR, -ux, -uy);
					}
				}
			}
		}
		ctx.globalAlpha = 1;

		// ── Nodes + label collection ────────────────────────────────────────────
		type LabelSpec = { x: number; y: number; text: string; fontSize: number; weight: string; color: string; alpha: number; priority: number; };
		const labelQueue: LabelSpec[] = [];

		for (let i = 0; i < nodes.length; i++) {
			// Skip nodes outside local view
			if (localNodeSet && !localNodeSet.has(i)) continue;

			const node = nodes[i];
			const isActive = node.path === activePath;
			const isHovered = node === hoveredNode;
			const isNeighbor = hoveredNeighborSet.has(i);
			const hasLinks = connectedSet.has(i);
			const matchesSearch = !isSearching || searchMatchSet!.has(i);

			const dimBySearch = isSearching && !matchesSearch;
			const dimByHover = isHovering && !isActive && !isNeighbor;
			const dimmed = dimBySearch || dimByHover;

			const radius = getNodeRadius(i, isActive);

			// Active glow ring - opacity pulse, no radius change
			if (isActive) {
				const glowR = radius + 12;
				const glow = ctx.createRadialGradient(node.x, node.y, radius, node.x, node.y, glowR);
				const glowAlpha = Math.round((0.25 + pulse * 0.45) * 255).toString(16).padStart(2, '0');
				glow.addColorStop(0, accent + glowAlpha);
				glow.addColorStop(1, accent + '00');
				ctx.globalAlpha = 1;
				ctx.beginPath();
				ctx.arc(node.x, node.y, glowR, 0, Math.PI * 2);
				ctx.fillStyle = glow;
				ctx.fill();
			}

			// Node fill
			ctx.globalAlpha = dimmed ? (dimBySearch ? 0.07 : 0.12) : 1;
			ctx.beginPath();
			ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
			if (isActive || isHovered) {
				ctx.fillStyle = accent;
			} else if (isHovering && isNeighbor) {
				ctx.fillStyle = accent + 'bb';
			} else {
				ctx.fillStyle = getNodeBaseColor(i);
			}
			ctx.fill();

			// Active outer ring - static, alpha pulse only
			if (isActive) {
				ctx.globalAlpha = 0.35 + pulse * 0.3;
				ctx.beginPath();
				ctx.arc(node.x, node.y, radius + 3, 0, Math.PI * 2);
				ctx.strokeStyle = accent;
				ctx.lineWidth = 1.5;
				ctx.stroke();
			}

			ctx.globalAlpha = 1;

			// Queue label (skip for heavily dimmed nodes)
			if (dimBySearch) continue;
			const showLabel = isActive || isHovered ||
				(matchesSearch && isSearching) ||
				(hasLinks && zoom > 0.45);
			if (!showLabel) continue;

			const raw = node.title;
			const text = raw.length > 24 ? raw.slice(0, 22) + '…' : raw;
			const fontSize = isActive ? 12 : isHovered ? 11 : 10;
			const weight = isActive ? '600' : '400';
			const color = isActive || isHovered ? textColor : textSec;
			const alpha = dimByHover ? 0 : 1;
			const priority = isActive ? 0 : isHovered ? 1 : (matchesSearch && isSearching ? 2 : 3);

			labelQueue.push({ x: node.x, y: node.y - radius - 5, text, fontSize, weight, color, alpha, priority });
		}

		// Draw labels with collision avoidance - high-priority labels drawn first
		labelQueue.sort((a, b) => a.priority - b.priority);
		const drawnRects: LabelRect[] = [];
		ctx.textAlign = 'center';
		ctx.textBaseline = 'bottom';

		for (const lab of labelQueue) {
			if (lab.alpha === 0) continue;
			ctx.font = `${lab.weight} ${lab.fontSize}px -apple-system, BlinkMacSystemFont, sans-serif`;
			const tw = ctx.measureText(lab.text).width;
			const th = lab.fontSize + 2;
			const rx = lab.x - tw / 2, ry = lab.y - th;
			const overlaps = drawnRects.some(r =>
				rx < r.x + r.w + 6 && rx + tw > r.x - 6 &&
				ry < r.y + r.h + 3 && ry + th > r.y - 3
			);
			if (!overlaps) {
				drawnRects.push({ x: rx, y: ry, w: tw, h: th });
				ctx.globalAlpha = lab.alpha;
				ctx.fillStyle = lab.color;
				ctx.shadowColor = bgColor;
				ctx.shadowBlur = 5 / zoom;
				ctx.fillText(lab.text, lab.x, lab.y);
			}
		}
		ctx.shadowBlur = 0;
		ctx.globalAlpha = 1;
		ctx.textBaseline = 'alphabetic';

		ctx.restore();
	}

	function getNodeAt(clientX: number, clientY: number): GraphNode | null {
		if (!canvas) return null;
		const rect = canvas.getBoundingClientRect();
		const x = (clientX - rect.left - pan.x) / zoom;
		const y = (clientY - rect.top - pan.y) / zoom;
		for (let i = nodes.length - 1; i >= 0; i--) {
			const n = nodes[i];
			const dx = n.x - x, dy = n.y - y;
			const r = getNodeRadius(i, n.path === ($activeNotePath || '')) + 4;
			if (dx * dx + dy * dy < r * r) return n;
		}
		return null;
	}

	function handleMouseDown(e: MouseEvent) {
		mouseDownPos = { x: e.clientX, y: e.clientY };
		dragMoved = false;
		const node = getNodeAt(e.clientX, e.clientY);
		if (node) { dragging = node; }
		else { panning = true; panStart = { x: e.clientX - pan.x, y: e.clientY - pan.y }; }
	}

	function handleMouseMove(e: MouseEvent) {
		const dx = e.clientX - mouseDownPos.x, dy = e.clientY - mouseDownPos.y;
		if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;

		if (dragging && dragMoved) {
			const rect = canvas.getBoundingClientRect();
			dragging.x = (e.clientX - rect.left - pan.x) / zoom;
			dragging.y = (e.clientY - rect.top - pan.y) / zoom;
			dragging.vx = 0; dragging.vy = 0;
			draw();
		} else if (panning) {
			pan.x = e.clientX - panStart.x;
			pan.y = e.clientY - panStart.y;
			draw();
		} else if (!dragging) {
			const node = getNodeAt(e.clientX, e.clientY);
			if (node !== hoveredNode) {
				updateHoveredState(node);
				if (canvas) canvas.style.cursor = node ? 'pointer' : 'grab';
				// Restart loop if it stopped (for immediate hover redraws)
				if (!animFrameId) draw();
			}
		}
	}

	function handleMouseUp(e: MouseEvent) {
		if (dragging && !dragMoved) {
			const node = dragging;
			dragging = null;
			navigatedFromGraph = true;
			onnavigate(node.path, node.title);
			return;
		}
		dragging = null;
		panning = false;
	}

	function handleWheel(e: WheelEvent) {
		e.preventDefault();
		const rect = canvas.getBoundingClientRect();
		const mx = e.clientX - rect.left, my = e.clientY - rect.top;
		const oldZoom = zoom;
		zoom = Math.max(0.2, Math.min(5, zoom * (e.deltaY > 0 ? 0.9 : 1.1)));
		pan.x = mx - (mx - pan.x) * (zoom / oldZoom);
		pan.y = my - (my - pan.y) * (zoom / oldZoom);
		draw();
	}

	function handleKeydown(e: KeyboardEvent) {
		if (e.key === 'Escape') {
			if (searchQuery) { searchQuery = ''; return; }
			onclose();
		}
	}

	let lastTouchDist = 0;

	function handleTouchStart(e: TouchEvent) {
		if (e.touches.length === 1) {
			const t = e.touches[0];
			mouseDownPos = { x: t.clientX, y: t.clientY };
			dragMoved = false;
			const node = getNodeAt(t.clientX, t.clientY);
			if (node) { dragging = node; }
			else { panning = true; panStart = { x: t.clientX - pan.x, y: t.clientY - pan.y }; }
		} else if (e.touches.length === 2) {
			dragging = null; panning = false;
			const dx = e.touches[0].clientX - e.touches[1].clientX;
			const dy = e.touches[0].clientY - e.touches[1].clientY;
			lastTouchDist = Math.sqrt(dx * dx + dy * dy);
		}
	}

	function handleTouchMove(e: TouchEvent) {
		e.preventDefault();
		if (e.touches.length === 1) {
			const t = e.touches[0];
			const dx = t.clientX - mouseDownPos.x, dy = t.clientY - mouseDownPos.y;
			if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
			if (dragging && dragMoved) {
				const rect = canvas.getBoundingClientRect();
				dragging.x = (t.clientX - rect.left - pan.x) / zoom;
				dragging.y = (t.clientY - rect.top - pan.y) / zoom;
				dragging.vx = 0; dragging.vy = 0;
				draw();
			} else if (panning) {
				pan.x = t.clientX - panStart.x;
				pan.y = t.clientY - panStart.y;
				draw();
			}
		} else if (e.touches.length === 2) {
			const dx = e.touches[0].clientX - e.touches[1].clientX;
			const dy = e.touches[0].clientY - e.touches[1].clientY;
			const dist = Math.sqrt(dx * dx + dy * dy);
			if (lastTouchDist > 0) {
				const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
				const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
				const rect = canvas.getBoundingClientRect();
				const mx = midX - rect.left, my = midY - rect.top;
				const oldZoom = zoom;
				zoom = Math.max(0.2, Math.min(5, zoom * (dist / lastTouchDist)));
				pan.x = mx - (mx - pan.x) * (zoom / oldZoom);
				pan.y = my - (my - pan.y) * (zoom / oldZoom);
				draw();
			}
			lastTouchDist = dist;
		}
	}

	function handleTouchEnd(e: TouchEvent) {
		if (e.touches.length === 0) {
			if (dragging && !dragMoved) {
				const node = dragging;
				dragging = null;
				navigatedFromGraph = true;
				onnavigate(node.path, node.title);
				return;
			}
			dragging = null; panning = false; lastTouchDist = 0;
		}
	}

	// Canvas setup
	$effect(() => {
		if (canvas) {
			const rect = canvas.parentElement?.getBoundingClientRect();
			if (rect) {
				dpr = window.devicePixelRatio || 1;
				canvasW = rect.width;
				canvasH = rect.height;
				canvas.width = canvasW * dpr;
				canvas.height = canvasH * dpr;
			}
			buildGraph();
		}
	});

	// React to active note changes (e.g., user clicks node → navigates → re-center)
	$effect(() => {
		const ap = $activeNotePath;
		const newIdx = nodes.findIndex(n => n.path === (ap || ''));
		activeNodeIdx = newIdx;
		if (navigatedFromGraph && newIdx >= 0 && canvas) {
			navigatedFromGraph = false;
			centerOnActiveNote();
		}
		if (newIdx >= 0 && !animFrameId && canvas) startAnimLoop();
		else if (newIdx < 0 && !animFrameId && canvas) draw(); // remove stale glow immediately
	});

	// React to search query changes
	$effect(() => {
		const _q = searchQuery; // track
		updateSearchMatch();
		if (canvas && !animFrameId) draw();
		if (activeNodeIdx >= 0 && !animFrameId && canvas) startAnimLoop();
	});

	onDestroy(() => {
		if (animFrameId) cancelAnimationFrame(animFrameId);
	});
</script>

<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="graph-overlay" onkeydown={handleKeydown}>
	<div class="graph-panel" class:mobile={isMobile}>
		<div class="graph-header">
			<h3>Graph View</h3>
			<div class="graph-search">
				<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
					<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
				</svg>
				<!-- svelte-ignore a11y_autofocus -->
				<input
					type="text"
					bind:value={searchQuery}
					placeholder="Filter notes..."
					class:active={!!searchQuery}
				/>
				{#if searchQuery}
					<button class="graph-search-clear" aria-label="Clear search" onclick={() => { searchQuery = ''; }}>
						<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
							<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
						</svg>
					</button>
				{/if}
			</div>
			<div class="graph-stats">
				{#if !loading}
					{#if localMode && activeNodeIdx >= 0}
						{computeLocalSets().nodeSet.size} notes · {computeLocalSets().edgeSet.size} links
					{:else}
						{nodes.length} notes · {edges.length} links
					{/if}
					{#if searchMatchSet}
						· <span class="search-count">{searchMatchSet.size} matched</span>
					{/if}
				{/if}
			</div>
			<button
				class="graph-btn"
				class:active={localMode}
				title={localMode ? 'Show all notes' : 'Show local graph'}
				onclick={() => { localMode = !localMode; focusActive(); }}
			>
				<!-- network/local icon -->
				<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
					<circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/>
					<line x1="12" y1="7" x2="5" y2="17"/><line x1="12" y1="7" x2="19" y2="17"/>
				</svg>
			</button>
			<button class="graph-btn" onclick={focusActive} title="Focus active note">
				<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
					<circle cx="12" cy="12" r="3"/>
					<line x1="12" y1="2" x2="12" y2="6"/>
					<line x1="12" y1="18" x2="12" y2="22"/>
					<line x1="2" y1="12" x2="6" y2="12"/>
					<line x1="18" y1="12" x2="22" y2="12"/>
				</svg>
			</button>
			<button class="graph-close" aria-label="Close graph" onclick={onclose}>
				<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
					<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
				</svg>
			</button>
		</div>
		<div class="graph-body">
			{#if loading}
				<div class="graph-loading">
					<svg class="spinner" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
						<circle cx="12" cy="12" r="10" opacity="0.25"/>
						<path d="M12 2a10 10 0 019.95 9"/>
					</svg>
					Building graph...
				</div>
			{/if}
			<!-- svelte-ignore a11y_no_static_element_interactions -->
			<canvas
				bind:this={canvas}
				class="graph-canvas"
				onmousedown={handleMouseDown}
				onmousemove={handleMouseMove}
				onmouseup={handleMouseUp}
				onwheel={handleWheel}
				ontouchstart={handleTouchStart}
				ontouchmove={handleTouchMove}
				ontouchend={handleTouchEnd}
			></canvas>
		</div>
	</div>
</div>

<style>
	.graph-overlay {
		position: fixed;
		inset: 0;
		background: rgba(0, 0, 0, 0.5);
		display: flex;
		align-items: center;
		justify-content: center;
		z-index: 2000;
	}

	.graph-panel {
		background: var(--bg-primary);
		border: 1px solid var(--border-color);
		border-radius: 16px;
		box-shadow: var(--shadow-lg);
		width: 85vw;
		height: 75vh;
		max-width: 1200px;
		max-height: 800px;
		display: flex;
		flex-direction: column;
		overflow: hidden;
	}

	.graph-header {
		display: flex;
		align-items: center;
		gap: 10px;
		padding: 12px 16px;
		border-bottom: 1px solid var(--border-light);
		flex-shrink: 0;
	}

	.graph-panel.mobile {
		width: 100vw;
		height: 100vh;
		max-width: none;
		max-height: none;
		border-radius: 0;
		border: none;
	}

	.graph-panel.mobile .graph-header {
		padding-top: calc(env(safe-area-inset-top, 36px) + 12px);
	}

	.graph-panel.mobile .graph-header h3 {
		display: none;
	}

	.graph-panel.mobile .graph-stats {
		display: none;
	}

	.graph-panel.mobile .graph-search {
		max-width: none;
	}

	.graph-header h3 {
		font-size: 14px;
		font-weight: 600;
		color: var(--text-primary);
		margin: 0;
		white-space: nowrap;
	}

	.graph-search {
		display: flex;
		align-items: center;
		gap: 6px;
		flex: 1;
		max-width: 260px;
		background: var(--bg-secondary);
		border: 1px solid var(--border-light);
		border-radius: 8px;
		padding: 4px 8px;
		color: var(--text-tertiary);
		transition: border-color 0.15s;
	}

	.graph-search:focus-within {
		border-color: var(--accent);
		color: var(--text-secondary);
	}

	.graph-search input {
		background: none;
		border: none;
		outline: none;
		font-size: 12px;
		color: var(--text-primary);
		flex: 1;
		min-width: 0;
	}

	.graph-search input::placeholder {
		color: var(--text-tertiary);
	}

	.graph-search-clear {
		background: none;
		border: none;
		padding: 0;
		cursor: pointer;
		color: var(--text-tertiary);
		display: flex;
		align-items: center;
		flex-shrink: 0;
	}

	.graph-search-clear:hover {
		color: var(--text-primary);
	}

	.graph-stats {
		flex: 1;
		font-size: 11px;
		color: var(--text-tertiary);
		white-space: nowrap;
	}

	.search-count {
		color: var(--accent);
		font-weight: 500;
	}

	.graph-btn,
	.graph-close {
		background: none;
		border: none;
		color: var(--text-tertiary);
		cursor: pointer;
		padding: 4px;
		border-radius: 6px;
		display: flex;
		align-items: center;
		flex-shrink: 0;
	}

	.graph-btn.active {
		color: var(--accent);
		background: var(--accent-light);
	}

	.graph-btn:hover,
	.graph-close:hover {
		background: var(--bg-hover);
		color: var(--text-primary);
	}

	.graph-body {
		flex: 1;
		position: relative;
		overflow: hidden;
	}

	.graph-canvas {
		width: 100%;
		height: 100%;
		cursor: grab;
		touch-action: none;
	}

	.graph-canvas:active {
		cursor: grabbing;
	}

	.graph-loading {
		position: absolute;
		inset: 0;
		display: flex;
		align-items: center;
		justify-content: center;
		gap: 10px;
		font-size: 13px;
		color: var(--text-tertiary);
		z-index: 1;
	}

	.spinner {
		animation: spin 1s linear infinite;
	}

	@keyframes spin {
		to { transform: rotate(360deg); }
	}
</style>
