/**
 * rSpace Unified Server
 *
 * Hono-based HTTP router + Bun WebSocket handler.
 * Mounts module routes under /:space/:moduleId.
 * Preserves backward-compatible subdomain routing and /api/communities/* API.
 */

import { resolve } from "node:path";
import { Hono } from "hono";
import { cors } from "hono/cors";
import type { ServerWebSocket } from "bun";
import {
	addShapes,
	clearShapes,
	communityExists,
	createCommunity,
	forgetShape,
	rememberShape,
	generateSyncMessageForPeer,
	getDocumentData,
	loadCommunity,
	receiveSyncMessage,
	removePeerSyncState,
	updateShape,
	updateShapeFields,
	cascadePermissions,
	listCommunities,
	deleteCommunity,
} from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
// Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts
import type { SpaceVisibility } from "./community-store";
import {
	verifyEncryptIDToken,
	evaluateSpaceAccess,
	extractToken,
	authenticateWSUpgrade,
} from "@encryptid/sdk/server";
import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server";

// ── Module system ──
import { registerModule, getAllModules, getModuleInfoList } from "../shared/module";
import { canvasModule } from "../modules/rspace/mod";
import { booksModule } from "../modules/rbooks/mod";
import { pubsModule } from "../modules/rpubs/mod";
import { cartModule } from "../modules/rcart/mod";
import { swagModule } from "../modules/rswag/mod";
import { choicesModule } from "../modules/rchoices/mod";
import { fundsModule } from "../modules/rfunds/mod";
import { filesModule } from "../modules/rfiles/mod";
import { forumModule } from "../modules/rforum/mod";
import { walletModule } from "../modules/rwallet/mod";
import { voteModule } from "../modules/rvote/mod";
import { notesModule } from "../modules/rnotes/mod";
import { mapsModule } from "../modules/rmaps/mod";
import { workModule } from "../modules/rwork/mod";
import { tripsModule } from "../modules/rtrips/mod";
import { calModule } from "../modules/rcal/mod";
import { networkModule } from "../modules/rnetwork/mod";
import { tubeModule } from "../modules/rtube/mod";
import { inboxModule } from "../modules/rinbox/mod";
import { dataModule } from "../modules/rdata/mod";
import { splatModule } from "../modules/rsplat/mod";
import { photosModule } from "../modules/rphotos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { spaces, createSpace } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { fetchLandingPage } from "./landing-proxy";
import { syncServer } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";

// Register modules
registerModule(canvasModule);
registerModule(booksModule);
registerModule(pubsModule);
registerModule(cartModule);
registerModule(swagModule);
registerModule(choicesModule);
registerModule(fundsModule);
registerModule(filesModule);
registerModule(forumModule);
registerModule(walletModule);
registerModule(voteModule);
registerModule(notesModule);
registerModule(mapsModule);
registerModule(workModule);
registerModule(tripsModule);
registerModule(calModule);
registerModule(networkModule);
registerModule(tubeModule);
registerModule(inboxModule);
registerModule(dataModule);
registerModule(splatModule);
registerModule(photosModule);
registerModule(socialsModule);
registerModule(docsModule);
registerModule(designModule);

// ── Config ──
const PORT = Number(process.env.PORT) || 3000;
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || "";
const DIST_DIR = resolve(import.meta.dir, "../dist");


// ── Hono app ──
const app = new Hono();

// CORS for API routes
app.use("/api/*", cors());

// ── .well-known/webauthn (WebAuthn Related Origins) ──
app.get("/.well-known/webauthn", (c) => {
	return c.json(
		{
			origins: [
				"https://rwallet.online",
				"https://rvote.online",
				"https://rmaps.online",
				"https://rfiles.online",
				"https://rnotes.online",
			],
		},
		200,
		{
			"Access-Control-Allow-Origin": "*",
			"Cache-Control": "public, max-age=3600",
		}
	);
});

// ── Space registry API ──
app.route("/api/spaces", spaces);

// ── mi — AI assistant endpoint ──
const MI_MODEL = process.env.MI_MODEL || "llama3.2";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";

app.post("/api/mi/ask", async (c) => {
	const { query, messages = [], space, module: currentModule, context = {} } = await c.req.json();
	if (!query) return c.json({ error: "Query required" }, 400);

	// Build rApp context for the system prompt
	const moduleList = getModuleInfoList()
		.map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`)
		.join("\n");

	// Build extended context section from client-provided context
	let contextSection = `- Space: ${space || "none selected"}\n- Active rApp: ${currentModule || "none"}`;
	if (context.pageTitle) contextSection += `\n- Page: ${context.pageTitle}`;
	if (context.activeTab) contextSection += `\n- Active tab: ${context.activeTab}`;
	if (context.openShapes?.length) {
		const shapeSummary = context.openShapes
			.slice(0, 15)
			.map((s: any) => {
				let desc = `  - ${s.type} (id: ${s.id})`;
				if (s.title) desc += `: ${s.title}`;
				if (s.snippet) desc += ` — "${s.snippet}"`;
				if (s.x != null) desc += ` at (${s.x}, ${s.y})`;
				return desc;
			})
			.join("\n");
		contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`;
	}
	if (context.selectedShapes?.length) {
		const selSummary = context.selectedShapes
			.map((s: any) => `  - ${s.type} (id: ${s.id})${s.title ? `: ${s.title}` : ""}${s.snippet ? ` — "${s.snippet}"` : ""}`)
			.join("\n");
		contextSection += `\n- The user currently has selected:\n${selSummary}`;
	}
	if (context.connections?.length) {
		const connSummary = context.connections
			.slice(0, 15)
			.map((c: any) => `  - ${c.sourceId} → ${c.targetId}`)
			.join("\n");
		contextSection += `\n- Connected shapes:\n${connSummary}`;
	}
	if (context.viewport) {
		contextSection += `\n- Viewport: zoom ${context.viewport.scale?.toFixed?.(2) || context.viewport.scale}, pan (${Math.round(context.viewport.x)}, ${Math.round(context.viewport.y)})`;
	}
	if (context.shapeGroups?.length) {
		contextSection += `\n- ${context.shapeGroups.length} group(s) of connected shapes`;
	}
	if (context.shapeCountByType && Object.keys(context.shapeCountByType).length) {
		const typeCounts = Object.entries(context.shapeCountByType)
			.map(([t, n]) => `${t}: ${n}`)
			.join(", ");
		contextSection += `\n- Shape types: ${typeCounts}`;
	}

	const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform.
You help users navigate, understand, and get the most out of the platform's apps (rApps).
You understand the full context of what the user has open and can guide them through setup and usage.

## Available rApps
${moduleList}

## Current Context
${contextSection}

## Guidelines
- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail.
- When suggesting actions, reference specific rApps by name and explain how they connect.
- You can suggest navigating to /:space/:moduleId paths.
- If the user has shapes open on their canvas, you can reference them by id and suggest connections.
- Help with setup: guide users through creating spaces, adding content, configuring rApps.
- If you don't know something specific about the user's data, say so honestly.
- Use a warm, knowledgeable tone. You're a mycelial guide, connecting knowledge across the platform.

## Actions
When the user asks you to create, modify, delete, connect, move, or arrange shapes on the canvas,
include action markers in your response. Each marker is on its own line:
[MI_ACTION:{"type":"create-shape","tagName":"folk-markdown","props":{"content":"# Hello"},"ref":"$1"}]
[MI_ACTION:{"type":"connect","sourceId":"$1","targetId":"shape-123"}]
[MI_ACTION:{"type":"update-shape","shapeId":"shape-123","fields":{"content":"Updated text"}}]
[MI_ACTION:{"type":"delete-shape","shapeId":"shape-123"}]
[MI_ACTION:{"type":"move-shape","shapeId":"shape-123","x":400,"y":200}]
[MI_ACTION:{"type":"navigate","path":"/myspace/canvas"}]

Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions.
Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt,
folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block,
folk-social-post, folk-splat, folk-drawfast, folk-rapp, folk-feed.

## Transforms
When the user asks to align, distribute, or arrange selected shapes:
[MI_ACTION:{"type":"transform","transform":"align-left","shapeIds":["shape-1","shape-2"]}]
Available transforms: align-left, align-right, align-center-h, align-top, align-bottom, align-center-v,
distribute-h, distribute-v, arrange-row, arrange-column, arrange-grid, arrange-circle,
match-width, match-height, match-size.`;

	// Build conversation for Ollama
	const ollamaMessages = [
		{ role: "system", content: systemPrompt },
		...messages.slice(-8).map((m: any) => ({ role: m.role, content: m.content })),
		{ role: "user", content: query },
	];

	try {
		const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ model: MI_MODEL, messages: ollamaMessages, stream: true }),
		});

		if (!ollamaRes.ok) {
			const errText = await ollamaRes.text().catch(() => "");
			console.error("mi: Ollama error:", ollamaRes.status, errText);
			return c.json({ error: "AI service unavailable" }, 502);
		}

		// Stream Ollama's NDJSON response directly to client
		return new Response(ollamaRes.body, {
			headers: {
				"Content-Type": "application/x-ndjson",
				"Cache-Control": "no-cache",
				"Transfer-Encoding": "chunked",
			},
		});
	} catch (e: any) {
		console.error("mi: Failed to reach Ollama:", e.message);
		// Fallback: return a static helpful response
		const fallback = generateFallbackResponse(query, currentModule, space, getModuleInfoList());
		return c.json({ response: fallback });
	}
});

function generateFallbackResponse(
	query: string,
	currentModule: string,
	space: string,
	modules: ReturnType<typeof getModuleInfoList>,
): string {
	const q = query.toLowerCase();

	// Simple keyword matching for common questions
	for (const m of modules) {
		if (q.includes(m.id) || q.includes(m.name.toLowerCase())) {
			return `**${m.name}** ${m.icon} — ${m.description}. You can access it at /${space || "personal"}/${m.id}.`;
		}
	}

	if (q.includes("help") || q.includes("what can")) {
		return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (spatial canvas), **rNotes** (notes), **rChat** (messaging), **rFunds** (community funding), and **rVote** (governance). What would you like to explore?`;
	}

	if (q.includes("search") || q.includes("find")) {
		return `You can browse your content through the app switcher (top-left dropdown), or navigate directly to any rApp. Try **rNotes** for text content, **rFiles** for documents, or **rPhotos** for images.`;
	}

	return `I'm currently running in offline mode (AI service not connected). I can still help with basic navigation — ask me about any specific rApp or feature! There are ${modules.length} apps available in rSpace.`;
}

// ── Existing /api/communities/* routes (backward compatible) ──

/** Resolve a community slug to SpaceAuthConfig for the SDK guard */
async function getSpaceConfig(slug: string): Promise<SpaceAuthConfig | null> {
	let doc = getDocumentData(slug);
	if (!doc) {
		await loadCommunity(slug);
		doc = getDocumentData(slug);
	}
	if (!doc) return null;
	return {
		spaceSlug: slug,
		visibility: (doc.meta.visibility || "public_read") as SpaceVisibility,
		ownerDID: doc.meta.ownerDID || undefined,
		app: "rspace",
	};
}

// Demo reset rate limiter
let lastDemoReset = 0;
const DEMO_RESET_COOLDOWN = 5 * 60 * 1000;

// POST /api/communities — create community (deprecated, use POST /api/spaces)
app.post("/api/communities", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required to create a community" }, 401);

	let claims: EncryptIDClaims;
	try {
		claims = await verifyEncryptIDToken(token);
	} catch {
		return c.json({ error: "Invalid or expired authentication token" }, 401);
	}

	const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>();
	const { name, slug, visibility = "public_read" } = body;

	const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
	if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);

	const result = await createSpace({
		name: name || "", slug: slug || "", ownerDID: claims.sub, visibility, source: 'api',
	});
	if (!result.ok) return c.json({ error: result.error }, result.status);

	c.header("Deprecation", "true");
	c.header("Link", "</api/spaces>; rel=\"successor-version\"");
	return c.json({ url: `https://${result.slug}.rspace.online`, slug: result.slug, name: result.name, visibility: result.visibility, ownerDID: result.ownerDID }, 201);
});

// POST /api/internal/provision — auth-free, called by rSpace Registry
app.post("/api/internal/provision", async (c) => {
	const body = await c.req.json<{ space?: string; description?: string; public?: boolean }>();
	const space = body.space?.trim();
	if (!space) return c.json({ error: "Missing space name" }, 400);

	if (await communityExists(space)) {
		return c.json({ status: "exists", slug: space });
	}

	const result = await createSpace({
		name: space.charAt(0).toUpperCase() + space.slice(1),
		slug: space,
		ownerDID: `did:system:${space}`,
		visibility: body.public ? "public" : "public_read",
		source: 'internal',
	});
	if (!result.ok) return c.json({ error: result.error }, result.status);

	return c.json({ status: "created", slug: space }, 201);
});

// POST /api/communities/demo/reset
app.post("/api/communities/demo/reset", async (c) => {
	const now = Date.now();
	if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
		const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
		return c.json({ error: `Demo reset on cooldown. Try again in ${remaining}s` }, 429);
	}
	lastDemoReset = now;
	await loadCommunity("demo");
	clearShapes("demo");
	await ensureDemoCommunity();
	broadcastAutomergeSync("demo");
	broadcastJsonSnapshot("demo");
	return c.json({ ok: true, message: "Demo community reset to seed data" });
});

// Campaign demo reset removed — campaign is now at /:space/rsocials/campaign

// GET /api/communities/:slug/shapes
app.get("/api/communities/:slug/shapes", async (c) => {
	const slug = c.req.param("slug");
	const token = extractToken(c.req.raw.headers);
	const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig });

	if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);

	await loadCommunity(slug);
	const data = getDocumentData(slug);
	if (!data) return c.json({ error: "Community not found" }, 404);

	return c.json({ shapes: data.shapes || {} });
});

// POST /api/communities/:slug/shapes
app.post("/api/communities/:slug/shapes", async (c) => {
	const slug = c.req.param("slug");
	const internalKey = c.req.header("X-Internal-Key");
	const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;

	if (!isInternalCall) {
		const token = extractToken(c.req.raw.headers);
		const access = await evaluateSpaceAccess(slug, token, "POST", { getSpaceConfig });
		if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
		if (access.readOnly) return c.json({ error: "Write access required to add shapes" }, 403);
	}

	await loadCommunity(slug);
	const data = getDocumentData(slug);
	if (!data) return c.json({ error: "Community not found" }, 404);

	const body = await c.req.json<{ shapes?: Record<string, unknown>[] }>();
	if (!body.shapes || !Array.isArray(body.shapes) || body.shapes.length === 0) {
		return c.json({ error: "shapes array is required and must not be empty" }, 400);
	}

	const ids = addShapes(slug, body.shapes);
	broadcastAutomergeSync(slug);
	broadcastJsonSnapshot(slug);

	return c.json({ ok: true, ids }, 201);
});

// PATCH /api/communities/:slug/shapes/:shapeId
app.patch("/api/communities/:slug/shapes/:shapeId", async (c) => {
	const slug = c.req.param("slug");
	const shapeId = c.req.param("shapeId");
	const internalKey = c.req.header("X-Internal-Key");
	const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;

	if (!isInternalCall) {
		const token = extractToken(c.req.raw.headers);
		const access = await evaluateSpaceAccess(slug, token, "PATCH", { getSpaceConfig });
		if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);
	}

	await loadCommunity(slug);
	const body = await c.req.json<Record<string, unknown>>();
	const updated = updateShapeFields(slug, shapeId, body);
	if (!updated) return c.json({ error: "Shape not found" }, 404);

	broadcastAutomergeSync(slug);
	broadcastJsonSnapshot(slug);

	return c.json({ ok: true });
});

// GET /api/communities/:slug — community info
app.get("/api/communities/:slug", async (c) => {
	const slug = c.req.param("slug");
	const token = extractToken(c.req.raw.headers);
	const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig });

	if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401);

	let data = getDocumentData(slug);
	if (!data) {
		await loadCommunity(slug);
		data = getDocumentData(slug);
	}
	if (!data) return c.json({ error: "Community not found" }, 404);

	return c.json({ meta: data.meta, readOnly: access.readOnly });
});

// ── Module info API (for app switcher) ──
app.get("/api/modules", (c) => {
	return c.json({ modules: getModuleInfoList() });
});

// ── x402 test endpoint (no auth, payment-gated only) ──
import { setupX402FromEnv } from "../shared/x402/hono-middleware";
const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" });
app.post("/api/x402-test", async (c) => {
	if (x402Test) {
		const result = await new Promise<Response | null>((resolve) => {
			x402Test(c, async () => { resolve(null); }).then((res) => {
				if (res instanceof Response) resolve(res);
			});
		});
		if (result) return result;
	}
	return c.json({ ok: true, message: "Payment received!", timestamp: new Date().toISOString() });
});

// ── Creative tools API endpoints ──

const FAL_KEY = process.env.FAL_KEY || "";

// Image generation via fal.ai Flux Pro
app.post("/api/image-gen", async (c) => {
	if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);

	const { prompt, style } = await c.req.json();
	if (!prompt) return c.json({ error: "prompt required" }, 400);

	const stylePrompts: Record<string, string> = {
		illustration: "digital illustration style, ",
		photorealistic: "photorealistic, high detail, ",
		painting: "oil painting style, artistic, ",
		sketch: "pencil sketch style, hand-drawn, ",
		"punk-zine": "punk zine aesthetic, cut-and-paste collage, bold contrast, ",
	};
	const styledPrompt = (stylePrompts[style] || "") + prompt;

	const res = await fetch("https://queue.fal.run/fal-ai/flux-pro/v1.1", {
		method: "POST",
		headers: {
			Authorization: `Key ${FAL_KEY}`,
			"Content-Type": "application/json",
		},
		body: JSON.stringify({
			prompt: styledPrompt,
			image_size: "landscape_4_3",
			num_images: 1,
			safety_tolerance: "2",
		}),
	});

	if (!res.ok) {
		const err = await res.text();
		console.error("[image-gen] fal.ai error:", err);
		return c.json({ error: "Image generation failed" }, 502);
	}

	const data = await res.json();
	const imageUrl = data.images?.[0]?.url || data.output?.url;
	if (!imageUrl) return c.json({ error: "No image returned" }, 502);

	return c.json({ url: imageUrl, image_url: imageUrl });
});

// Text-to-video via fal.ai WAN 2.1
app.post("/api/video-gen/t2v", async (c) => {
	if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);

	const { prompt, duration } = await c.req.json();
	if (!prompt) return c.json({ error: "prompt required" }, 400);

	const res = await fetch("https://queue.fal.run/fal-ai/wan/v2.1", {
		method: "POST",
		headers: {
			Authorization: `Key ${FAL_KEY}`,
			"Content-Type": "application/json",
		},
		body: JSON.stringify({
			prompt,
			num_frames: duration === "5s" ? 81 : 49,
			resolution: "480p",
		}),
	});

	if (!res.ok) {
		const err = await res.text();
		console.error("[video-gen/t2v] fal.ai error:", err);
		return c.json({ error: "Video generation failed" }, 502);
	}

	const data = await res.json();
	const videoUrl = data.video?.url || data.output?.url;
	if (!videoUrl) return c.json({ error: "No video returned" }, 502);

	return c.json({ url: videoUrl, video_url: videoUrl });
});

// Image-to-video via fal.ai Kling
app.post("/api/video-gen/i2v", async (c) => {
	if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);

	const { image, prompt, duration } = await c.req.json();
	if (!image) return c.json({ error: "image required" }, 400);

	const res = await fetch("https://queue.fal.run/fal-ai/kling-video/v1/standard/image-to-video", {
		method: "POST",
		headers: {
			Authorization: `Key ${FAL_KEY}`,
			"Content-Type": "application/json",
		},
		body: JSON.stringify({
			image_url: image,
			prompt: prompt || "",
			duration: duration === "5s" ? "5" : "5",
		}),
	});

	if (!res.ok) {
		const err = await res.text();
		console.error("[video-gen/i2v] fal.ai error:", err);
		return c.json({ error: "Video generation failed" }, 502);
	}

	const data = await res.json();
	const videoUrl = data.video?.url || data.output?.url;
	if (!videoUrl) return c.json({ error: "No video returned" }, 502);

	return c.json({ url: videoUrl, video_url: videoUrl });
});

// Blender 3D generation via LLM + RunPod
const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || "";

app.post("/api/blender-gen", async (c) => {
	if (!RUNPOD_API_KEY) return c.json({ error: "RUNPOD_API_KEY not configured" }, 503);

	const { prompt } = await c.req.json();
	if (!prompt) return c.json({ error: "prompt required" }, 400);

	// Step 1: Generate Blender Python script via LLM
	const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
	let script = "";

	try {
		const llmRes = await fetch(`${OLLAMA_URL}/api/generate`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				model: process.env.OLLAMA_MODEL || "llama3.1",
				prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`,
				stream: false,
			}),
		});

		if (llmRes.ok) {
			const llmData = await llmRes.json();
			script = llmData.response || "";
			// Extract code block if wrapped in markdown
			const codeMatch = script.match(/```python\n([\s\S]*?)```/);
			if (codeMatch) script = codeMatch[1];
		}
	} catch (e) {
		console.error("[blender-gen] LLM error:", e);
	}

	if (!script) {
		return c.json({ error: "Failed to generate Blender script" }, 502);
	}

	// Step 2: Execute on RunPod (headless Blender)
	try {
		const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", {
			method: "POST",
			headers: {
				Authorization: `Bearer ${RUNPOD_API_KEY}`,
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				input: {
					script,
					render: true,
				},
			}),
		});

		if (!runpodRes.ok) {
			// Return just the script if RunPod fails
			return c.json({ script, error_detail: "RunPod execution failed" });
		}

		const runpodData = await runpodRes.json();
		return c.json({
			render_url: runpodData.output?.render_url || null,
			script,
			blend_url: runpodData.output?.blend_url || null,
		});
	} catch (e) {
		// Return the script even if RunPod is unavailable
		return c.json({ script, error_detail: "RunPod unavailable" });
	}
});

// KiCAD PCB design — REST-to-MCP bridge
const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://localhost:3001";

app.post("/api/kicad/:action", async (c) => {
	const action = c.req.param("action");
	const body = await c.req.json();

	const validActions = [
		"create_project", "add_schematic_component", "add_schematic_connection",
		"export_svg", "run_drc", "export_gerber", "export_bom", "export_pdf",
		"search_symbols", "search_footprints", "place_component",
	];

	if (!validActions.includes(action)) {
		return c.json({ error: `Unknown action: ${action}` }, 400);
	}

	try {
		const mcpRes = await fetch(`${KICAD_MCP_URL}/call-tool`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				name: action,
				arguments: body,
			}),
		});

		if (!mcpRes.ok) {
			const err = await mcpRes.text();
			console.error(`[kicad/${action}] MCP error:`, err);
			return c.json({ error: `KiCAD action failed: ${action}` }, 502);
		}

		const data = await mcpRes.json();
		return c.json(data);
	} catch (e) {
		console.error(`[kicad/${action}] Connection error:`, e);
		return c.json({ error: "KiCAD MCP server not available" }, 503);
	}
});

// FreeCAD parametric CAD — REST-to-MCP bridge
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://localhost:3002";

app.post("/api/freecad/:action", async (c) => {
	const action = c.req.param("action");
	const body = await c.req.json();

	const validActions = [
		"generate", "export_step", "export_stl", "update_parameters",
	];

	if (!validActions.includes(action)) {
		return c.json({ error: `Unknown action: ${action}` }, 400);
	}

	try {
		const mcpRes = await fetch(`${FREECAD_MCP_URL}/call-tool`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				name: action,
				arguments: body,
			}),
		});

		if (!mcpRes.ok) {
			const err = await mcpRes.text();
			console.error(`[freecad/${action}] MCP error:`, err);
			return c.json({ error: `FreeCAD action failed: ${action}` }, 502);
		}

		const data = await mcpRes.json();
		return c.json(data);
	} catch (e) {
		console.error(`[freecad/${action}] Connection error:`, e);
		return c.json({ error: "FreeCAD MCP server not available" }, 503);
	}
});

// ── Auto-provision personal space ──
app.post("/api/spaces/auto-provision", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);

	let claims: EncryptIDClaims;
	try {
		claims = await verifyEncryptIDToken(token);
	} catch {
		return c.json({ error: "Invalid or expired token" }, 401);
	}

	const username = claims.username?.toLowerCase();
	if (!username || !/^[a-z0-9][a-z0-9-]*$/.test(username)) {
		return c.json({ error: "Username not suitable for space slug" }, 400);
	}

	if (await communityExists(username)) {
		return c.json({ status: "exists", slug: username });
	}

	const result = await createSpace({
		name: `${claims.username}'s Space`,
		slug: username,
		ownerDID: claims.sub,
		visibility: "members_only",
		source: 'auto-provision',
	});
	if (!result.ok) return c.json({ error: result.error }, result.status);

	return c.json({ status: "created", slug: username }, 201);
});

// ── Inject space visibility into HTML responses ──
// Replaces the default data-space-visibility="public_read" rendered by renderShell
// with the actual visibility from the space config, so the client-side access gate
// can block content for members_only spaces when no session exists.
app.use("/:space/*", async (c, next) => {
	await next();
	const ct = c.res.headers.get("content-type");
	if (!ct?.includes("text/html")) return;
	const space = c.req.param("space");
	if (!space || space === "api" || space.includes(".")) return;
	const config = await getSpaceConfig(space);
	const vis = config?.visibility || "public_read";
	if (vis === "public_read" || vis === "public") return;
	const html = await c.res.text();
	c.res = new Response(
		html.replace(
			'data-space-visibility="public_read"',
			`data-space-visibility="${vis}"`,
		),
		{ status: c.res.status, headers: c.res.headers },
	);
});

// ── Mount module routes under /:space/:moduleId ──
// Enforce enabledModules: if a space has an explicit list, only those modules route.
// The 'rspace' (canvas) module is always allowed as the core module.
for (const mod of getAllModules()) {
	app.use(`/:space/${mod.id}/*`, async (c, next) => {
		if (mod.id === "rspace") return next();
		const space = c.req.param("space");
		if (!space || space === "api" || space.includes(".")) return next();
		const doc = getDocumentData(space);
		if (!doc?.meta?.enabledModules) return next(); // null = all enabled
		if (doc.meta.enabledModules.includes(mod.id)) return next();
		return c.json({ error: "Module not enabled for this space" }, 404);
	});
	app.route(`/:space/${mod.id}`, mod.routes);
	// Auto-mount browsable output list pages
	if (mod.outputPaths) {
		for (const op of mod.outputPaths) {
			app.get(`/:space/${mod.id}/${op.path}`, (c) => {
				return c.html(renderOutputListPage(
					c.req.param("space"), mod, op, getModuleInfoList()
				));
			});
		}
	}
}

// ── Page routes ──

// Landing page: rspace.online/ → server-rendered main landing
app.get("/", (c) => {
	return c.html(renderMainLanding(getModuleInfoList()));
});

// About/info page (full landing content)
app.get("/about", async (c) => {
	const file = Bun.file(resolve(DIST_DIR, "index.html"));
	if (await file.exists()) {
		return new Response(file, { headers: { "Content-Type": "text/html" } });
	}
	return c.text("rSpace", 200);
});

// Create new space page
app.get("/create-space", async (c) => {
	const file = Bun.file(resolve(DIST_DIR, "create-space.html"));
	if (await file.exists()) {
		return new Response(file, { headers: { "Content-Type": "text/html" } });
	}
	return c.text("Create space", 200);
});

// Legacy redirect
app.get("/new", (c) => c.redirect("/create-space", 301));

// ── Admin dashboard & API ──

const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean);

function isAdminDID(did: string | undefined): boolean {
	return !!did && ADMIN_DIDS.includes(did);
}

// Serve admin HTML page
app.get("/admin", async (c) => {
	const file = Bun.file(resolve(DIST_DIR, "admin.html"));
	if (await file.exists()) {
		return new Response(file, { headers: { "Content-Type": "text/html" } });
	}
	return c.text("Admin", 200);
});

// Admin data endpoint (GET) — returns spaces list + modules
app.get("/admin-data", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	let claims: EncryptIDClaims;
	try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
	if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);

	const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
	const slugs = await listCommunities();

	const spacesList = [];
	for (const slug of slugs) {
		await loadCommunity(slug);
		const data = getDocumentData(slug);
		if (!data?.meta) continue;

		const shapes = data.shapes || {};
		const members = data.members || {};
		const shapeCount = Object.keys(shapes).length;
		const memberCount = Object.keys(members).length;

		let fileSizeBytes = 0;
		try {
			const { stat } = await import("node:fs/promises");
			const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
			fileSizeBytes = s.size;
		} catch {
			try {
				const { stat } = await import("node:fs/promises");
				const s = await stat(`${STORAGE_DIR}/${slug}.json`);
				fileSizeBytes = s.size;
			} catch { /* not on disk yet */ }
		}

		const shapeTypes: Record<string, number> = {};
		for (const shape of Object.values(shapes)) {
			const t = (shape as Record<string, unknown>).type as string || "unknown";
			shapeTypes[t] = (shapeTypes[t] || 0) + 1;
		}

		spacesList.push({
			slug: data.meta.slug,
			name: data.meta.name,
			visibility: data.meta.visibility || "public_read",
			createdAt: data.meta.createdAt,
			ownerDID: data.meta.ownerDID,
			shapeCount,
			memberCount,
			fileSizeBytes,
			shapeTypes,
		});
	}

	spacesList.sort((a, b) => {
		const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
		const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
		return db - da;
	});

	return c.json({
		spaces: spacesList,
		total: spacesList.length,
		modules: getModuleInfoList(),
	});
});

// Admin action endpoint (POST) — mutations (delete space, etc.)
app.post("/admin-action", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	let claims: EncryptIDClaims;
	try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
	if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);

	const body = await c.req.json();
	const { action, slug } = body;

	if (action === "delete-space" && slug) {
		await loadCommunity(slug);
		const data = getDocumentData(slug);
		if (!data) return c.json({ error: "Space not found" }, 404);

		const deleteCtx = {
			spaceSlug: slug,
			ownerDID: data.meta?.ownerDID ?? null,
			enabledModules: data.meta?.enabledModules || getAllModules().map(m => m.id),
			syncServer,
		};
		for (const mod of getAllModules()) {
			if (mod.onSpaceDelete) {
				try { await mod.onSpaceDelete(deleteCtx); } catch (e) {
					console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e);
				}
			}
		}

		// Clean up EncryptID space_members
		try {
			await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, {
				method: "DELETE",
				headers: { Authorization: `Bearer ${token}` },
			});
		} catch (e) {
			console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e);
		}

		await deleteCommunity(slug);
		return c.json({ ok: true, message: `Space "${slug}" deleted by admin` });
	}

	return c.json({ error: "Unknown action" }, 400);
});

// Space root: /:space → space dashboard
app.get("/:space", (c) => {
	const space = c.req.param("space");
	// Don't serve dashboard for static file paths
	if (space.includes(".")) return c.notFound();
	return c.html(renderSpaceDashboard(space, getModuleInfoList()));
});

// ── WebSocket types ──
interface WSData {
	communitySlug: string;
	peerId: string;
	claims: EncryptIDClaims | null;
	readOnly: boolean;
	mode: "automerge" | "json";
	// Nest context: set when a folk-canvas shape connects to a nested space
	nestFrom?: string;                  // slug of the parent space that contains the nest
	nestPermissions?: NestPermissions;  // effective permissions for this nested view
	nestFilter?: SpaceRefFilter;        // shape filter applied to this nested view
}

// Track connected clients per community
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();

function generatePeerId(): string {
	return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | undefined {
	return communityClients.get(slug)?.get(peerId);
}

function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void {
	const clients = communityClients.get(slug);
	if (!clients) return;
	const docData = getDocumentData(slug);
	if (!docData) return;
	const allShapes = docData.shapes || {};

	// Pre-build the unfiltered message (most clients use this)
	let unfilteredMsg: string | null = null;

	for (const [clientPeerId, client] of clients) {
		if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
			// Apply nest filter for nested connections
			if (client.data.nestFilter) {
				const filtered: typeof allShapes = {};
				for (const [id, shape] of Object.entries(allShapes)) {
					if (client.data.nestFilter.shapeTypes && !client.data.nestFilter.shapeTypes.includes(shape.type)) continue;
					if (client.data.nestFilter.shapeIds && !client.data.nestFilter.shapeIds.includes(id)) continue;
					filtered[id] = shape;
				}
				client.send(JSON.stringify({ type: "snapshot", shapes: filtered }));
			} else {
				if (!unfilteredMsg) unfilteredMsg = JSON.stringify({ type: "snapshot", shapes: allShapes });
				client.send(unfilteredMsg);
			}
		}
	}
}

function broadcastAutomergeSync(slug: string, excludePeerId?: string): void {
	const clients = communityClients.get(slug);
	if (!clients) return;
	for (const [clientPeerId, client] of clients) {
		if (client.data.mode === "automerge" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
			const syncMsg = generateSyncMessageForPeer(slug, clientPeerId);
			if (syncMsg) {
				client.send(JSON.stringify({ type: "sync", data: Array.from(syncMsg) }));
			}
		}
	}
}

// ── Subdomain parsing (backward compat) ──
const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start", "auth"];

function getSubdomain(host: string | null): string | null {
	if (!host) return null;
	if (host.includes("localhost") || host.includes("127.0.0.1")) return null;
	const parts = host.split(".");
	if (parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online") {
		const sub = parts[0];
		if (!RESERVED_SUBDOMAINS.includes(sub)) return sub;
	}
	return null;
}

// ── Auto-provision helpers ──

/** Extract JWT from Authorization header, then eid_token cookie, then encryptid_token cookie */
function extractTokenFromRequest(req: Request): string | null {
	const auth = req.headers.get("authorization");
	if (auth?.startsWith("Bearer ")) return auth.slice(7);
	const cookie = req.headers.get("cookie");
	if (cookie) {
		const eidMatch = cookie.match(/(?:^|;\s*)eid_token=([^;]*)/);
		if (eidMatch) return decodeURIComponent(eidMatch[1]);
		const encMatch = cookie.match(/(?:^|;\s*)encryptid_token=([^;]*)/);
		if (encMatch) return decodeURIComponent(encMatch[1]);
	}
	return null;
}

/** Base64-decode JWT payload to extract username (cheap pre-check, no crypto) */
function decodeJWTUsername(token: string): string | null {
	try {
		const parts = token.split(".");
		if (parts.length < 2) return null;
		const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
		const pad = "=".repeat((4 - (b64.length % 4)) % 4);
		const payload = JSON.parse(atob(b64 + pad));
		return payload.username?.toLowerCase() || null;
	} catch {
		return null;
	}
}

/** Tracks slugs currently being provisioned to prevent concurrent double-creation */
const provisioningInProgress = new Set<string>();

// ── Static file serving ──
function getContentType(path: string): string {
	if (path.endsWith(".html")) return "text/html";
	if (path.endsWith(".js")) return "application/javascript";
	if (path.endsWith(".css")) return "text/css";
	if (path.endsWith(".json")) return "application/json";
	if (path.endsWith(".svg")) return "image/svg+xml";
	if (path.endsWith(".wasm")) return "application/wasm";
	if (path.endsWith(".png")) return "image/png";
	if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
	if (path.endsWith(".gif")) return "image/gif";
	if (path.endsWith(".ico")) return "image/x-icon";
	return "application/octet-stream";
}

async function serveStatic(path: string): Promise<Response | null> {
	const filePath = resolve(DIST_DIR, path);
	const file = Bun.file(filePath);
	if (await file.exists()) {
		return new Response(file, { headers: { "Content-Type": getContentType(path) } });
	}
	return null;
}

// ── Standalone domain → module lookup ──
const domainToModule = new Map<string, string>();
for (const mod of getAllModules()) {
	if (mod.standaloneDomain) {
		domainToModule.set(mod.standaloneDomain, mod.id);
	}
}

// ── Bun.serve: WebSocket + fetch delegation ──
const server = Bun.serve<WSData>({
	port: PORT,

	async fetch(req, server) {
		const url = new URL(req.url);
		const host = req.headers.get("host");
		const hostClean = host?.split(":")[0] || "";
		const subdomain = getSubdomain(host);

		// ── Standalone domain → 301 redirect to rspace.online ──
		// Check both bare domain and subdomain variants (e.g. rnotes.online, alice.rnotes.online)
		let standaloneModuleId = domainToModule.get(hostClean);
		let standaloneSub: string | null = null;
		if (!standaloneModuleId) {
			// Check if this is a subdomain of a standalone domain (e.g. alice.rnotes.online)
			const hostParts = hostClean.split(".");
			if (hostParts.length >= 3) {
				const baseDomain = hostParts.slice(-2).join(".");
				const candidate = domainToModule.get(baseDomain);
				if (candidate) {
					standaloneModuleId = candidate;
					standaloneSub = hostParts.slice(0, -2).join(".");
				}
			}
		}
		if (standaloneModuleId) {
			// Self-fetch detection: landing proxy uses this User-Agent;
			// return 404 to break circular fetch so the generic fallback is used
			if (req.headers.get("user-agent") === "rSpace-Proxy/1.0") {
				return new Response("Not found", { status: 404 });
			}

			// Determine the space and remaining path
			const pathParts = url.pathname.split("/").filter(Boolean);
			let space = standaloneSub || null; // subdomain on standalone domain = space
			let remainingPath = "";

			if (pathParts.length > 0 && !pathParts[0].includes(".") &&
				pathParts[0] !== "api" && pathParts[0] !== "ws") {
				// First path segment is the space (if no subdomain already set it)
				if (!space) {
					space = pathParts[0];
					remainingPath = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : "";
				} else {
					remainingPath = url.pathname;
				}
			} else {
				remainingPath = url.pathname === "/" ? "" : url.pathname;
			}

			// Build redirect URL
			let redirectUrl: string;
			if (space) {
				// Space-qualified: alice.rnotes.online/path or rnotes.online/alice/path
				// → alice.rspace.online/rnotes/path
				redirectUrl = `https://${space}.rspace.online/${standaloneModuleId}${remainingPath}`;
			} else {
				// No space: rnotes.online/ or rnotes.online/api/...
				// → rspace.online/rnotes or rspace.online/rnotes/api/...
				redirectUrl = `https://rspace.online/${standaloneModuleId}${remainingPath}`;
			}
			if (url.search) redirectUrl += url.search;
			return Response.redirect(redirectUrl, 301);
		}

		// ── WebSocket upgrade ──
		if (url.pathname.startsWith("/ws/")) {
			const communitySlug = url.pathname.split("/")[2];
			if (communitySlug) {
				const spaceConfig = await getSpaceConfig(communitySlug);
				const claims = await authenticateWSUpgrade(req);
				let readOnly = false;

				if (spaceConfig) {
					const vis = spaceConfig.visibility;
					if (vis === "authenticated" || vis === "members_only") {
						if (!claims) return new Response("Authentication required", { status: 401 });
					} else if (vis === "public_read") {
						readOnly = !claims;
					}
				}

				const peerId = generatePeerId();
				const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge";

				// Nest context: if connecting from a parent space via folk-canvas
				const nestFrom = url.searchParams.get("nest-from") || undefined;
				let nestPermissions: NestPermissions | undefined;
				let nestFilter: SpaceRefFilter | undefined;

				if (nestFrom) {
					await loadCommunity(nestFrom);
					const parentData = getDocumentData(nestFrom);
					if (parentData?.nestedSpaces) {
						// Find the SpaceRef in the parent that points to this space
						const matchingRef = Object.values(parentData.nestedSpaces)
							.find(ref => ref.sourceSlug === communitySlug);
						if (matchingRef) {
							nestPermissions = matchingRef.permissions;
							nestFilter = matchingRef.filter;
							// If nest doesn't allow writes, force readOnly
							if (!matchingRef.permissions.write) {
								readOnly = true;
							}
						}
					}
				}

				const upgraded = server.upgrade(req, {
					data: { communitySlug, peerId, claims, readOnly, mode, nestFrom, nestPermissions, nestFilter } as WSData,
				});
				if (upgraded) return undefined;
			}
			return new Response("WebSocket upgrade failed", { status: 400 });
		}

		// ── Explicit page routes (before Hono, to avoid /:space catch-all) ──
		if (url.pathname === "/admin") {
			const adminHtml = await serveStatic("admin.html");
			if (adminHtml) return adminHtml;
		}

		// ── Static assets (before Hono routing) ──
		if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) {
			const assetPath = url.pathname.slice(1);
			// Serve files with extensions directly
			if (assetPath.includes(".")) {
				const staticResponse = await serveStatic(assetPath);
				if (staticResponse) return staticResponse;
			}
		}

		// ── Subdomain routing: {space}.rspace.online/{moduleId}/... ──
		if (subdomain) {
			// ── Auto-provision personal space on first visit ──
			// Fast path: communityExists is an in-memory Map check for cached spaces.
			if (!(await communityExists(subdomain))) {
				const token = extractTokenFromRequest(req);
				if (token) {
					const jwtUsername = decodeJWTUsername(token);
					if (jwtUsername === subdomain && !provisioningInProgress.has(subdomain)) {
						provisioningInProgress.add(subdomain);
						try {
							const claims = await verifyEncryptIDToken(token);
							const username = claims.username?.toLowerCase();
							if (username === subdomain && !(await communityExists(subdomain))) {
								await createSpace({
									name: `${claims.username}'s Space`,
									slug: subdomain,
									ownerDID: claims.sub,
									visibility: "members_only",
									source: 'subdomain',
								});
							}
						} catch (e) {
							console.error(`[AutoProvision] Token verification failed for ${subdomain}:`, e);
						} finally {
							provisioningInProgress.delete(subdomain);
						}
					}
				}
			}

			const pathSegments = url.pathname.split("/").filter(Boolean);

			// Root: show space dashboard
			if (pathSegments.length === 0) {
				return new Response(renderSpaceDashboard(subdomain, getModuleInfoList()), {
					headers: { "Content-Type": "text/html" },
				});
			}

			// Global routes pass through without subdomain prefix
			if (
				url.pathname.startsWith("/api/") ||
				url.pathname.startsWith("/.well-known/") ||
				url.pathname === "/about" ||
				url.pathname === "/admin" ||
				url.pathname === "/create-space" ||
				url.pathname === "/new"
			) {
				return app.fetch(req);
			}

			// Static assets (paths with file extensions) pass through
			if (pathSegments[0].includes(".")) {
				return app.fetch(req);
			}

			// Normalize module ID to lowercase (rTrips → rtrips)
			const normalizedPath = "/" + pathSegments.map((seg, i) =>
				i === 0 ? seg.toLowerCase() : seg
			).join("/");

			// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
			// e.g. demo.rspace.online/vote/api/polls → /demo/vote/api/polls
			const rewrittenPath = `/${subdomain}${normalizedPath}`;
			const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
			const rewrittenReq = new Request(rewrittenUrl, req);
			return app.fetch(rewrittenReq);
		}

		// ── Bare-domain routing: rspace.online/{...} ──
		if (!subdomain && hostClean.includes("rspace.online")) {
			const pathSegments = url.pathname.split("/").filter(Boolean);
			if (pathSegments.length >= 1) {
				const firstSegment = pathSegments[0].toLowerCase();
				const allModules = getAllModules();
				const knownModuleIds = new Set(allModules.map((m) => m.id));
				const mod = allModules.find((m) => m.id === firstSegment);

				if (mod) {
					// rspace.online/{moduleId} → landing page
					if (pathSegments.length === 1) {
						// 1. Check for inline rich landing page
						if (mod.landingPage) {
							const html = renderModuleLanding({
								module: mod,
								modules: getModuleInfoList(),
								bodyHTML: mod.landingPage(),
							});
							return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
						}
						// 2. Try proxying the rich standalone landing page
						const proxyHtml = await fetchLandingPage(mod, getModuleInfoList());
						if (proxyHtml) {
							return new Response(proxyHtml, {
								headers: { "Content-Type": "text/html; charset=utf-8" },
							});
						}
						// 3. Fallback to generic landing page
						const html = renderModuleLanding({
							module: mod,
							modules: getModuleInfoList(),
						});
						return new Response(html, { headers: { "Content-Type": "text/html" } });
					}
					// rspace.online/{moduleId}/sub-path → rewrite to demo space internally
					const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
					const rewrittenPath = `/demo${normalizedPath}`;
					const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
					const rewrittenReq = new Request(rewrittenUrl, req);
					return app.fetch(rewrittenReq);
				}

				// rspace.online/{space}/{...} → redirect to {space}.rspace.online/{...}
				// (space is not a module ID — it's a space slug, canonicalize to subdomain)
				// Skip redirect for known server paths (api, admin, etc.)
				const serverPaths = new Set(["api", "admin", "admin-data", "admin-action", ".well-known"]);
				if (!knownModuleIds.has(firstSegment) && !serverPaths.has(firstSegment) && pathSegments.length >= 2) {
					const space = firstSegment;
					const rest = "/" + pathSegments.slice(1).join("/");
					const baseDomain = hostClean.replace(/^www\./, "");
					return Response.redirect(
						`${url.protocol}//${space}.${baseDomain}${rest}${url.search}`, 301
					);
				}
			}
		}

		// ── Hono handles everything else ──
		const response = await app.fetch(req);

		// If Hono returns 404, try serving canvas.html as SPA fallback
		// But only for paths that don't match a known module route
		if (response.status === 404 && !url.pathname.startsWith("/api/")) {
			const parts = url.pathname.split("/").filter(Boolean);
			// Check if this is under a known module — if so, the module's 404 is authoritative
			const knownModuleIds = getAllModules().map((m) => m.id);
			const isModulePath = parts.length >= 2 && knownModuleIds.includes(parts[1].toLowerCase());

			if (!isModulePath && parts.length >= 1 && !parts[0].includes(".")) {
				// Not a module path — could be a canvas SPA route, try fallback
				const canvasHtml = await serveStatic("canvas.html");
				if (canvasHtml) return canvasHtml;

				const indexHtml = await serveStatic("index.html");
				if (indexHtml) return indexHtml;
			}
		}

		return response;
	},

	// ── WebSocket handlers (unchanged) ──
	websocket: {
		open(ws: ServerWebSocket<WSData>) {
			const { communitySlug, peerId, mode, nestFrom, nestFilter, claims } = ws.data;

			if (!communityClients.has(communitySlug)) {
				communityClients.set(communitySlug, new Map());
			}
			communityClients.get(communitySlug)!.set(peerId, ws);

			// Register with DocSyncManager for multi-doc sync
			syncServer.addPeer(peerId, ws, claims ? { sub: claims.sub, username: claims.username } : undefined);

			const nestLabel = nestFrom ? ` (nested from ${nestFrom})` : "";
			console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})${nestLabel}`);

			loadCommunity(communitySlug).then((doc) => {
				if (!doc) return;

				if (mode === "json") {
					const docData = getDocumentData(communitySlug);
					if (docData) {
						let shapes = docData.shapes || {};

						// Apply nest filter if this is a nested connection
						if (nestFilter) {
							const filtered: typeof shapes = {};
							for (const [id, shape] of Object.entries(shapes)) {
								if (nestFilter.shapeTypes && !nestFilter.shapeTypes.includes(shape.type)) continue;
								if (nestFilter.shapeIds && !nestFilter.shapeIds.includes(id)) continue;
								filtered[id] = shape;
							}
							shapes = filtered;
						}

						ws.send(JSON.stringify({ type: "snapshot", shapes }));
					}
				} else {
					const syncMessage = generateSyncMessageForPeer(communitySlug, peerId);
					if (syncMessage) {
						ws.send(JSON.stringify({ type: "sync", data: Array.from(syncMessage) }));
					}
				}
			});
		},

		message(ws: ServerWebSocket<WSData>, message: string | Buffer) {
			const { communitySlug, peerId } = ws.data;

			try {
				const msg = JSON.parse(message.toString());

				// ── DocSyncManager protocol (messages with docId) ──
				if (msg.type === "subscribe" || msg.type === "unsubscribe" || msg.type === "awareness") {
					if (msg.docIds || msg.docId) {
						syncServer.handleMessage(peerId, message.toString());
						return;
					}
				}
				if (msg.type === "sync" && msg.docId) {
					// New protocol: sync with docId → route to SyncServer
					syncServer.handleMessage(peerId, message.toString());
					return;
				}

				// ── Legacy canvas protocol (no docId) ──
				if (msg.type === "sync" && Array.isArray(msg.data)) {
					if (ws.data.readOnly) {
						ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit this space" }));
						return;
					}
					const syncMessage = new Uint8Array(msg.data);
					const result = receiveSyncMessage(communitySlug, peerId, syncMessage);

					if (result.response) {
						ws.send(JSON.stringify({ type: "sync", data: Array.from(result.response) }));
					}

					for (const [targetPeerId, targetMessage] of result.broadcastToPeers) {
						const targetClient = getClient(communitySlug, targetPeerId);
						if (targetClient && targetClient.data.mode === "automerge" && targetClient.readyState === WebSocket.OPEN) {
							targetClient.send(JSON.stringify({ type: "sync", data: Array.from(targetMessage) }));
						}
					}

					if (result.broadcastToPeers.size > 0) {
						broadcastJsonSnapshot(communitySlug, peerId);
					}
				} else if (msg.type === "ping") {
					ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
				} else if (msg.type === "presence") {
					const clients = communityClients.get(communitySlug);
					if (clients) {
						const presenceMsg = JSON.stringify({ type: "presence", peerId, ...msg });
						for (const [clientPeerId, client] of clients) {
							if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
								client.send(presenceMsg);
							}
						}
					}
				} else if (msg.type === "update" && msg.id && msg.data) {
					if (ws.data.readOnly) {
						ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
						return;
					}
					// Nest-level: check addShapes for new shapes
					if (ws.data.nestPermissions) {
						const existingDoc = getDocumentData(communitySlug);
						const isNewShape = existingDoc && !existingDoc.shapes?.[msg.id];
						if (isNewShape && !ws.data.nestPermissions.addShapes) {
							ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow adding shapes" }));
							return;
						}
					}
					updateShape(communitySlug, msg.id, msg.data);
					broadcastJsonSnapshot(communitySlug, peerId);
					broadcastAutomergeSync(communitySlug, peerId);
				} else if (msg.type === "delete" && msg.id) {
					if (ws.data.readOnly) {
						ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" }));
						return;
					}
					if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) {
						ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow deleting shapes" }));
						return;
					}
					forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
					broadcastJsonSnapshot(communitySlug, peerId);
					broadcastAutomergeSync(communitySlug, peerId);
				} else if (msg.type === "forget" && msg.id) {
					if (ws.data.readOnly) {
						ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" }));
						return;
					}
					if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) {
						ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow forgetting shapes" }));
						return;
					}
					forgetShape(communitySlug, msg.id, ws.data.claims?.sub);
					broadcastJsonSnapshot(communitySlug, peerId);
					broadcastAutomergeSync(communitySlug, peerId);
				} else if (msg.type === "remember" && msg.id) {
					if (ws.data.readOnly) {
						ws.send(JSON.stringify({ type: "error", message: "Authentication required to remember" }));
						return;
					}
					rememberShape(communitySlug, msg.id);
					broadcastJsonSnapshot(communitySlug, peerId);
					broadcastAutomergeSync(communitySlug, peerId);
				}
			} catch (e) {
				console.error("[WS] Failed to parse message:", e);
			}
		},

		close(ws: ServerWebSocket<WSData>) {
			const { communitySlug, peerId } = ws.data;
			const clients = communityClients.get(communitySlug);
			if (clients) {
				clients.delete(peerId);
				if (clients.size === 0) communityClients.delete(communitySlug);
			}
			removePeerSyncState(communitySlug, peerId);
			syncServer.removePeer(peerId);
			console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`);
		},
	},
});

// ── Startup ──

// Call onInit for each module that defines it (schema registration, DB init, etc.)
(async () => {
	for (const mod of getAllModules()) {
		if (mod.onInit) {
			try {
				await mod.onInit({ syncServer });
				console.log(`[Init] ${mod.name} initialized`);
			} catch (e) {
				console.error(`[Init] ${mod.name} failed:`, e);
			}
		}
	}
})();

ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e));

console.log(`rSpace unified server running on http://localhost:${PORT}`);
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);
