/**
 * 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 sharp from "sharp";
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,
	updateSpaceMeta,
	setMember,
} from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import { seedTemplateShapes } from "./seed-template";
// Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts
import type { SpaceVisibility } from "./community-store";
import {
	evaluateSpaceAccess,
	authenticateWSUpgrade,
} from "@encryptid/sdk/server";
import type { SpaceAuthConfig } from "@encryptid/sdk/server";
import { verifyToken, extractToken } from "./auth";
import type { EncryptIDClaims } from "./auth";
import { createMcpRouter } from "./mcp-server";

const spaceAuthOpts = () => ({
	getSpaceConfig,
	...(process.env.JWT_SECRET ? { secret: process.env.JWT_SECRET } : {}),
});

// ── Module system ──
import { registerModule, getAllModules, getModuleInfoList, getModule } 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 { flowsModule } from "../modules/rflows/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 { tasksModule } from "../modules/rtasks/mod";
import { checklistCheckRoutes, checklistApiRoutes } from "../modules/rtasks/checklist-routes";
import { magicLinkRoutes } from "./magic-link/routes";
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 { meetsModule } from "../modules/rmeets/mod";
import { chatsModule } from "../modules/rchats/mod";
import { agentsModule } from "../modules/ragents/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { mindersModule } from "../modules/rminders/mod";
import { bnbModule } from "../modules/rbnb/mod";
import { vnbModule } from "../modules/rvnb/mod";
import { crowdsurfModule } from "../modules/crowdsurf/mod";
import { timeModule } from "../modules/rtime/mod";
import { govModule } from "../modules/rgov/mod";
import { sheetsModule } from "../modules/rsheets/mod";
import { exchangeModule } from "../modules/rexchange/mod";
import { auctionsModule } from "../modules/rauctions/mod";
import { credModule } from "../modules/rcred/mod";
import { feedsModule } from "../modules/rfeeds/mod";
import { rpastModule } from "../modules/rpast/mod";
import { initMarkwhen } from "../shared/markwhen";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
import { renderShell, renderSubPageInfo, renderModuleLanding, renderOnboarding, setFragmentMode } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { syncServer, flushAndShutdown } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";
import { backupRouter } from "./local-first/backup-routes";
import { ipfsRouter } from "./ipfs-routes";
import { isIPFSEnabled, pinToIPFS } from "./ipfs";
import { oauthRouter, setOAuthStatusSyncServer } from "./oauth/index";
import { setNotionOAuthSyncServer } from "./oauth/notion";
import { setGoogleOAuthSyncServer } from "./oauth/google";
import { setClickUpOAuthSyncServer } from "./oauth/clickup";
import { notificationRouter } from "./notification-routes";
import { registerUserConnection, unregisterUserConnection, notify } from "./notification-service";
import { SystemClock } from "./clock-service";
import type { ClockPayload } from "./clock-service";
import { miRoutes } from "./mi-routes";
import { bugReportRouter } from "./bug-report-routes";
import { createSecurityMiddleware, mcpGuard } from "./security";

// ── Process-level error safety net (prevent crash on unhandled socket errors) ──
process.on('uncaughtException', (err) => {
	console.error('[FATAL] Uncaught exception (swallowed):', err.message);
	// Don't exit — keep the server running
});
process.on('unhandledRejection', (reason) => {
	console.error('[FATAL] Unhandled rejection (swallowed):', reason);
});

// ── Graceful shutdown — flush all in-memory docs to disk ──
let shuttingDown = false;
async function gracefulShutdown(signal: string) {
	if (shuttingDown) return;
	shuttingDown = true;
	console.log(`[Server] ${signal} received — flushing docs to disk...`);
	const timeout = setTimeout(() => {
		console.error("[Server] Flush timed out after 10s — forcing exit");
		process.exit(1);
	}, 10_000);
	try {
		await flushAndShutdown();
	} catch (e) {
		console.error("[Server] Flush error during shutdown:", e);
	}
	clearTimeout(timeout);
	process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

// Register modules (order determines app-switcher menu position)
registerModule(canvasModule);
registerModule(pubsModule);
registerModule(cartModule);
registerModule(swagModule);
registerModule(choicesModule);
registerModule(flowsModule);
registerModule(filesModule);
registerModule(walletModule);
registerModule(voteModule);
registerModule(notesModule);
registerModule(mapsModule);
registerModule(tasksModule);
registerModule(calModule);
registerModule(networkModule);
registerModule(inboxModule);
registerModule(dataModule);
registerModule(splatModule);
registerModule(photosModule);
registerModule(socialsModule);
registerModule(mindersModule);
registerModule(meetsModule);
registerModule(chatsModule);
registerModule(agentsModule);
registerModule(bnbModule);
registerModule(vnbModule);
registerModule(crowdsurfModule);
registerModule(timeModule);
registerModule(govModule);      // Governance decision circuits
registerModule(exchangeModule); // P2P crypto/fiat exchange
registerModule(auctionsModule); // Community auctions with USDC
registerModule(credModule);     // Contribution recognition via CredRank
registerModule(feedsModule);    // Community RSS dashboard
registerModule(designModule);   // Scribus DTP + AI design agent
// De-emphasized modules (bottom of menu)
registerModule(forumModule);
registerModule(tubeModule);
registerModule(tripsModule);
registerModule(booksModule);
registerModule(sheetsModule);
registerModule(docsModule);      // Full TipTap editor (split from rNotes)
registerModule(rpastModule);     // Chronicle-of-self timeline across all rApps

// Wire rPast / markwhen projection layer to syncServer.
// Scans the in-memory doc cache for `loadDoc` / `listDocIds` — loadAllDocs
// hydrates the cache at startup, so this sees every persisted doc.
initMarkwhen({
	async loadDoc<T>(docId: string): Promise<T | null> {
		return (syncServer.getDoc<T>(docId) as T | undefined) ?? null;
	},
	async listDocIds(prefix: string): Promise<string[]> {
		return syncServer.getDocIds().filter(id => id.startsWith(prefix));
	},
});

// ── 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 ──
type AppEnv = { Variables: { isSubdomain: boolean } };
const app = new Hono<AppEnv>();

// Detect subdomain routing and set context flag
app.use("*", async (c, next) => {
	const host = c.req.header("host")?.split(":")[0] || "";
	const parts = host.split(".");
	const isSubdomain = parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online"
		&& !["www", "rspace", "create", "new", "start", "auth"].includes(parts[0]);
	c.set("isSubdomain", isSubdomain);
	await next();
});

// CORS for API routes — restrict to known origins
app.use("/api/*", cors({
	origin: (origin) => {
		if (!origin) return origin; // server-to-server (no Origin header) — allow
		const allowed = [
			"rspace.online", "ridentity.online", "rsocials.online",
			"rwallet.online", "rvote.online", "rmaps.online",
		];
		try {
			const host = new URL(origin).hostname;
			if (allowed.some((d) => host === d || host.endsWith(`.${d}`))) return origin;
			// Allow localhost for dev
			if (host === "localhost" || host === "127.0.0.1") return origin;
		} catch {}
		return ""; // deny
	},
	credentials: true,
}));

// Security middleware — UA filtering + tiered rate limiting
app.use("/api/*", createSecurityMiddleware());

// ── .well-known/webauthn (WebAuthn Related Origins) ──
// Browsers enforce a 5 eTLD+1 limit. Only list domains where passkey
// ceremonies happen directly. Must match encryptid/server.ts priority list.
app.get("/.well-known/webauthn", (c) => {
	return c.json(
		{
			origins: [
				"https://ridentity.online",           // EncryptID domain (eTLD+1 #1)
				"https://auth.ridentity.online",
				"https://rsocials.online",            // Postiz ecosystem (eTLD+1 #2)
				"https://demo.rsocials.online",
				"https://socials.crypto-commons.org", // (eTLD+1 #3)
				"https://socials.p2pfoundation.net",  // (eTLD+1 #4)
			],
		},
		200,
		{
			"Access-Control-Allow-Origin": "*",
			"Cache-Control": "public, max-age=3600",
		}
	);
});

// ── Analytics tracker (proxied from Umami — replaces rdata.online/collect.js) ──
const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online";
app.get("/collect.js", async (c) => {
	try {
		const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) });
		if (res.ok) {
			const script = await res.text();
			return new Response(script, {
				headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=3600" },
			});
		}
	} catch {}
	return new Response("/* umami unavailable */", {
		headers: { "Content-Type": "application/javascript" },
	});
});

// ── Serve generated files from /data/files/generated/ and /api/files/generated/ ──
// The /api/ route avoids Cloudflare/Traefik redirecting /data/ paths
const GENERATED_MIME: Record<string, string> = {
	png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp",
	glb: "model/gltf-binary", gltf: "model/gltf+json",
	step: "application/step", stp: "application/step",
	stl: "application/sla", fcstd: "application/octet-stream",
	svg: "image/svg+xml", pdf: "application/pdf",
};

function serveGeneratedFile(c: any) {
	// Support both flat files and subdirectory paths (e.g. freecad-xxx/model.step)
	const filename = c.req.param("filename");
	const subdir = c.req.param("subdir");
	const relPath = subdir ? `${subdir}/${filename}` : filename;
	if (!relPath || relPath.includes("..")) {
		return c.json({ error: "Invalid filename" }, 400);
	}
	const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
	const filePath = resolve(dir, relPath);
	// Ensure resolved path stays within generated dir
	if (!filePath.startsWith(dir)) return c.json({ error: "Invalid path" }, 400);
	const file = Bun.file(filePath);
	return file.exists().then((exists: boolean) => {
		if (!exists) return c.notFound();
		const ext = filePath.split(".").pop()?.toLowerCase() || "";
		return new Response(file, { headers: { "Content-Type": GENERATED_MIME[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
	});
}
app.get("/data/files/generated/:filename", serveGeneratedFile);
app.get("/data/files/generated/:subdir/:filename", serveGeneratedFile);
app.get("/api/files/generated/:filename", serveGeneratedFile);
app.get("/api/files/generated/:subdir/:filename", serveGeneratedFile);

// ── IPFS background pinning for generated files ──

/** In-memory cache of filePath → CID. Populated from .cid sidecar files on startup. */
const generatedCidCache = new Map<string, string>();

/** Load existing .cid sidecar files into cache on startup. */
async function loadGeneratedCids() {
	const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
	try {
		const { readdir, readFile } = await import("node:fs/promises");
		const files = await readdir(dir);
		for (const f of files) {
			if (!f.endsWith(".cid")) continue;
			try {
				const cid = (await readFile(resolve(dir, f), "utf-8")).trim();
				const originalFile = f.replace(/\.cid$/, "");
				generatedCidCache.set(originalFile, cid);
			} catch {}
		}
		if (generatedCidCache.size > 0) {
			console.log(`[ipfs] Loaded ${generatedCidCache.size} CID sidecar files`);
		}
	} catch {}
}
loadGeneratedCids();

/**
 * Pin a generated file to IPFS in the background.
 * Writes a .cid sidecar file alongside the original.
 * Safe to call fire-and-forget — failures are logged and swallowed.
 */
async function pinGeneratedFile(filePath: string, filename: string) {
	if (!isIPFSEnabled()) return;
	if (generatedCidCache.has(filename)) return;
	try {
		const file = Bun.file(filePath);
		const buf = new Uint8Array(await file.arrayBuffer());
		const cid = await pinToIPFS(buf, filename);
		generatedCidCache.set(filename, cid);
		const { writeFile } = await import("node:fs/promises");
		await writeFile(`${filePath}.cid`, cid);
		console.log(`[ipfs] Pinned ${filename} → ${cid}`);
	} catch (err: any) {
		console.warn(`[ipfs] Pin failed for ${filename} (non-fatal):`, err.message);
	}
}

// ── Link preview / unfurl API ──
const linkPreviewCache = new Map<string, { title: string; description: string; image: string | null; domain: string; fetchedAt: number }>();

app.get("/api/link-preview", async (c) => {
	const url = c.req.query("url");
	if (!url) return c.json({ error: "Missing url parameter" }, 400);

	try { new URL(url); } catch { return c.json({ error: "Invalid URL" }, 400); }

	// Check cache (1 hour TTL)
	const cached = linkPreviewCache.get(url);
	if (cached && Date.now() - cached.fetchedAt < 3600_000) {
		return c.json(cached, 200, { "Cache-Control": "public, max-age=3600" });
	}

	try {
		const controller = new AbortController();
		const timeout = setTimeout(() => controller.abort(), 5000);
		const res = await fetch(url, {
			headers: { "User-Agent": "Mozilla/5.0 (compatible; rSpace/1.0; +https://rspace.online)", "Accept": "text/html" },
			signal: controller.signal,
			redirect: "follow",
		});
		clearTimeout(timeout);

		if (!res.ok) return c.json({ error: "Fetch failed" }, 502);
		const contentType = res.headers.get("content-type") || "";
		if (!contentType.includes("text/html")) {
			const domain = new URL(url).hostname.replace(/^www\./, "");
			const result = { title: url, description: "", image: null, domain, fetchedAt: Date.now() };
			linkPreviewCache.set(url, result);
			return c.json(result);
		}

		const html = await res.text();
		const getMetaContent = (nameOrProp: string): string => {
			const re = new RegExp(`<meta[^>]*(?:name|property)=["']${nameOrProp}["'][^>]*content=["']([^"']*?)["']`, "i");
			const re2 = new RegExp(`<meta[^>]*content=["']([^"']*?)["'][^>]*(?:name|property)=["']${nameOrProp}["']`, "i");
			return re.exec(html)?.[1] || re2.exec(html)?.[1] || "";
		};

		const ogTitle = getMetaContent("og:title") || getMetaContent("twitter:title");
		const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
		const title = ogTitle || titleMatch?.[1]?.trim() || url;
		const description = getMetaContent("og:description") || getMetaContent("twitter:description") || getMetaContent("description");
		let image = getMetaContent("og:image") || getMetaContent("twitter:image") || null;
		if (image && !image.startsWith("http")) {
			try { image = new URL(image, url).href; } catch { image = null; }
		}
		const domain = new URL(url).hostname.replace(/^www\./, "");

		const result = { title, description, image, domain, fetchedAt: Date.now() };
		linkPreviewCache.set(url, result);

		// Cap cache size
		if (linkPreviewCache.size > 500) {
			const oldest = [...linkPreviewCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
			for (let i = 0; i < 100; i++) linkPreviewCache.delete(oldest[i][0]);
		}

		return c.json(result, 200, { "Cache-Control": "public, max-age=3600" });
	} catch (e) {
		console.error("[link-preview] fetch error:", url, e instanceof Error ? e.message : e);
		return c.json({ error: "Failed to fetch URL" }, 502);
	}
});

// ── Ecosystem manifest (self-declaration) ──
// Serves rSpace's own manifest so other ecosystem apps can discover it
app.get("/.well-known/rspace-manifest.json", (c) => {
	const modules = getModuleInfoList();
	const shapes = modules.map((m) => ({
		tagName: `folk-rapp`,
		name: m.name,
		description: m.description || "",
		defaults: { width: 500, height: 400 },
		portDescriptors: [],
		eventDescriptors: [],
		config: { moduleId: m.id },
	}));

	return c.json(
		{
			appId: "rspace",
			name: "rSpace",
			version: "1.0.0",
			icon: "🌐",
			description: "Local-first workspace for the r-ecosystem",
			homepage: "https://rspace.online",
			moduleUrl: "/assets/folk-rapp.js",
			color: "#6366f1",
			embeddingModes: ["trusted", "sandboxed"],
			shapes,
			minProtocolVersion: 1,
		},
		200,
		{
			"Access-Control-Allow-Origin": "*",
			"Cache-Control": "public, max-age=3600",
		}
	);
});

// ── Ecosystem manifest proxy (CORS avoidance) ──
// Fetches external app manifests server-side so canvas clients avoid CORS issues
const ecosystemManifestCache = new Map<string, { data: unknown; fetchedAt: number }>();

app.get("/api/ecosystem/:appId/manifest", async (c) => {
	const appId = c.req.param("appId");

	// Known ecosystem app origins
	const ECOSYSTEM_ORIGINS: Record<string, string> = {
		rwallet: "https://rwallet.online",
		rvote: "https://rvote.online",
		rmaps: "https://rmaps.online",
		rsocials: "https://rsocials.online",
	};

	const origin = ECOSYSTEM_ORIGINS[appId];
	if (!origin) {
		return c.json({ error: "Unknown ecosystem app" }, 404);
	}

	// Check cache (1 hour TTL)
	const cached = ecosystemManifestCache.get(appId);
	if (cached && Date.now() - cached.fetchedAt < 3600_000) {
		return c.json(cached.data, 200, { "Cache-Control": "public, max-age=3600" });
	}

	try {
		const controller = new AbortController();
		const timeout = setTimeout(() => controller.abort(), 5000);
		const manifestUrl = `${origin}/.well-known/rspace-manifest.json`;
		const res = await fetch(manifestUrl, {
			headers: { "Accept": "application/json", "User-Agent": "rSpace-Ecosystem/1.0" },
			signal: controller.signal,
			redirect: "error", // reject redirects — prevents allowlist bypass
		});
		clearTimeout(timeout);

		if (!res.ok) return c.json({ error: "Failed to fetch manifest" }, 502);
		const data = await res.json();

		// Resolve relative moduleUrl to absolute
		if (data.moduleUrl && !data.moduleUrl.startsWith("http")) {
			data.resolvedModuleUrl = `${origin}${data.moduleUrl.startsWith("/") ? "" : "/"}${data.moduleUrl}`;
		} else {
			data.resolvedModuleUrl = data.moduleUrl;
		}
		data.origin = origin;
		data.fetchedAt = Date.now();

		ecosystemManifestCache.set(appId, { data, fetchedAt: Date.now() });

		// Cap cache size
		if (ecosystemManifestCache.size > 50) {
			const oldest = [...ecosystemManifestCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
			ecosystemManifestCache.delete(oldest[0][0]);
		}

		return c.json(data, 200, { "Cache-Control": "public, max-age=3600" });
	} catch {
		return c.json({ error: "Failed to fetch manifest" }, 502);
	}
});

// ── Ecosystem module proxy (CORS avoidance for JS modules) ──
app.get("/api/ecosystem/:appId/module", async (c) => {
	const appId = c.req.param("appId");

	// Fetch manifest first to get module URL
	const cached = ecosystemManifestCache.get(appId);
	if (!cached) {
		return c.json({ error: "Fetch manifest first via /api/ecosystem/:appId/manifest" }, 400);
	}
	const manifest = cached.data as Record<string, unknown>;
	const moduleUrl = (manifest.resolvedModuleUrl || manifest.moduleUrl) as string;
	if (!moduleUrl) return c.json({ error: "No moduleUrl in manifest" }, 400);

	try {
		const controller = new AbortController();
		const timeout = setTimeout(() => controller.abort(), 10_000);
		const res = await fetch(moduleUrl, {
			headers: { "Accept": "application/javascript", "User-Agent": "rSpace-Ecosystem/1.0" },
			signal: controller.signal,
			redirect: "error", // reject redirects — prevents allowlist bypass
		});
		clearTimeout(timeout);

		if (!res.ok) return c.json({ error: "Failed to fetch module" }, 502);
		const js = await res.text();
		return new Response(js, {
			headers: {
				"Content-Type": "application/javascript",
				"Cache-Control": "public, max-age=86400",
				"Access-Control-Allow-Origin": "*",
			},
		});
	} catch {
		return c.json({ error: "Failed to fetch module" }, 502);
	}
});

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

// ── Backup API (encrypted blob storage) ──
app.route("/api/backup", backupRouter);

// ── IPFS API (pinning + gateway proxy) ──
app.route("/api/ipfs", ipfsRouter);

// ── OAuth API (Notion, Google integrations) ──
app.route("/api/oauth", oauthRouter);

// ── Notifications API ──
app.route("/api/notifications", notificationRouter);

// ── MI — AI assistant endpoints ──
app.route("/api/mi", miRoutes);

// ── Email Checklist (top-level, bypasses space auth) ──
app.route("/rtasks/check", checklistCheckRoutes);
app.route("/api/rtasks", checklistApiRoutes);

// ── Dashboard summary API ──
import { dashboardRoutes } from "./dashboard-routes";
app.route("/api", dashboardRoutes);

// ── Bug Report API ──
app.route("/api/bug-report", bugReportRouter);

// ── MCP Server (Model Context Protocol) ──
app.use("/api/mcp/*", mcpGuard);
app.route("/api/mcp", createMcpRouter(syncServer));

// ── Magic Link Responses (top-level, bypasses space auth) ──
app.route("/respond", magicLinkRoutes);

// ── Same-origin auth proxy (avoids cross-origin issues on Safari) ──
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";

const proxyToEncryptid = async (c: any) => {
	const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
	const headers = new Headers(c.req.raw.headers);
	headers.delete("host");
	try {
		const res = await fetch(targetUrl, {
			method: c.req.method,
			headers,
			body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
			// @ts-ignore duplex needed for streaming request bodies
			duplex: "half",
		});
		return new Response(res.body, { status: res.status, headers: res.headers });
	} catch (e: any) {
		return c.json({ error: "EncryptID service unavailable" }, 502);
	}
};

app.all("/api/auth/*", proxyToEncryptid);
app.all("/api/register/*", proxyToEncryptid);
app.all("/api/account/*", proxyToEncryptid);
app.all("/api/session/*", proxyToEncryptid);
app.all("/api/guardians/*", proxyToEncryptid);
app.all("/api/guardians", proxyToEncryptid);
app.all("/api/user/*", proxyToEncryptid);
app.all("/api/device-link/*", proxyToEncryptid);
app.all("/api/recovery/*", proxyToEncryptid);

// ── EncryptID proxy (forward /encryptid/* to encryptid container) ──
app.all("/encryptid/*", async (c) => {
	const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
	const headers = new Headers(c.req.raw.headers);
	headers.delete("host");
	try {
		const res = await fetch(targetUrl, {
			method: c.req.method,
			headers,
			body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
			// @ts-ignore duplex needed for streaming request bodies
			duplex: "half",
		});
		return new Response(res.body, { status: res.status, headers: res.headers });
	} catch (e: any) {
		return c.json({ error: "EncryptID service unavailable" }, 502);
	}
});

// ── User API proxy (forward /api/user/* and /api/users/* to EncryptID) ──
app.all("/api/user/*", async (c) => {
	const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
	const headers = new Headers(c.req.raw.headers);
	headers.delete("host");
	try {
		const res = await fetch(targetUrl, {
			method: c.req.method,
			headers,
			body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
			// @ts-ignore duplex needed for streaming request bodies
			duplex: "half",
		});
		return new Response(res.body, { status: res.status, headers: res.headers });
	} catch (e: any) {
		return c.json({ error: "EncryptID service unavailable" }, 502);
	}
});
app.all("/api/users/*", async (c) => {
	const targetUrl = `${ENCRYPTID_INTERNAL}${c.req.path}${new URL(c.req.url).search}`;
	const headers = new Headers(c.req.raw.headers);
	headers.delete("host");
	try {
		const res = await fetch(targetUrl, {
			method: c.req.method,
			headers,
			body: c.req.method !== "GET" && c.req.method !== "HEAD" ? c.req.raw.body : undefined,
			// @ts-ignore duplex needed for streaming request bodies
			duplex: "half",
		});
		return new Response(res.body, { status: res.status, headers: res.headers });
	} catch (e: any) {
		return c.json({ error: "EncryptID service unavailable" }, 502);
	}
});

// ── 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;
	let vis = (doc.meta.visibility || "private") as SpaceVisibility;
	if (slug === "demo") vis = "public";
	return {
		spaceSlug: slug,
		visibility: vis,
		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 verifyToken(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 = "private" } = body;

	const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
	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: "public",
		source: 'internal',
	});
	if (!result.ok) return c.json({ error: result.error }, result.status);

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

// POST /api/internal/sync-space-member — called by EncryptID after identity invite claim
// Syncs a member from EncryptID's space_members (PostgreSQL) to the Automerge doc
app.post("/api/internal/sync-space-member", async (c) => {
	const body = await c.req.json<{ spaceSlug: string; userDid: string; role: string; username?: string }>();
	if (!body.spaceSlug || !body.userDid || !body.role) {
		return c.json({ error: "spaceSlug, userDid, and role are required" }, 400);
	}
	try {
		await loadCommunity(body.spaceSlug);
		setMember(body.spaceSlug, body.userDid, body.role as any, body.username);
		return c.json({ ok: true });
	} catch (err: any) {
		console.error("sync-space-member error:", err.message);
		return c.json({ error: "Failed to sync member" }, 500);
	}
});

// POST /api/internal/mint-crdt — called by onramp-service after fiat payment confirmed
app.post("/api/internal/mint-crdt", async (c) => {
	const internalKey = c.req.header("X-Internal-Key");
	if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
		return c.json({ error: "Unauthorized" }, 401);
	}

	const body = await c.req.json<{
		did?: string;
		label?: string;
		amountDecimal?: string;
		txHash?: string;
		network?: string;
	}>();

	const { did, label, amountDecimal, txHash, network } = body;
	if (!did || !amountDecimal || !txHash || !network) {
		return c.json({ error: "did, amountDecimal, txHash, and network are required" }, 400);
	}

	const { mintFromOnChain } = await import("./token-service");
	const success = mintFromOnChain(did, label || "Unknown", amountDecimal, txHash, network);

	if (!success) {
		// mintFromOnChain returns false for duplicates or invalid amounts — both are idempotent
		return c.json({ ok: false, reason: "already minted or invalid amount" });
	}

	return c.json({ ok: true, minted: amountDecimal, did, txHash });
});

// POST /api/internal/escrow-burn — called by offramp-service to escrow cUSDC before payout
app.post("/api/internal/escrow-burn", async (c) => {
	const internalKey = c.req.header("X-Internal-Key");
	if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
		return c.json({ error: "Unauthorized" }, 401);
	}

	const body = await c.req.json<{
		did?: string;
		label?: string;
		amount?: number;
		offRampId?: string;
	}>();

	if (!body.did || !body.amount || !body.offRampId) {
		return c.json({ error: "did, amount, and offRampId are required" }, 400);
	}

	const { burnTokensEscrow, getTokenDoc, getBalance } = await import("./token-service");
	const doc = getTokenDoc("cusdc");
	if (!doc) return c.json({ error: "cUSDC token not found" }, 500);

	const balance = getBalance(doc, body.did);
	if (balance < body.amount) {
		return c.json({ error: `Insufficient balance: ${balance} < ${body.amount}` }, 400);
	}

	const ok = burnTokensEscrow(
		"cusdc", body.did, body.label || "", body.amount, body.offRampId,
		`Off-ramp withdrawal: ${body.offRampId}`,
	);
	if (!ok) return c.json({ error: "Escrow burn failed" }, 500);

	return c.json({ ok: true, escrowed: body.amount, offRampId: body.offRampId });
});

// POST /api/internal/confirm-offramp — called by offramp-service after payout confirmed/failed
app.post("/api/internal/confirm-offramp", async (c) => {
	const internalKey = c.req.header("X-Internal-Key");
	if (!INTERNAL_API_KEY || internalKey !== INTERNAL_API_KEY) {
		return c.json({ error: "Unauthorized" }, 401);
	}

	const body = await c.req.json<{
		offRampId?: string;
		status?: "confirmed" | "reversed";
	}>();

	if (!body.offRampId || !body.status) {
		return c.json({ error: "offRampId and status ('confirmed' | 'reversed') required" }, 400);
	}

	const { confirmBurn, reverseBurn } = await import("./token-service");
	if (body.status === "confirmed") {
		const ok = confirmBurn("cusdc", body.offRampId);
		return c.json({ ok, action: "confirmed" });
	} else {
		const ok = reverseBurn("cusdc", body.offRampId);
		return c.json({ ok, action: "reversed" });
	}
});

// 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", spaceAuthOpts());

	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", spaceAuthOpts());
		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", spaceAuthOpts());
		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/space-access/:slug — lightweight membership check for client-side gate
app.get("/api/space-access/:slug", async (c) => {
	const slug = c.req.param("slug");
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ access: false, reason: "not-authenticated" });
	let claims: EncryptIDClaims | null = null;
	try { claims = await verifyToken(token); } catch {}
	if (!claims) return c.json({ access: false, reason: "not-authenticated" });

	const config = await getSpaceConfig(slug);
	const vis = config?.visibility || "private";
	const resolved = await resolveCallerRole(slug, claims);

	if (vis === "private" && resolved && !resolved.isOwner && resolved.role === "viewer") {
		return c.json({ access: false, reason: "not-member", visibility: vis, role: resolved.role });
	}

	return c.json({
		access: true,
		visibility: vis,
		role: resolved?.role || "viewer",
		isOwner: resolved?.isOwner || false,
	});
});

// 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", spaceAuthOpts());

	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() });
});

// ── Module landing HTML API (for info popover) ──
app.get("/api/modules/:moduleId/landing", (c) => {
	const moduleId = c.req.param("moduleId");
	const mod = getModule(moduleId);
	if (!mod) return c.json({ error: "Module not found" }, 404);
	const html = mod.landingPage ? mod.landingPage() : `<p>${mod.description || "No description available."}</p>`;
	return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId });
});

// ── Comment Pin API ──
import { listAllUsersWithTrust, listSpaceMembers } from "../src/encryptid/db";

// Space members for @mention autocomplete
app.get("/:space/api/space-members", async (c) => {
	const space = c.req.param("space");
	try {
		const users = await listAllUsersWithTrust(space);
		return c.json({
			members: users.map((u) => ({
				did: u.did,
				username: u.username,
				displayName: u.displayName,
			})),
		});
	} catch {
		return c.json({ members: [] });
	}
});

// Space-wide comment notification (all members)
app.post("/:space/api/comment-pins/notify", async (c) => {
	const space = c.req.param("space");
	try {
		const body = await c.req.json();
		const { pinId, authorDid, authorName, text, pinIndex, isReply, mentionedDids, moduleId } = body;
		if (!pinId || !authorDid) {
			return c.json({ error: "Missing fields" }, 400);
		}

		const effectiveModule = moduleId || "rspace";
		const isModuleComment = effectiveModule !== "rspace";
		const members = await listSpaceMembers(space);
		const excludeDids = new Set<string>([authorDid, ...(mentionedDids || [])]);
		const title = isReply
			? `${authorName || "Someone"} replied to a comment`
			: `New comment from ${authorName || "Someone"}`;
		const preview = text ? text.slice(0, 120) + (text.length > 120 ? "..." : "") : "";

		// In-app + push notifications for all members (excluding author and @mentioned)
		await Promise.all(
			members
				.filter((m) => !excludeDids.has(m.userDID))
				.map((m) =>
					notify({
						userDid: m.userDID,
						category: "module",
						eventType: isModuleComment ? "module_comment" : "canvas_comment",
						title,
						body: preview || `Comment pin #${pinIndex || "?"} in ${space}`,
						spaceSlug: space,
						moduleId: effectiveModule,
						actionUrl: `/${effectiveModule}#pin-${pinId}`,
						actorDid: authorDid,
						actorUsername: authorName,
					}),
				),
		);

		// Email notification to all space members (fire-and-forget)
		import("../modules/rinbox/agent-notify")
			.then(({ sendSpaceNotification }) => {
				sendSpaceNotification(
					space,
					title,
					`<p>${preview}</p><p><a href="https://${space}.rspace.online/${effectiveModule}#pin-${pinId}">View comment</a></p>`,
				);
			})
			.catch(() => {});

		return c.json({ ok: true });
	} catch (err) {
		console.error("[comment-pins] notify-all error:", err);
		return c.json({ error: "Failed to send notifications" }, 500);
	}
});

// Mention notification
app.post("/:space/api/comment-pins/notify-mention", async (c) => {
	const space = c.req.param("space");
	try {
		const body = await c.req.json();
		const { pinId, authorDid, authorName, mentionedDids, pinIndex, moduleId } = body;
		if (!pinId || !authorDid || !mentionedDids?.length) {
			return c.json({ error: "Missing fields" }, 400);
		}
		const effectiveModule = moduleId || "rspace";
		const isModuleComment = effectiveModule !== "rspace";
		for (const did of mentionedDids) {
			await notify({
				userDid: did,
				category: "module",
				eventType: isModuleComment ? "module_mention" : "canvas_mention",
				title: `${authorName} mentioned you in a comment`,
				body: `Comment pin #${pinIndex || "?"} in ${space}`,
				spaceSlug: space,
				moduleId: effectiveModule,
				actionUrl: `/${effectiveModule}#pin-${pinId}`,
				actorDid: authorDid,
				actorUsername: authorName,
			});
		}
		return c.json({ ok: true });
	} catch (err) {
		console.error("[comment-pins] notify error:", err);
		return c.json({ error: "Failed to send notification" }, 500);
	}
});

// ── x402 test endpoint (payment-gated, supports on-chain + CRDT) ──
import { setupX402FromEnv } from "../shared/x402/hono-middleware";
import { setTokenVerifier } from "../shared/x402/crdt-scheme";
import { getBalance, getTokenDoc, transferTokens, mintFromOnChain } from "./token-service";

// Wire EncryptID JWT verifier into CRDT scheme
setTokenVerifier(async (token: string) => {
	const claims = await verifyToken(token);
	return { sub: claims.sub, did: claims.did as string | undefined, username: claims.username };
});

const x402Test = setupX402FromEnv({
	description: "x402 test endpoint",
	resource: "/api/x402-test",
	crdtPayment: {
		tokenId: "cusdc",
		amount: 10_000, // 0.01 cUSDC (6 decimals)
		payToDid: "did:key:treasury-main-rspace-dao-2026",
		payToLabel: "DAO Treasury",
		getBalance,
		getTokenDoc,
		transferTokens,
	},
	onPaymentSettled: async ({ paymentHeader, context }) => {
		// Direction 1: mint cUSDC after on-chain USDC payment
		try {
			const token = extractToken(context.req.raw.headers);
			if (!token) {
				console.warn("[x402 bridge] No JWT — skipping cUSDC mint (on-chain payment still valid)");
				return;
			}
			const claims = await verifyToken(token);
			const did = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
			const label = claims.username || did;
			const amount = process.env.X402_UPLOAD_PRICE || "0.01";
			const network = process.env.X402_NETWORK || "eip155:84532";
			// Use payment header hash as pseudo-txHash for idempotency
			const txHash = `x402-${Buffer.from(paymentHeader).toString("base64url").slice(0, 40)}`;
			mintFromOnChain(did, label, amount, txHash, network);
		} catch (e) {
			console.warn("[x402 bridge] cUSDC mint failed (non-fatal):", e);
		}
	},
});

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;
	}
	const scheme = c.get("x402Scheme") || "none";
	return c.json({ ok: true, scheme, message: "Payment received!", timestamp: new Date().toISOString() });
});

// ── Creative tools API endpoints ──

const FAL_KEY = process.env.FAL_KEY || "";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
const SPLAT_NOTIFY_EMAIL = process.env.SPLAT_NOTIFY_EMAIL || "";
const SITE_URL = process.env.SITE_URL || "https://rspace.online";

// ── 3D Gen job queue ──

import { createTransport } from "nodemailer";

interface Gen3DJob {
	id: string;
	status: "pending" | "processing" | "complete" | "failed";
	imageUrl: string;
	resultUrl?: string;
	resultFormat?: string;
	error?: string;
	createdAt: number;
	completedAt?: number;
	emailSent?: boolean;
	title?: string;
}

const gen3dJobs = new Map<string, Gen3DJob>();

// Clean up old jobs every 30 minutes (keep for 24h)
setInterval(() => {
	const cutoff = Date.now() - 24 * 60 * 60 * 1000;
	for (const [id, job] of gen3dJobs) {
		if (job.createdAt < cutoff) gen3dJobs.delete(id);
	}
}, 30 * 60 * 1000);

// ── Video generation job queue (async to avoid Cloudflare timeouts) ──

interface VideoGenJob {
	id: string;
	status: "pending" | "processing" | "complete" | "failed";
	type: "t2v" | "i2v";
	prompt: string;
	sourceImage?: string;
	resultUrl?: string;
	error?: string;
	createdAt: number;
	completedAt?: number;
	queuePosition?: number;
	falStatus?: string;
	model?: string;
	duration?: string;
	resolution?: string;
	aspectRatio?: string;
	generateAudio?: boolean;
}

const videoGenJobs = new Map<string, VideoGenJob>();

// Clean up old video jobs every 30 minutes (keep for 6h)
setInterval(() => {
	const cutoff = Date.now() - 6 * 60 * 60 * 1000;
	for (const [id, job] of videoGenJobs) {
		if (job.createdAt < cutoff) videoGenJobs.delete(id);
	}
}, 30 * 60 * 1000);

async function processVideoGenJob(job: VideoGenJob) {
	job.status = "processing";
	const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };

	try {
		let MODEL: string;
		let body: Record<string, any>;
		const m = job.model || (job.type === "i2v" ? "kling" : "wan");

		switch (m) {
			case "seedance":
			case "seedance-fast": {
				const fast = m === "seedance-fast" ? "/fast" : "";
				const mode = job.type === "i2v" ? "image-to-video" : "text-to-video";
				MODEL = `bytedance/seedance-2.0${fast}/${mode}`;
				body = {
					prompt: job.prompt,
					duration: job.duration || "5",
					resolution: job.resolution || "480p",
					aspect_ratio: job.aspectRatio || "16:9",
					generate_audio: job.generateAudio ?? false,
				};
				if (job.type === "i2v" && job.sourceImage) {
					body.image_url = job.sourceImage;
				}
				break;
			}
			case "kling":
				MODEL = "fal-ai/kling-video/v1/standard/image-to-video";
				body = { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" };
				break;
			case "wan":
			default:
				MODEL = "fal-ai/wan-t2v";
				body = { prompt: job.prompt, num_frames: 81, resolution: "480p" };
				break;
		}

		// Submit to fal.ai queue
		const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
			method: "POST",
			headers: falHeaders,
			body: JSON.stringify(body),
		});

		if (!submitRes.ok) {
			const errText = await submitRes.text();
			console.error(`[video-gen] fal.ai submit error (${job.type}):`, submitRes.status, errText);
			job.status = "failed";
			job.error = "Video generation failed to start";
			job.completedAt = Date.now();
			return;
		}

		const submitData = await submitRes.json() as { request_id: string; status_url?: string; response_url?: string };
		const request_id = submitData.request_id;
		const statusPollUrl = submitData.status_url || `https://queue.fal.run/${MODEL}/requests/${request_id}/status`;
		let responseUrl = submitData.response_url || "";
		console.log(`[video-gen] Job ${job.id} submitted (model=${MODEL}, reqId=${request_id})`);

		// Poll for completion (up to 10 min)
		const deadline = Date.now() + 600_000;
		let completed = false;

		while (Date.now() < deadline) {
			await new Promise((r) => setTimeout(r, 3000));
			const statusRes = await fetch(statusPollUrl, { headers: falHeaders });
			if (!statusRes.ok) {
				console.log(`[video-gen] Poll ${job.id}: status HTTP ${statusRes.status}`);
				continue;
			}
			const statusData = await statusRes.json() as { status: string; response_url?: string; queue_position?: number };
			console.log(`[video-gen] Poll ${job.id}: status=${statusData.status}`);
			job.falStatus = statusData.status;
			job.queuePosition = statusData.queue_position;
			if (statusData.response_url) responseUrl = statusData.response_url;
			if (statusData.status === "COMPLETED") { completed = true; break; }
			if (statusData.status === "FAILED") {
				job.status = "failed";
				job.error = "Video generation failed on fal.ai";
				job.completedAt = Date.now();
				return;
			}
		}

		if (!completed) {
			job.status = "failed";
			job.error = "Video generation timed out";
			job.completedAt = Date.now();
			return;
		}

		// Fetch result
		const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`;
		const resultRes = await fetch(resultUrl, { headers: falHeaders });
		if (!resultRes.ok) {
			job.status = "failed";
			job.error = "Failed to retrieve video";
			job.completedAt = Date.now();
			return;
		}

		const data = await resultRes.json();
		const videoUrl = data.video?.url || data.output?.url;
		if (!videoUrl) {
			console.error(`[video-gen] No video URL in response:`, JSON.stringify(data).slice(0, 500));
			job.status = "failed";
			job.error = "No video returned";
			job.completedAt = Date.now();
			return;
		}

		job.status = "complete";
		job.resultUrl = videoUrl;
		job.completedAt = Date.now();
		console.log(`[video-gen] Job ${job.id} complete: ${videoUrl}`);
	} catch (e: any) {
		console.error("[video-gen] error:", e.message);
		job.status = "failed";
		job.error = "Video generation failed";
		job.completedAt = Date.now();
	}
}

let splatMailTransport: ReturnType<typeof createTransport> | null = null;
if (process.env.SMTP_PASS) {
	splatMailTransport = createTransport({
		host: process.env.SMTP_HOST || "mail.rmail.online",
		port: Number(process.env.SMTP_PORT) || 587,
		secure: Number(process.env.SMTP_PORT) === 465,
		tls: { rejectUnauthorized: false },
		auth: {
			user: process.env.SMTP_USER || "noreply@rmail.online",
			pass: process.env.SMTP_PASS,
		},
	});
}

async function sendSplatEmail(job: Gen3DJob) {
	if (!splatMailTransport || !SPLAT_NOTIFY_EMAIL) return;
	const downloadUrl = `${SITE_URL}${job.resultUrl}`;
	const title = job.title || "3D Model";
	try {
		await splatMailTransport.sendMail({
			from: process.env.SMTP_FROM || "rSplat <noreply@rmail.online>",
			to: SPLAT_NOTIFY_EMAIL,
			subject: `Your 3D splat "${title}" is ready — rSplat`,
			html: `
				<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px;">
					<h2 style="color:#818cf8;">Your 3D model is ready!</h2>
					<p>Your AI-generated 3D model <strong>"${title}"</strong> has finished processing.</p>
					<p style="margin:24px 0;">
						<a href="${downloadUrl}" style="display:inline-block;padding:12px 24px;background:#818cf8;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">
							View &amp; Download
						</a>
					</p>
					<p>Or save it to your gallery at <a href="${SITE_URL}/rsplat">${SITE_URL}/rsplat</a></p>
					<p style="color:#64748b;font-size:13px;margin-top:32px;">Generated by rSplat on rSpace</p>
				</div>
			`,
		});
		job.emailSent = true;
		console.log(`[3d-gen] Email sent to ${SPLAT_NOTIFY_EMAIL} for job ${job.id}`);
	} catch (e: any) {
		console.error(`[3d-gen] Failed to send email:`, e.message);
	}
}

async function process3DGenJob(job: Gen3DJob) {
	job.status = "processing";
	const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
	const MODEL = "fal-ai/hunyuan3d-v21";

	try {
		// 1. Resize staged image with sharp (max 1024px) for reliable fal.ai processing
		let imageInput = job.imageUrl;
		const stagedMatch = job.imageUrl.match(/\/(?:api\/files|data\/files)\/generated\/([^?#]+)/);
		if (stagedMatch) {
			try {
				const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
				const srcPath = resolve(dir, stagedMatch[1]);
				const file = Bun.file(srcPath);
				if (await file.exists()) {
					const resizedBuf = await sharp(srcPath)
						.resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
						.jpeg({ quality: 90 })
						.toBuffer();
					const resizedName = `stage-resized-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jpg`;
					await Bun.write(resolve(dir, resizedName), resizedBuf);
					// Use public HTTPS URL for the resized image
					imageInput = `${PUBLIC_ORIGIN}/api/files/generated/${resizedName}`;
					console.log(`[3d-gen] Resized image: ${Math.round(resizedBuf.length / 1024)}KB → ${imageInput}`);
				}
			} catch (e: any) {
				console.warn(`[3d-gen] Resize failed, using original URL:`, e.message);
			}
		}

		// 2. Submit to fal.ai queue
		const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
			method: "POST",
			headers: falHeaders,
			body: JSON.stringify({ input_image_url: imageInput, textured_mesh: true, octree_resolution: 256 }),
		});
		if (!submitRes.ok) {
			const errText = await submitRes.text();
			console.error("[3d-gen] fal.ai submit error:", submitRes.status, errText);
			let detail = "3D generation failed to start";
			try {
				const parsed = JSON.parse(errText);
				if (parsed.detail) {
					detail = typeof parsed.detail === "string" ? parsed.detail
						: Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
						: detail;
				}
			} catch {}
			job.status = "failed";
			job.error = detail;
			job.completedAt = Date.now();
			return;
		}
		const { request_id } = await submitRes.json() as { request_id: string };

		// 3. Poll for completion (up to 8 min — Hunyuan3D with textures can take 3-5 min)
		const deadline = Date.now() + 480_000;
		let responseUrl = "";
		let completed = false;
		while (Date.now() < deadline) {
			await new Promise((r) => setTimeout(r, 3000));
			const statusRes = await fetch(
				`https://queue.fal.run/${MODEL}/requests/${request_id}/status`,
				{ headers: falHeaders },
			);
			if (!statusRes.ok) continue;
			const statusData = await statusRes.json() as { status: string; response_url?: string };
			console.log(`[3d-gen] Poll ${job.id}: status=${statusData.status}`);
			if (statusData.response_url) responseUrl = statusData.response_url;
			if (statusData.status === "COMPLETED") { completed = true; break; }
			if (statusData.status === "FAILED") {
				job.status = "failed";
				job.error = "3D generation failed on fal.ai";
				job.completedAt = Date.now();
				return;
			}
		}
		if (!completed) {
			job.status = "failed";
			job.error = "3D generation timed out";
			job.completedAt = Date.now();
			return;
		}

		// 4. Fetch result using response_url from status (with fallback)
		const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`;
		console.log(`[3d-gen] Fetching result from: ${resultUrl}`);
		let resultRes = await fetch(resultUrl, { headers: falHeaders });
		if (!resultRes.ok) {
			console.warn(`[3d-gen] Result fetch failed (status=${resultRes.status}), retrying in 3s...`);
			const errBody = await resultRes.text().catch(() => "");
			console.warn(`[3d-gen] Result error body:`, errBody);
			await new Promise((r) => setTimeout(r, 3000));
			resultRes = await fetch(resultUrl, { headers: falHeaders });
		}
		if (!resultRes.ok) {
			const errBody = await resultRes.text().catch(() => "");
			console.error(`[3d-gen] Result fetch failed after retry (status=${resultRes.status}):`, errBody);
			let detail = "Failed to retrieve 3D model";
			try {
				const parsed = JSON.parse(errBody);
				if (parsed.detail) {
					detail = Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
						: typeof parsed.detail === "string" ? parsed.detail : detail;
				}
			} catch {}
			job.status = "failed";
			job.error = detail;
			job.completedAt = Date.now();
			return;
		}

		const data = await resultRes.json();
		console.log(`[3d-gen] Result keys for ${job.id}:`, Object.keys(data));
		const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url || data.mesh?.url;
		if (!modelUrl) {
			console.error(`[3d-gen] No model URL found in response:`, JSON.stringify(data).slice(0, 500));
			job.status = "failed";
			job.error = "No 3D model returned";
			job.completedAt = Date.now();
			return;
		}

		// 5. Download and save
		const modelRes = await fetch(modelUrl);
		if (!modelRes.ok) {
			job.status = "failed";
			job.error = "Failed to download model";
			job.completedAt = Date.now();
			return;
		}

		const modelBuf = await modelRes.arrayBuffer();
		const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.glb`;
		const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
		await Bun.write(resolve(dir, filename), modelBuf);
		pinGeneratedFile(resolve(dir, filename), filename);

		job.status = "complete";
		job.resultUrl = `/data/files/generated/${filename}`;
		job.resultFormat = "glb";
		job.completedAt = Date.now();
		console.log(`[3d-gen] Job ${job.id} complete: ${job.resultUrl}`);

		// Send email notification
		sendSplatEmail(job).catch(() => {});
	} catch (e: any) {
		console.error("[3d-gen] error:", e.message);
		job.status = "failed";
		job.error = "3D generation failed";
		job.completedAt = Date.now();
	}
}

// ── Image helpers ──

/** Read a /data/files/generated/... path from disk → base64 */
async function readFileAsBase64(serverPath: string): Promise<string> {
	const filename = serverPath.split("/").pop();
	if (!filename) throw new Error("Invalid path");
	const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
	const file = Bun.file(resolve(dir, filename));
	if (!(await file.exists())) throw new Error("File not found: " + serverPath);
	const buf = await file.arrayBuffer();
	return Buffer.from(buf).toString("base64");
}

/** Save a data:image/... URL to disk → return server-relative URL */
async function saveDataUrlToDisk(dataUrl: string, prefix: string): Promise<string> {
	const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
	if (!match) throw new Error("Invalid data URL");
	const ext = match[1] === "jpeg" ? "jpg" : match[1];
	const b64 = match[2];
	const filename = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
	const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
	await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
	pinGeneratedFile(resolve(dir, filename), filename);
	return `/data/files/generated/${filename}`;
}

// Image generation via fal.ai Flux Pro (delegates to shared helper)
app.post("/api/image-gen", async (c) => {
	const { prompt, style } = await c.req.json();
	if (!prompt) return c.json({ error: "prompt required" }, 400);

	const { generateImageViaFal } = await import("./mi-media");
	const result = await generateImageViaFal(prompt, style);
	if (!result.ok) return c.json({ error: result.error }, 502);
	return c.json({ url: result.url, image_url: result.url });
});

// Upload image (data URL → disk)
app.post("/api/image-upload", async (c) => {
	const { image } = await c.req.json();
	if (!image) return c.json({ error: "image required" }, 400);
	try {
		const url = await saveDataUrlToDisk(image, "upload");
		return c.json({ url });
	} catch (e: any) {
		console.error("[image-upload]", e.message);
		return c.json({ error: e.message }, 400);
	}
});

// Analyze style from reference images via Gemini vision
app.post("/api/image-gen/analyze-style", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

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

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({
		model: "gemini-2.5-flash",
		generationConfig: { responseMimeType: "application/json" },
	});

	// Build multimodal parts
	const parts: any[] = [];
	for (const img of images) {
		let b64: string, mimeType: string;
		if (img.startsWith("data:")) {
			const match = img.match(/^data:(image\/\w+);base64,(.+)$/);
			if (!match) continue;
			mimeType = match[1];
			b64 = match[2];
		} else {
			// Server path
			b64 = await readFileAsBase64(img);
			mimeType = img.endsWith(".png") ? "image/png" : "image/jpeg";
		}
		parts.push({ inlineData: { data: b64, mimeType } });
	}

	parts.push({
		text: `Analyze the visual style of these reference images. ${context ? `Context: ${context}` : ""}

Return JSON with these fields:
{
  "style_description": "A detailed paragraph describing the overall visual style",
  "color_palette": ["#hex1", "#hex2", ...up to 6 dominant colors],
  "style_keywords": ["keyword1", "keyword2", ...up to 8 keywords],
  "brand_prompt_prefix": "A concise prompt prefix (under 50 words) that can be prepended to any image generation prompt to reproduce this style"
}

Focus on: color palette, textures, composition patterns, mood, typography style, illustration approach.`,
	});

	try {
		const result = await model.generateContent({ contents: [{ role: "user", parts }] });
		const text = result.response.text();
		const parsed = JSON.parse(text);
		return c.json(parsed);
	} catch (e: any) {
		console.error("[analyze-style] error:", e.message);
		return c.json({ error: "Style analysis failed" }, 502);
	}
});

// MakeReal: sketch-to-HTML via Gemini vision
app.post("/api/makereal", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

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

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });

	// Extract base64 from data URL
	const match = sketch_image.match(/^data:(image\/\w+);base64,(.+)$/);
	if (!match) return c.json({ error: "Invalid image data URL" }, 400);

	const frameworkInstructions: Record<string, string> = {
		html: "Use plain HTML and CSS only. No external dependencies.",
		tailwind: "Use Tailwind CSS via CDN (<script src=\"https://cdn.tailwindcss.com\"></script>). Use Tailwind utility classes for all styling.",
		react: "Use React via CDN (react, react-dom, babel-standalone). Include a single <script type=\"text/babel\"> block with the React component.",
	};

	const systemPrompt = `You are an expert frontend developer. Convert the wireframe sketch into a complete, functional HTML page.
Rules:
- Return ONLY the HTML code, no markdown fences, no explanation
- ${frameworkInstructions[framework] || frameworkInstructions.html}
- Make it visually polished with proper spacing, colors, and typography
- Include all styles inline or in a <style> block — the page must be fully self-contained
- Make interactive elements functional (buttons, inputs, toggles should work)
- Use modern CSS (flexbox/grid) for layout
- Match the layout and structure shown in the wireframe sketch as closely as possible`;

	const userPrompt = prompt || "Convert this wireframe into a working UI";

	try {
		const result = await model.generateContent({
			contents: [{
				role: "user",
				parts: [
					{ inlineData: { data: match[2], mimeType: match[1] } },
					{ text: `${systemPrompt}\n\nUser request: ${userPrompt}` },
				],
			}],
		});

		let htmlContent = result.response.text();
		// Strip markdown fences if present
		htmlContent = htmlContent.replace(/^```html?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();

		// Save to generated files
		const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
		const filename = `makereal-${Date.now()}.html`;
		await Bun.write(resolve(dir, filename), htmlContent);

		return c.json({ html: htmlContent, url: `/data/files/generated/${filename}` });
	} catch (e: any) {
		console.error("[makereal] error:", e.message);
		return c.json({ error: "HTML generation failed" }, 502);
	}
});

// img2img generation via fal.ai or Gemini
app.post("/api/image-gen/img2img", async (c) => {
	const { prompt, source_image, reference_images, provider = "fal", strength = 0.85, style, model: modelOverride } = await c.req.json();
	if (!prompt) return c.json({ error: "prompt required" }, 400);
	if (!source_image) return c.json({ error: "source_image required" }, 400);

	const fullPrompt = (style ? style + " " : "") + prompt;

	if (provider === "fal") {
		if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);

		try {
			// Save source to disk if data URL
			let sourceUrl: string;
			if (source_image.startsWith("data:")) {
				const path = await saveDataUrlToDisk(source_image, "img2img-src");
				sourceUrl = `https://rspace.online${path}`;
			} else if (source_image.startsWith("/")) {
				sourceUrl = `https://rspace.online${source_image}`;
			} else {
				sourceUrl = source_image;
			}

			let falEndpoint: string;
			let falBody: any;

			if (reference_images?.length) {
				// Multi-reference edit mode
				const imageUrls: string[] = [sourceUrl];
				for (const ref of reference_images) {
					if (ref.startsWith("data:")) {
						const path = await saveDataUrlToDisk(ref, "img2img-ref");
						imageUrls.push(`https://rspace.online${path}`);
					} else if (ref.startsWith("/")) {
						imageUrls.push(`https://rspace.online${ref}`);
					} else {
						imageUrls.push(ref);
					}
				}
				falEndpoint = "https://fal.run/fal-ai/flux-2/edit";
				falBody = { image_urls: imageUrls, prompt: fullPrompt };
			} else {
				// Single source img2img
				falEndpoint = "https://fal.run/fal-ai/flux/dev/image-to-image";
				falBody = { image_url: sourceUrl, prompt: fullPrompt, strength, num_inference_steps: 28 };
			}

			const res = await fetch(falEndpoint, {
				method: "POST",
				headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" },
				body: JSON.stringify(falBody),
			});

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

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

			// Download and save locally
			const imgRes = await fetch(resultUrl);
			if (!imgRes.ok) return c.json({ url: resultUrl, image_url: resultUrl });
			const imgBuf = await imgRes.arrayBuffer();
			const filename = `img2img-${Date.now()}.png`;
			const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
			await Bun.write(resolve(dir, filename), Buffer.from(imgBuf));
			pinGeneratedFile(resolve(dir, filename), filename);
			const localUrl = `/data/files/generated/${filename}`;
			return c.json({ url: localUrl, image_url: localUrl });
		} catch (e: any) {
			console.error("[img2img] fal error:", e.message);
			return c.json({ error: "img2img generation failed" }, 502);
		}
	}

	if (provider === "gemini") {
		if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

		const { GoogleGenAI } = await import("@google/genai");
		const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });

		const geminiModelMap: Record<string, string> = {
			"gemini-flash": "gemini-2.5-flash-image",
			"gemini-pro": "gemini-3-pro-image-preview",
			"gemini-flash-2": "gemini-3.1-flash-image-preview",
		};
		const modelName = geminiModelMap[modelOverride || "gemini-flash"] || "gemini-2.5-flash-image";

		try {
			// Build multimodal parts
			const parts: any[] = [{ text: fullPrompt }];

			// Add source image
			const addImage = async (img: string) => {
				let b64: string, mimeType: string;
				if (img.startsWith("data:")) {
					const match = img.match(/^data:(image\/\w+);base64,(.+)$/);
					if (!match) return;
					mimeType = match[1];
					b64 = match[2];
				} else {
					b64 = await readFileAsBase64(img);
					mimeType = img.endsWith(".png") ? "image/png" : "image/jpeg";
				}
				parts.push({ inlineData: { data: b64, mimeType } });
			};

			await addImage(source_image);
			if (reference_images?.length) {
				for (const ref of reference_images) await addImage(ref);
			}

			const result = await ai.models.generateContent({
				model: modelName,
				contents: [{ role: "user", parts }],
				config: { responseModalities: ["Text", "Image"] },
			});

			const resParts = result.candidates?.[0]?.content?.parts || [];
			for (const part of resParts) {
				if ((part as any).inlineData) {
					const { data: b64, mimeType } = (part as any).inlineData;
					const ext = mimeType?.includes("png") ? "png" : "jpg";
					const filename = `img2img-gemini-${Date.now()}.${ext}`;
					const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
					await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
					pinGeneratedFile(resolve(dir, filename), filename);
					const url = `/data/files/generated/${filename}`;
					return c.json({ url, image_url: url });
				}
			}

			return c.json({ error: "No image in Gemini response" }, 502);
		} catch (e: any) {
			console.error("[img2img] Gemini error:", e.message);
			return c.json({ error: "Gemini img2img failed" }, 502);
		}
	}

	return c.json({ error: `Unknown provider: ${provider}` }, 400);
});

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

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

	const jobId = crypto.randomUUID();
	const job: VideoGenJob = {
		id: jobId, status: "pending", type: "t2v",
		prompt, createdAt: Date.now(),
		model: model || "wan",
		duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio,
	};
	videoGenJobs.set(jobId, job);
	processVideoGenJob(job);
	return c.json({ job_id: jobId, status: "pending" });
});

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

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

	// Stage the source image if it's a data URL
	let imageUrl = image;
	if (image.startsWith("data:")) {
		const url = await saveDataUrlToDisk(image, "vid-src");
		imageUrl = publicUrl(c, url);
	} else if (image.startsWith("/")) {
		imageUrl = publicUrl(c, image);
	}

	const jobId = crypto.randomUUID();
	const job: VideoGenJob = {
		id: jobId, status: "pending", type: "i2v",
		prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(),
		model: model || "kling",
		duration, resolution, aspectRatio: aspect_ratio, generateAudio: generate_audio,
	};
	videoGenJobs.set(jobId, job);
	processVideoGenJob(job);
	return c.json({ job_id: jobId, status: "pending" });
});

// Poll video generation job status
app.get("/api/video-gen/:jobId", async (c) => {
	const jobId = c.req.param("jobId");
	const job = videoGenJobs.get(jobId);
	if (!job) return c.json({ error: "Job not found" }, 404);

	const response: Record<string, any> = {
		job_id: job.id, status: job.status, created_at: job.createdAt,
		fal_status: job.falStatus, queue_position: job.queuePosition,
	};
	if (job.status === "complete") {
		response.url = job.resultUrl;
		response.video_url = job.resultUrl;
		response.completed_at = job.completedAt;
	} else if (job.status === "failed") {
		response.error = job.error;
		response.completed_at = job.completedAt;
	}
	return c.json(response);
});

// Stage image for 3D generation (binary upload → HTTPS URL for fal.ai)
const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || "https://rspace.online";

/** Build public URL using the request's Host header so subdomains (jeff.rspace.online) work */
function publicUrl(c: any, path: string): string {
	const host = c.req.header("host") || new URL(PUBLIC_ORIGIN).host;
	// Always use https — site is behind Cloudflare/Traefik, x-forwarded-proto may be "http" internally
	return `https://${host}${path}`;
}

app.post("/api/image-stage", async (c) => {
	const ct = c.req.header("content-type") || "";
	console.log("[image-stage] Content-Type:", ct);

	let formData: FormData;
	try {
		formData = await c.req.formData();
	} catch (e: any) {
		console.error("[image-stage] formData parse failed:", e.message, "| Content-Type:", ct);
		// Fallback: try reading raw body as binary if it's not multipart
		if (ct.startsWith("application/octet-stream") || !ct.includes("multipart")) {
			const buf = Buffer.from(await c.req.arrayBuffer());
			if (!buf.length) return c.json({ error: "Empty body" }, 400);
			const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
			const filename = `stage-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jpg`;
			await Bun.write(resolve(dir, filename), buf);
			const url = publicUrl(c, `/api/files/generated/${filename}`);
			console.log("[image-stage] Staged (binary fallback):", url);
			return c.json({ url });
		}
		return c.json({ error: "Invalid upload format. Expected multipart/form-data." }, 400);
	}

	const file = formData.get("file") as File | null;
	if (!file) return c.json({ error: "file required" }, 400);

	// HEIC rejection
	const ext = file.name.split(".").pop()?.toLowerCase() || "";
	if (ext === "heic" || ext === "heif" || file.type === "image/heic" || file.type === "image/heif") {
		return c.json({ error: "HEIC files are not supported. Please convert to JPEG or PNG first." }, 400);
	}

	// Validate type
	const validTypes = ["image/jpeg", "image/png", "image/webp"];
	const validExts = ["jpg", "jpeg", "png", "webp"];
	if (!validTypes.includes(file.type) && !validExts.includes(ext)) {
		return c.json({ error: "Only JPEG, PNG, and WebP images are supported" }, 400);
	}

	// 15MB limit
	if (file.size > 15 * 1024 * 1024) {
		return c.json({ error: "Image too large. Maximum 15MB." }, 400);
	}

	const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
	const outExt = ext === "png" ? "png" : ext === "webp" ? "webp" : "jpg";
	const filename = `stage-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${outExt}`;
	const buf = Buffer.from(await file.arrayBuffer());
	await Bun.write(resolve(dir, filename), buf);

	const url = publicUrl(c, `/api/files/generated/${filename}`);
	console.log("[image-stage] Staged:", url, `(${buf.length} bytes)`);
	return c.json({ url });
});

// Image-to-3D via fal.ai Hunyuan3D v2.1 (async job queue)
app.post("/api/3d-gen", async (c) => {
	if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);

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

	const jobId = crypto.randomUUID();
	const job: Gen3DJob = {
		id: jobId,
		status: "pending",
		imageUrl: image_url,
		createdAt: Date.now(),
		title: title || "Untitled",
	};
	gen3dJobs.set(jobId, job);

	// Process in background — no await, returns immediately
	process3DGenJob(job);

	return c.json({ job_id: jobId, status: "pending" });
});

// Poll job status
app.get("/api/3d-gen/:jobId", async (c) => {
	const jobId = c.req.param("jobId");
	const job = gen3dJobs.get(jobId);
	if (!job) return c.json({ error: "Job not found" }, 404);

	const response: Record<string, any> = {
		job_id: job.id,
		status: job.status,
		created_at: job.createdAt,
	};

	if (job.status === "complete") {
		response.url = job.resultUrl;
		response.format = job.resultFormat;
		response.completed_at = job.completedAt;
		response.email_sent = job.emailSent || false;
		response.source_image = job.imageUrl;
	} else if (job.status === "failed") {
		response.error = job.error;
		response.completed_at = job.completedAt;
	}

	return c.json(response);
});

// Blender 3D generation via LLM + headless worker sidecar
const BLENDER_WORKER_URL = process.env.BLENDER_WORKER_URL || "http://blender-worker:8810";

app.get("/api/blender-gen/health", async (c) => {
	const issues: string[] = [];
	const warnings: string[] = [];
	if (!GEMINI_API_KEY) issues.push("GEMINI_API_KEY not configured");

	const running = await isSidecarRunning("blender-worker");
	if (!running) warnings.push("blender-worker stopped (will start on demand)");
	else {
		try {
			const res = await fetch(`${BLENDER_WORKER_URL}/health`, { signal: AbortSignal.timeout(3000) });
			if (!res.ok) warnings.push("blender-worker unhealthy");
		} catch {
			warnings.push("blender-worker unreachable");
		}
	}

	return c.json({ available: issues.length === 0, issues, warnings });
});

app.post("/api/blender-gen", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_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 Gemini Flash
	let script = "";

	try {
		const { GoogleGenerativeAI } = await import("@google/generative-ai");
		const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
		const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
		const result = await model.generateContent(`Generate a Blender Python script that creates: ${prompt}

The script should:
- Import bpy
- Clear the default scene (delete all default objects)
- Create the described objects with materials and colors
- Set up basic lighting (sun + area light) and camera positioned to frame the scene
- Use Cycles render engine with CPU device: bpy.context.scene.render.engine = "CYCLES" and bpy.context.scene.cycles.device = "CPU" and bpy.context.scene.cycles.samples = 64 and bpy.context.scene.cycles.use_denoising = False
- Render to /tmp/render.png at 1024x1024 with bpy.context.scene.render.image_settings.file_format = "PNG"

Output ONLY the Python code, no explanations or comments outside the code.`);

		const text = result.response.text();
		const codeMatch = text.match(/```(?:python)?\n([\s\S]*?)```/);
		script = codeMatch ? codeMatch[1].trim() : text.trim();
	} catch (e) {
		console.error("[blender-gen] Gemini error:", e);
	}

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

	// Step 2: Start sidecar on demand, execute on blender-worker
	try {
		await ensureSidecar("blender-worker");
		const workerRes = await fetch(`${BLENDER_WORKER_URL}/render`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ script }),
			signal: AbortSignal.timeout(95_000), // 95s — worker has 90s internal timeout
		});

		const data = await workerRes.json() as {
			success?: boolean;
			render_url?: string;
			error?: string;
			stdout?: string;
			stderr?: string;
		};

		markSidecarUsed("blender-worker");

		if (data.success && data.render_url) {
			return c.json({ script, render_url: data.render_url });
		}

		// Worker ran but render failed — return script + error details
		return c.json({
			script,
			render_url: null,
			error_detail: data.error || "Render failed",
		});
	} catch (e) {
		console.error("[blender-gen] worker error:", e);
		// Worker unreachable — return script only
		return c.json({ script, render_url: null, error_detail: "blender-worker unavailable" });
	}
});

// ── Blender Multi-User server status ──
const MULTIUSER_HOST = process.env.MULTIUSER_HOST || "159.195.32.209";
const MULTIUSER_PORT = 5555;

app.get("/api/blender-multiuser/status", async (c) => {
	let available = false;
	try {
		const net = await import("node:net");
		available = await new Promise<boolean>((resolve) => {
			const sock = net.createConnection({ host: "blender-multiuser", port: MULTIUSER_PORT }, () => {
				sock.destroy();
				resolve(true);
			});
			sock.setTimeout(2000);
			sock.on("timeout", () => { sock.destroy(); resolve(false); });
			sock.on("error", () => resolve(false));
		});
	} catch {}

	return c.json({
		available,
		host: MULTIUSER_HOST,
		port: MULTIUSER_PORT,
		instructions: "Install Multi-User addon from extensions.blender.org, connect to host:port in Blender",
	});
});

// ── Design Agent (Scribus DTP via Gemini tool loop) ──
const SCRIBUS_BRIDGE_URL = process.env.SCRIBUS_BRIDGE_URL || "http://scribus-novnc:8765";
const SCRIBUS_BRIDGE_SECRET = process.env.SCRIBUS_BRIDGE_SECRET || "";

const SCRIBUS_TOOLS = [
	{
		name: "new_document",
		description: "Create a new Scribus document with given page size and margins",
		parameters: {
			type: "object",
			properties: {
				width: { type: "number", description: "Page width in points (default 595 = A4)" },
				height: { type: "number", description: "Page height in points (default 842 = A4)" },
				margins: { type: "number", description: "Margin in points (default 40)" },
				pages: { type: "number", description: "Number of pages (default 1)" },
				unit: { type: "string", description: "Unit: pt, mm, in (default pt)" },
			},
		},
	},
	{
		name: "add_text_frame",
		description: "Add a text frame to the document",
		parameters: {
			type: "object",
			properties: {
				x: { type: "number", description: "X position in points" },
				y: { type: "number", description: "Y position in points" },
				width: { type: "number", description: "Frame width in points" },
				height: { type: "number", description: "Frame height in points" },
				text: { type: "string", description: "Text content" },
				fontSize: { type: "number", description: "Font size in points (default 12)" },
				fontName: { type: "string", description: "Font name (default: Arial Regular)" },
				name: { type: "string", description: "Unique frame name" },
			},
			required: ["x", "y", "width", "height", "text", "name"],
		},
	},
	{
		name: "add_image_frame",
		description: "Add an image frame to the document",
		parameters: {
			type: "object",
			properties: {
				x: { type: "number", description: "X position" },
				y: { type: "number", description: "Y position" },
				width: { type: "number", description: "Frame width" },
				height: { type: "number", description: "Frame height" },
				imagePath: { type: "string", description: "Path to image file inside container" },
				name: { type: "string", description: "Unique frame name" },
			},
			required: ["x", "y", "width", "height", "name"],
		},
	},
	{
		name: "add_shape",
		description: "Add a rectangle or ellipse shape to the document",
		parameters: {
			type: "object",
			properties: {
				x: { type: "number", description: "X position" },
				y: { type: "number", description: "Y position" },
				width: { type: "number", description: "Shape width" },
				height: { type: "number", description: "Shape height" },
				shapeType: { type: "string", description: "Shape type: rectangle or ellipse" },
				fillColor: { type: "string", description: "Fill color as hex (e.g. #ff0000)" },
				name: { type: "string", description: "Unique frame name" },
			},
			required: ["x", "y", "width", "height", "name"],
		},
	},
	{
		name: "set_background_color",
		description: "Set the page background color",
		parameters: {
			type: "object",
			properties: {
				color: { type: "string", description: "Hex color (e.g. #ffffff)" },
			},
			required: ["color"],
		},
	},
	{
		name: "move_frame",
		description: "Move a frame to a new position",
		parameters: {
			type: "object",
			properties: {
				name: { type: "string", description: "Frame name" },
				x: { type: "number", description: "New X position" },
				y: { type: "number", description: "New Y position" },
				relative: { type: "boolean", description: "If true, move relative to current position" },
			},
			required: ["name", "x", "y"],
		},
	},
	{
		name: "delete_frame",
		description: "Delete a frame by name",
		parameters: {
			type: "object",
			properties: {
				name: { type: "string", description: "Frame name to delete" },
			},
			required: ["name"],
		},
	},
	{
		name: "save_as_sla",
		description: "Save the document as a Scribus .sla file",
		parameters: {
			type: "object",
			properties: {
				space: { type: "string", description: "Space/project name for organizing files" },
				filename: { type: "string", description: "Filename without extension" },
			},
			required: ["space", "filename"],
		},
	},
	{
		name: "get_doc_state",
		description: "Get current document state including all frames and their properties",
		parameters: { type: "object", properties: {} },
	},
];

const SCRIBUS_SYSTEM_PROMPT = `You are a Scribus desktop publishing design assistant. You create professional print-ready layouts by calling Scribus tools step by step.

Available tools control a live Scribus document. Follow this workflow:
1. new_document — Create the page (default A4: 595×842 pt, or specify custom size)
2. set_background_color — Set page background if needed
3. add_text_frame — Add titles, headings, body text
4. add_shape — Add decorative shapes, dividers, color blocks
5. add_image_frame — Add image placeholders
6. get_doc_state — Verify the layout
7. save_as_sla — Save the final document

Design principles:
- Use clear visual hierarchy: large bold titles, medium subheads, smaller body text
- Leave generous margins (40+ pt) and whitespace
- Use shapes as backgrounds or accent elements behind text
- For flyers: big headline at top, key info in middle, call-to-action at bottom
- For posters: dramatic title, supporting image area, details below
- For cards: centered content, decorative border shapes
- Name every frame descriptively (e.g. "title", "subtitle", "hero-bg", "body-text")
- Standard A4 is 595×842 pt, US Letter is 612×792 pt
- Font sizes: titles 36-60pt, subtitles 18-24pt, body 10-14pt`;

async function callScribusBridge(action: string, args: Record<string, any> = {}): Promise<any> {
	const headers: Record<string, string> = { "Content-Type": "application/json" };
	if (SCRIBUS_BRIDGE_SECRET) headers["X-Bridge-Secret"] = SCRIBUS_BRIDGE_SECRET;

	const res = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/command`, {
		method: "POST",
		headers,
		body: JSON.stringify({ action, args }),
		signal: AbortSignal.timeout(30_000),
	});
	return res.json();
}

app.post("/api/design-agent", async (c) => {
	const { brief, space } = await c.req.json();
	if (!brief) return c.json({ error: "brief is required" }, 400);
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const encoder = new TextEncoder();
	const stream = new ReadableStream({
		async start(controller) {
			const send = (event: any) => {
				controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
			};

			try {
				// 1. Start Scribus sidecar
				send({ action: "starting_scribus", status: "Starting Scribus container..." });
				await ensureSidecar("scribus-novnc");

				// 2. Health check
				try {
					const healthRes = await fetch(`${SCRIBUS_BRIDGE_URL}/health`, { signal: AbortSignal.timeout(10_000) });
					const health = await healthRes.json() as any;
					if (!health.ok) {
						send({ action: "error", error: "Scribus bridge unhealthy" });
						controller.close();
						return;
					}
				} catch (e) {
					send({ action: "error", error: `Scribus bridge unreachable: ${e instanceof Error ? e.message : e}` });
					controller.close();
					return;
				}
				send({ action: "scribus_ready", status: "Scribus ready" });

				// 3. Initialize Gemini Flash with Scribus tools
				const { GoogleGenerativeAI } = await import("@google/generative-ai");
				const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
				const model = genAI.getGenerativeModel({
					model: "gemini-2.5-flash",
					systemInstruction: SCRIBUS_SYSTEM_PROMPT,
					generationConfig: { temperature: 0.3 },
					tools: [{ functionDeclarations: SCRIBUS_TOOLS as any }],
				});

				// 4. Agent loop
				const maxTurns = 15;
				const deadline = Date.now() + 90_000;
				let contents: any[] = [
					{ role: "user", parts: [{ text: `Design brief: ${brief}\n\nSpace/project: ${space || "demo"}\nCreate this design now using the available Scribus tools.` }] },
				];

				for (let turn = 0; turn < maxTurns; turn++) {
					if (Date.now() > deadline) {
						send({ action: "thinking", status: "Deadline reached, finishing up..." });
						break;
					}

					send({ action: "thinking", status: `Planning step ${turn + 1}...` });

					const result = await model.generateContent({ contents });
					const candidate = result.response.candidates?.[0];
					if (!candidate) break;

					const parts = candidate.content?.parts || [];
					const fnCalls = parts.filter((p: any) => p.functionCall);

					if (fnCalls.length === 0) {
						// Model is done — extract final text
						const finalText = parts.filter((p: any) => p.text).map((p: any) => p.text).join("\n");
						if (finalText) send({ action: "thinking", status: finalText });
						break;
					}

					// Execute each function call
					const fnResponseParts: any[] = [];
					for (const part of fnCalls) {
						const fc = part.functionCall!;
						const toolName = fc.name;
						const toolArgs = fc.args || {};

						send({ action: "executing", tool: toolName, status: `Executing ${toolName}` });
						console.log(`[design-agent] Turn ${turn + 1}: ${toolName}(${JSON.stringify(toolArgs).slice(0, 200)})`);

						let toolResult: any;
						try {
							toolResult = await callScribusBridge(toolName, toolArgs);
						} catch (err) {
							toolResult = { error: err instanceof Error ? err.message : String(err) };
						}

						send({ action: "tool_result", tool: toolName, result: toolResult });

						fnResponseParts.push({
							functionResponse: {
								name: toolName,
								response: { result: JSON.stringify(toolResult) },
							},
						});
					}

					contents.push({ role: "model", parts });
					contents.push({ role: "user", parts: fnResponseParts });
				}

				// 5. Get final document state
				let docState: any = null;
				try {
					const stateRes = await fetch(`${SCRIBUS_BRIDGE_URL}/api/scribus/state`, {
						headers: SCRIBUS_BRIDGE_SECRET ? { "X-Bridge-Secret": SCRIBUS_BRIDGE_SECRET } : {},
						signal: AbortSignal.timeout(10_000),
					});
					docState = await stateRes.json();
				} catch (e) {
					console.warn("[design-agent] Failed to get final state:", e);
				}

				send({ action: "done", status: "Design complete", state: docState });
				markSidecarUsed("scribus-novnc");
			} catch (e) {
				console.error("[design-agent] error:", e);
				send({ action: "error", error: e instanceof Error ? e.message : String(e) });
			}

			controller.close();
		},
	});

	return new Response(stream, {
		headers: {
			"Content-Type": "text/event-stream",
			"Cache-Control": "no-cache",
			Connection: "keep-alive",
		},
	});
});

// ── ASCII Art Generation (proxies to ascii-art service on rspace-internal) ──
const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000";

app.post("/api/ascii-gen", async (c) => {
	const body = await c.req.json();
	const { prompt, width, height, palette, output_format } = body;
	if (!prompt) return c.json({ error: "prompt required" }, 400);

	try {
		const res = await fetch(`${ASCII_ART_URL}/api/pattern`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				prompt,
				width: width || 80,
				height: height || 40,
				palette: palette || "classic",
				output_format: output_format || "html",
			}),
			signal: AbortSignal.timeout(30_000),
		});
		if (!res.ok) {
			const err = await res.text();
			return c.json({ error: `ASCII art service error: ${err}` }, res.status as any);
		}
		// Service returns raw HTML — wrap in JSON for the client
		const htmlContent = await res.text();
		// Strip HTML tags to get plain text version
		const textContent = htmlContent.replace(/<[^>]*>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
		return c.json({ html: htmlContent, text: textContent });
	} catch (e: any) {
		console.error("[ascii-gen] error:", e);
		return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502);
	}
});

app.post("/api/ascii-gen/render", async (c) => {
	try {
		const res = await fetch(`${ASCII_ART_URL}/api/render`, {
			method: "POST",
			headers: { "Content-Type": c.req.header("Content-Type") || "application/json" },
			body: await c.req.arrayBuffer(),
			signal: AbortSignal.timeout(30_000),
		});
		if (!res.ok) {
			const err = await res.text();
			return c.json({ error: `ASCII render error: ${err}` }, res.status as any);
		}
		const htmlContent = await res.text();
		const textContent = htmlContent.replace(/<[^>]*>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
		return c.json({ html: htmlContent, text: textContent });
	} catch (e: any) {
		console.error("[ascii-gen] render error:", e);
		return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502);
	}
});

// KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator";
import { ensureSidecar, markSidecarUsed, isSidecarRunning, startIdleWatcher } from "./sidecar-manager";

const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/mcp";
let kicadClient: Client | null = null;

async function getKicadClient(): Promise<Client> {
	await ensureSidecar("kicad-mcp");
	if (kicadClient) return kicadClient;

	const transport = new StreamableHTTPClientTransport(new URL(KICAD_MCP_URL));
	const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });

	transport.onclose = () => { kicadClient = null; };
	transport.onerror = () => { kicadClient = null; };

	await client.connect(transport);
	kicadClient = client;
	return client;
}

app.get("/api/kicad/health", async (c) => {
	try {
		const running = await isSidecarRunning("kicad-mcp");
		if (!running) return c.json({ available: false, status: "stopped (starts on demand)" });
		const client = await getKicadClient();
		const tools = await client.listTools();
		return c.json({ available: true, tools: tools.tools.length });
	} catch (e) {
		return c.json({ available: false, error: e instanceof Error ? e.message : "Connection failed" });
	}
});

// KiCAD AI-orchestrated design generation
app.post("/api/kicad/generate", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

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

	const enrichedPrompt = components?.length
		? `${prompt}\n\nKey components to use: ${components.join(", ")}`
		: prompt;

	try {
		const client = await getKicadClient();
		const orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY);
		const result = assembleKicadResult(orch);
		markSidecarUsed("kicad-mcp");

		return c.json({
			schematic_svg: result.schematicSvg,
			board_svg: result.boardSvg,
			gerber_url: result.gerberUrl,
			bom_url: result.bomUrl,
			pdf_url: result.pdfUrl,
			drc: result.drcResults,
			summary: result.summary,
		});
	} catch (e) {
		console.error("[kicad/generate] error:", e);
		kicadClient = null;
		return c.json({ error: e instanceof Error ? e.message : "KiCAD generation failed" }, 502);
	}
});

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 client = await getKicadClient();
		const result = await client.callTool({ name: action, arguments: body });

		// Extract text content and parse as JSON if possible
		const content = result.content as Array<{ type: string; text?: string }>;
		const textContent = content
			?.filter((c) => c.type === "text")
			.map((c) => c.text)
			.join("\n");

		try {
			return c.json(JSON.parse(textContent || "{}"));
		} catch {
			return c.json({ result: textContent });
		}
	} catch (e) {
		console.error(`[kicad/${action}] MCP error:`, e);
		kicadClient = null;
		return c.json({ error: "KiCAD MCP server not available" }, 503);
	}
});

// FreeCAD parametric CAD — MCP StreamableHTTP bridge (sidecar container)
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/mcp";
let freecadClient: Client | null = null;

async function getFreecadClient(): Promise<Client> {
	await ensureSidecar("freecad-mcp");
	if (freecadClient) return freecadClient;

	const transport = new StreamableHTTPClientTransport(new URL(FREECAD_MCP_URL));
	const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });

	transport.onclose = () => { freecadClient = null; };
	transport.onerror = () => { freecadClient = null; };

	await client.connect(transport);
	freecadClient = client;
	return client;
}

app.get("/api/freecad/health", async (c) => {
	try {
		const running = await isSidecarRunning("freecad-mcp");
		if (!running) return c.json({ available: false, status: "stopped (starts on demand)" });
		const client = await getFreecadClient();
		const tools = await client.listTools();
		return c.json({ available: true, tools: tools.tools.length });
	} catch (e) {
		return c.json({ available: false, error: e instanceof Error ? e.message : "Connection failed" });
	}
});

// FreeCAD AI-orchestrated CAD generation
app.post("/api/freecad/generate", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

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

	try {
		const client = await getFreecadClient();
		const orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY);
		const result = assembleFreecadResult(orch);
		markSidecarUsed("freecad-mcp");

		return c.json({
			preview_url: result.previewUrl,
			step_url: result.stepUrl,
			stl_url: result.stlUrl,
			summary: result.summary,
		});
	} catch (e) {
		console.error("[freecad/generate] error:", e);
		freecadClient = null;
		return c.json({ error: e instanceof Error ? e.message : "FreeCAD generation failed" }, 502);
	}
});

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

	const validActions = [
		"generate", "create_box", "create_cylinder", "create_sphere",
		"boolean_operation", "save_document", "list_objects", "execute_script",
		"export_step", "export_stl", "update_parameters",
	];

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

	try {
		const client = await getFreecadClient();
		const result = await client.callTool({ name: action, arguments: body });

		const fcContent = result.content as Array<{ type: string; text?: string }>;
		const textContent = fcContent
			?.filter((c) => c.type === "text")
			.map((c) => c.text)
			.join("\n");

		try {
			return c.json(JSON.parse(textContent || "{}"));
		} catch {
			return c.json({ result: textContent });
		}
	} catch (e) {
		console.error(`[freecad/${action}] MCP error:`, e);
		freecadClient = null;
		return c.json({ error: "FreeCAD MCP server not available" }, 503);
	}
});

// ── Multi-provider prompt endpoint ──
const GEMINI_MODELS: Record<string, string> = {
	"gemini-flash": "gemini-2.5-flash",
	"gemini-pro": "gemini-2.5-pro",
};
const OLLAMA_MODELS: Record<string, string> = {
	"llama3.2": "llama3.2:3b",
	"llama3.1": "llama3.1:8b",
	"qwen2.5-coder": "qwen2.5-coder:7b",
	"mistral-small": "mistral-small:24b",
};

const CANVAS_TOOLS_SYSTEM_PROMPT = `You are a helpful AI assistant in rSpace, a collaborative canvas workspace.
When the user asks to create, show, display, or visualize something, use the available tools to spawn shapes on the canvas.
After creating shapes, give a brief summary of what you placed. Only create shapes directly relevant to the request.
For text-only questions (explanations, coding help, math), respond with text — don't create shapes unless asked.`;

app.post("/api/prompt", async (c) => {
	const { messages, model = "gemini-flash", useTools = false, systemPrompt, enabledModules } = await c.req.json();
	if (!messages?.length) return c.json({ error: "messages required" }, 400);

	// Determine provider
	if (GEMINI_MODELS[model]) {
		if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

		const { GoogleGenerativeAI } = await import("@google/generative-ai");
		const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);

		// Build model config with optional tools
		const modelConfig: any = { model: GEMINI_MODELS[model] };
		if (useTools) {
			const { getToolsForModules } = await import("../lib/canvas-tools");
			const tools = getToolsForModules(enabledModules ?? null);
			modelConfig.tools = [{ functionDeclarations: tools.map(t => t.declaration) }];
			modelConfig.systemInstruction = systemPrompt || CANVAS_TOOLS_SYSTEM_PROMPT;
		}
		const geminiModel = genAI.getGenerativeModel(modelConfig);

		// Convert chat messages to Gemini contents format (with optional images)
		const contents = messages.map((m: { role: string; content: string; images?: string[] }) => {
			const parts: any[] = [{ text: m.content }];
			if (m.images?.length) {
				for (const img of m.images) {
					const match = img.match(/^data:(image\/\w+);base64,(.+)$/);
					if (match) {
						parts.push({ inlineData: { data: match[2], mimeType: match[1] } });
					}
				}
			}
			return { role: m.role === "assistant" ? "model" : "user", parts };
		});

		try {
			if (!useTools) {
				const result = await geminiModel.generateContent({ contents });
				const text = result.response.text();
				return c.json({ content: text });
			}

			// Multi-turn tool loop (max 5 rounds)
			const toolCalls: { name: string; args: Record<string, any>; label: string }[] = [];
			const { findTool } = await import("../lib/canvas-tools");
			let loopContents = [...contents];

			for (let turn = 0; turn < 5; turn++) {
				const result = await geminiModel.generateContent({ contents: loopContents });
				const response = result.response;
				const candidate = response.candidates?.[0];
				if (!candidate) break;

				const parts = candidate.content?.parts || [];
				const fnCalls = parts.filter((p: any) => p.functionCall);

				if (fnCalls.length === 0) {
					// No more tool calls — extract final text
					const text = response.text();
					return c.json({ content: text, toolCalls });
				}

				// Record tool calls and build function responses
				const fnResponseParts: any[] = [];
				for (const part of fnCalls) {
					const fc = part.functionCall!;
					const tool = findTool(fc.name);
					const label = tool?.actionLabel(fc.args) || fc.name;
					toolCalls.push({ name: fc.name, args: fc.args, label });
					fnResponseParts.push({
						functionResponse: {
							name: fc.name,
							response: { success: true, message: `${label} — shape will be created on the canvas.` },
						},
					});
				}

				// Append model turn + function responses for next iteration
				loopContents.push({ role: "model", parts });
				loopContents.push({ role: "user", parts: fnResponseParts });
			}

			// Exhausted loop — return what we have
			return c.json({ content: "I've set up the requested items on your canvas.", toolCalls });
		} catch (e: any) {
			console.error("[prompt] Gemini error:", e.message);
			return c.json({ error: "Gemini request failed" }, 502);
		}
	}

	if (OLLAMA_MODELS[model]) {
		try {
			await ensureSidecar("ollama");
			const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({
					model: OLLAMA_MODELS[model],
					messages: messages.map((m: { role: string; content: string }) => ({
						role: m.role,
						content: m.content,
					})),
					stream: false,
				}),
			});

			if (!ollamaRes.ok) {
				const err = await ollamaRes.text();
				console.error("[prompt] Ollama error:", err);
				return c.json({ error: "Ollama request failed" }, 502);
			}

			const data = await ollamaRes.json();
			markSidecarUsed("ollama");
			return c.json({ content: data.message?.content || "" });
		} catch (e: any) {
			console.error("[prompt] Ollama unreachable:", e.message);
			return c.json({ error: "Ollama unreachable" }, 503);
		}
	}

	return c.json({ error: `Unknown model: ${model}` }, 400);
});

// ── Trip AI Prompt (executes trip tools server-side) ──
app.post("/api/trips/ai-prompt", async (c) => {
	const { messages, model = "gemini-flash", systemPrompt } = await c.req.json();
	if (!messages?.length) return c.json({ error: "messages required" }, 400);

	if (!GEMINI_MODELS[model]) return c.json({ error: `Unsupported model: ${model}` }, 400);
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);

	// Combine canvas tools (create_destination, etc.) + trip tools (search_flights, etc.)
	const { CANVAS_TOOL_DECLARATIONS } = await import("../lib/canvas-tools");
	const { TRIP_TOOL_DECLARATIONS, findTripTool } = await import("../lib/trip-ai-tools");
	const { findTool } = await import("../lib/canvas-tools");
	const allDeclarations = [...CANVAS_TOOL_DECLARATIONS, ...TRIP_TOOL_DECLARATIONS];

	const geminiModel = genAI.getGenerativeModel({
		model: GEMINI_MODELS[model],
		tools: [{ functionDeclarations: allDeclarations as any }],
		systemInstruction: systemPrompt || "You are a travel planning assistant.",
	});

	const contents = messages.map((m: { role: string; content: string }) => ({
		role: m.role === "assistant" ? "model" : "user",
		parts: [{ text: m.content }],
	}));

	const OSRM_URL = process.env.OSRM_URL || "http://osrm-backend:5000";

	try {
		const toolCalls: { name: string; args: Record<string, any>; label: string }[] = [];
		const toolResults: Record<string, any> = {};
		let loopContents = [...contents];

		for (let turn = 0; turn < 5; turn++) {
			const result = await geminiModel.generateContent({ contents: loopContents });
			const response = result.response;
			const candidate = response.candidates?.[0];
			if (!candidate) break;

			const parts = candidate.content?.parts || [];
			const fnCalls = parts.filter((p: any) => p.functionCall);

			if (fnCalls.length === 0) {
				const text = response.text();
				return c.json({ content: text, toolCalls, toolResults });
			}

			const fnResponseParts: any[] = [];
			for (const part of fnCalls) {
				const fc = part.functionCall!;
				const tripTool = findTripTool(fc.name);
				if (tripTool) {
					// Execute server-side and return real data
					const data = await tripTool.execute(fc.args, { osrmUrl: OSRM_URL });
					const label = tripTool.actionLabel(fc.args);
					toolCalls.push({ name: fc.name, args: fc.args, label });
					toolResults[`${fc.name}_${toolCalls.length}`] = data;
					fnResponseParts.push({
						functionResponse: { name: fc.name, response: data },
					});
				} else {
					// Canvas tool — acknowledge only
					const canvasTool = findTool(fc.name);
					const label = canvasTool?.actionLabel(fc.args) || fc.name;
					toolCalls.push({ name: fc.name, args: fc.args, label });
					fnResponseParts.push({
						functionResponse: {
							name: fc.name,
							response: { success: true, message: `${label} — item will be created.` },
						},
					});
				}
			}

			loopContents.push({ role: "model", parts });
			loopContents.push({ role: "user", parts: fnResponseParts });
		}

		return c.json({ content: "I've set up the requested items.", toolCalls, toolResults });
	} catch (e: any) {
		console.error("[trips/ai-prompt] Gemini error:", e.message);
		return c.json({ error: "Gemini request failed" }, 502);
	}
});

// ── Gemini image generation ──
app.post("/api/gemini/image", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

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

	const styleHints: Record<string, string> = {
		photorealistic: "photorealistic, high detail, natural lighting, ",
		illustration: "digital illustration, clean lines, vibrant colors, ",
		painting: "oil painting style, brushstrokes visible, painterly, ",
		sketch: "pencil sketch, hand-drawn, line art, ",
		"punk-zine": "punk zine aesthetic, xerox texture, high contrast, DIY, rough edges, ",
		collage: "cut-and-paste collage, mixed media, layered paper textures, ",
		vintage: "vintage aesthetic, retro colors, aged paper texture, ",
		minimalist: "minimalist design, simple shapes, limited color palette, ",
	};
	const enhancedPrompt = (styleHints[style] || "") + prompt;

	const { GoogleGenAI } = await import("@google/genai");
	const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });

	// Build multimodal contents if reference images provided
	let contentsPayload: any = enhancedPrompt;
	if (reference_images?.length) {
		const parts: any[] = [{ text: enhancedPrompt + "\n\nUse these reference images as style guidance:" }];
		for (const ref of reference_images) {
			let b64: string, mimeType: string;
			if (ref.startsWith("data:")) {
				const match = ref.match(/^data:(image\/\w+);base64,(.+)$/);
				if (!match) continue;
				mimeType = match[1];
				b64 = match[2];
			} else {
				b64 = await readFileAsBase64(ref);
				mimeType = ref.endsWith(".png") ? "image/png" : "image/jpeg";
			}
			parts.push({ inlineData: { data: b64, mimeType } });
		}
		contentsPayload = [{ role: "user", parts }];
	}

	const models = ["gemini-2.5-flash-image", "imagen-3.0-generate-002"];
	for (const modelName of models) {
		try {
			if (modelName.startsWith("gemini")) {
				const result = await ai.models.generateContent({
					model: modelName,
					contents: contentsPayload,
					config: { responseModalities: ["Text", "Image"] },
				});

				const parts = result.candidates?.[0]?.content?.parts || [];
				for (const part of parts) {
					if ((part as any).inlineData) {
						const { data: b64, mimeType } = (part as any).inlineData;
						const ext = mimeType?.includes("png") ? "png" : "jpg";
						const filename = `gemini-${Date.now()}.${ext}`;
						const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
						await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
						pinGeneratedFile(resolve(dir, filename), filename);
						const url = `/data/files/generated/${filename}`;
						return c.json({ url, image_url: url });
					}
				}
			} else {
				// Imagen API
				const result = await ai.models.generateImages({
					model: modelName,
					prompt: enhancedPrompt,
					config: {
						numberOfImages: 1,
						aspectRatio: aspect_ratio || "3:4",
					},
				});
				const img = (result as any).generatedImages?.[0];
				if (img?.image?.imageBytes) {
					const filename = `imagen-${Date.now()}.png`;
					const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
					await Bun.write(resolve(dir, filename), Buffer.from(img.image.imageBytes, "base64"));
					pinGeneratedFile(resolve(dir, filename), filename);
					const url = `/data/files/generated/${filename}`;
					return c.json({ url, image_url: url });
				}
			}
		} catch (e: any) {
			console.error(`[gemini/image] ${modelName} error:`, e.message);
			continue;
		}
	}

	return c.json({ error: "All Gemini image models failed" }, 502);
});

// ── Zine generation endpoints ──

const ZINE_STYLES: Record<string, string> = {
	"punk-zine": "Xerox texture, high contrast B&W, DIY collage, hand-drawn typography",
	mycelial: "Organic networks, spore patterns, earth tones, fungal textures, interconnected webs",
	minimal: "Clean lines, white space, modern sans-serif, subtle gradients",
	collage: "Layered imagery, mixed media textures, vintage photographs",
	retro: "1970s aesthetic, earth tones, groovy typography, halftone patterns",
	academic: "Diagram-heavy, annotated illustrations, infographic elements",
};

const ZINE_TONES: Record<string, string> = {
	rebellious: "Defiant, anti-establishment, punk energy",
	regenerative: "Hopeful, nature-inspired, healing, systems-thinking",
	playful: "Fun, whimsical, approachable",
	informative: "Educational, clear, accessible",
	poetic: "Lyrical, metaphorical, evocative",
};

app.post("/api/zine/outline", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const { topic, style = "punk-zine", tone = "informative", pageCount = 8 } = await c.req.json();
	if (!topic) return c.json({ error: "topic required" }, 400);

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" });

	const systemPrompt = `You are a zine designer creating a ${pageCount}-page MycroZine.
Style: ${ZINE_STYLES[style] || style}
Tone: ${ZINE_TONES[tone] || tone}

Create a structured outline for a zine about: "${topic}"

Each page has sections that will be generated independently. Return ONLY valid JSON (no markdown fences):
{
  "pages": [
    {
      "pageNumber": 1,
      "type": "cover",
      "title": "...",
      "sections": [
        { "id": "headline", "type": "text", "content": "Main headline text" },
        { "id": "subhead", "type": "text", "content": "Subtitle or tagline" },
        { "id": "hero", "type": "image", "imagePrompt": "Detailed image description for cover art" }
      ],
      "hashtags": ["#tag1"]
    },
    {
      "pageNumber": 2,
      "type": "content",
      "title": "...",
      "sections": [
        { "id": "heading", "type": "text", "content": "Section heading" },
        { "id": "body", "type": "text", "content": "Main body text (2-3 paragraphs)" },
        { "id": "pullquote", "type": "text", "content": "A punchy pull quote" },
        { "id": "visual", "type": "image", "imagePrompt": "Detailed description for page illustration" }
      ],
      "hashtags": ["#tag"]
    }
  ]
}

Rules:
- Page 1 is always "cover" type with headline, subhead, hero image
- Pages 2-${pageCount - 1} are "content" with heading, body, pullquote, visual
- Page ${pageCount} is "cta" (call-to-action) with heading, body, action, visual
- Each image prompt should be highly specific, matching the ${style} style
- Text content should match the ${tone} tone
- Make body text substantive (2-3 short paragraphs each)`;

	try {
		const result = await model.generateContent(systemPrompt);
		const text = result.response.text();
		// Parse JSON (strip markdown fences if present)
		const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
		const outline = JSON.parse(jsonStr);
		return c.json(outline);
	} catch (e: any) {
		console.error("[zine/outline] error:", e.message);
		return c.json({ error: "Failed to generate outline" }, 502);
	}
});

app.post("/api/zine/page", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const { outline, style = "punk-zine", tone = "informative" } = await c.req.json();
	if (!outline?.pageNumber) return c.json({ error: "outline required with pageNumber" }, 400);

	// Find all image sections and generate them
	const imageSections = (outline.sections || []).filter((s: any) => s.type === "image");
	const generatedImages: Record<string, string> = {};

	const { GoogleGenAI } = await import("@google/genai");
	const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });

	for (const section of imageSections) {
		const styleDesc = ZINE_STYLES[style] || style;
		const toneDesc = ZINE_TONES[tone] || tone;
		const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${outline.title}".`;

		try {
			const result = await ai.models.generateContent({
				model: "gemini-2.5-flash-image",
				contents: enhancedPrompt,
				config: { responseModalities: ["Text", "Image"] },
			});

			const parts = result.candidates?.[0]?.content?.parts || [];
			for (const part of parts) {
				if ((part as any).inlineData) {
					const { data: b64, mimeType } = (part as any).inlineData;
					const ext = mimeType?.includes("png") ? "png" : "jpg";
					const filename = `zine-p${outline.pageNumber}-${section.id}-${Date.now()}.${ext}`;
					const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
					await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
					pinGeneratedFile(resolve(dir, filename), filename);
					generatedImages[section.id] = `/data/files/generated/${filename}`;
					break;
				}
			}
		} catch (e: any) {
			console.error(`[zine/page] Image gen failed for ${section.id}:`, e.message);
		}
	}

	return c.json({
		pageNumber: outline.pageNumber,
		images: generatedImages,
		outline,
	});
});

app.post("/api/zine/regenerate-section", async (c) => {
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const { section, pageTitle, style = "punk-zine", tone = "informative", feedback = "" } = await c.req.json();
	if (!section?.id) return c.json({ error: "section with id required" }, 400);

	if (section.type === "image") {
		// Regenerate image with feedback
		const { GoogleGenAI } = await import("@google/genai");
		const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });

		const styleDesc = ZINE_STYLES[style] || style;
		const toneDesc = ZINE_TONES[tone] || tone;
		const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${pageTitle}".${feedback ? ` User feedback: ${feedback}` : ""}`;

		try {
			const result = await ai.models.generateContent({
				model: "gemini-2.5-flash-image",
				contents: enhancedPrompt,
				config: { responseModalities: ["Text", "Image"] },
			});

			const parts = result.candidates?.[0]?.content?.parts || [];
			for (const part of parts) {
				if ((part as any).inlineData) {
					const { data: b64, mimeType } = (part as any).inlineData;
					const ext = mimeType?.includes("png") ? "png" : "jpg";
					const filename = `zine-regen-${section.id}-${Date.now()}.${ext}`;
					const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
					await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
					pinGeneratedFile(resolve(dir, filename), filename);
					return c.json({ sectionId: section.id, type: "image", url: `/data/files/generated/${filename}` });
				}
			}
			return c.json({ error: "No image generated" }, 502);
		} catch (e: any) {
			console.error("[zine/regenerate-section] image error:", e.message);
			return c.json({ error: "Image regeneration failed" }, 502);
		}
	}

	if (section.type === "text") {
		// Regenerate text via Gemini
		const { GoogleGenerativeAI } = await import("@google/generative-ai");
		const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
		const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });

		const toneDesc = ZINE_TONES[tone] || tone;
		const prompt = `Rewrite this zine text section. Section type: "${section.id}". Page title: "${pageTitle}". Tone: ${toneDesc}.
Current text: "${section.content}"
${feedback ? `User feedback: ${feedback}` : "Make it punchier and more engaging."}

Return ONLY the new text, no quotes or explanation.`;

		try {
			const result = await model.generateContent(prompt);
			return c.json({ sectionId: section.id, type: "text", content: result.response.text().trim() });
		} catch (e: any) {
			console.error("[zine/regenerate-section] text error:", e.message);
			return c.json({ error: "Text regeneration failed" }, 502);
		}
	}

	return c.json({ error: `Unknown section type: ${section.type}` }, 400);
});

// ── 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 verifyToken(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: "private",
		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" rendered by renderShell
// with the actual visibility from the space config, so the client-side access gate
// can block content for private 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 || "private";
	if (vis === "private") return; // Shell already defaults to private
	const html = await c.res.text();
	c.res = new Response(
		html.replace(
			'data-space-visibility="private"',
			`data-space-visibility="${vis}"`,
		),
		{ status: c.res.status, headers: c.res.headers },
	);
});

// ── Template seeding route: /:space/:moduleId/template ──
// Triggers template seeding for empty spaces, then redirects to the module page.
app.get("/:space/:moduleId/template", async (c) => {
	const space = c.req.param("space");
	const moduleId = c.req.param("moduleId");
	if (!space || space === "api" || space.includes(".")) return c.notFound();

	try {
		await loadCommunity(space);
		const seeded = seedTemplateShapes(space);
		if (seeded) {
			broadcastAutomergeSync(space);
			broadcastJsonSnapshot(space);
		}
		// Seed module-specific demo data (calendar events, notes, tasks, etc.)
		const mod = getModule(moduleId);
		if (mod?.seedTemplate) mod.seedTemplate(space);
	} catch (e) {
		console.error(`[Template] On-demand seed failed for "${space}":`, e);
	}

	const redirectPath = c.get("isSubdomain") ? `/${moduleId}` : `/${space}/${moduleId}`;
	return c.redirect(redirectPath, 302);
});

// ── Empty-state detection for onboarding ──

import type { RSpaceModule } from "../shared/module";
import { resolveDataSpace } from "../shared/scope-resolver";

function moduleHasData(_space: string, _mod: RSpaceModule): boolean {
	// Always show the module's own UI — let each module handle its empty state.
	// Previously demo returned true while live spaces checked for CRDT docs,
	// causing live spaces to show a generic onboarding page instead of the module.
	return true;
}

// ── 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) => {
		const space = c.req.param("space");
		if (!space || space === "api" || space.includes(".")) return next();

		// Check enabled modules (skip for core rspace module)
		await loadCommunity(space);
		const doc = getDocumentData(space);
		if (mod.id !== "rspace") {
			if (doc?.meta?.enabledModules && !doc.meta.enabledModules.includes(mod.id)) {
				// Redirect browser navigations to space root; return JSON error for API calls
				const accept = c.req.header("Accept") || "";
				if (accept.includes("text/html")) {
					return c.redirect(`https://${space}.rspace.online/`);
				}
				return c.json({ error: "Module not enabled for this space" }, 404);
			}
		}

		// Resolve effective data space (global vs space-scoped)
		const overrides = doc?.meta?.moduleScopeOverrides ?? null;
		const effectiveSpace = resolveDataSpace(mod.id, space, overrides);
		c.set("effectiveSpace", effectiveSpace);

		// ── Access control for private/permissioned spaces (API requests only) ──
		// HTML page navigations are gated client-side (tokens live in localStorage,
		// not cookies, so the server can't check auth on browser navigations).
		// The WebSocket gate prevents data sync for non-members regardless.
		const vis = doc?.meta?.visibility || "private";
		const accept = c.req.header("Accept") || "";
		const isHtmlRequest = accept.includes("text/html");

		// Exempt public-facing endpoints that are designed for unauthenticated users
		const pathname = new URL(c.req.url).pathname;
		const isPublicEndpoint = pathname.endsWith("/api/flows/user-onramp")
			|| pathname.endsWith("/api/onramp/config")
			|| pathname.endsWith("/api/transak/config")
			|| pathname.endsWith("/api/transak/webhook")
			|| pathname.endsWith("/api/coinbase/webhook")
			|| pathname.endsWith("/api/ramp/webhook")
			|| pathname.includes("/rcart/api/payments")
			|| pathname.includes("/rcart/pay/")
			|| pathname.includes("/rwallet/api/")
			|| pathname.includes("/rdesign/api/")
			|| pathname.includes("/rtasks/api/")
			|| (c.req.method === "GET" && pathname.includes("/rvote/api/"));

		if (!isHtmlRequest && !isPublicEndpoint && (vis === "private" || vis === "permissioned")) {
			const token = extractToken(c.req.raw.headers);
			if (!token) {
				return c.json({ error: "Authentication required" }, 401);
			}
			let claims: EncryptIDClaims | null = null;
			try { claims = await verifyToken(token); } catch {}
			if (!claims) {
				return c.json({ error: "Authentication required" }, 401);
			}
			if (vis === "private") {
				const resolved = await resolveCallerRole(space, claims);
				if (resolved) {
					c.set("spaceRole", resolved.role);
					c.set("isOwner", resolved.isOwner);
				}
				if (!resolved || (!resolved.isOwner && resolved.role === "viewer")) {
					return c.json({ error: "You don't have access to this space" }, 403);
				}
			}
		}

		// Resolve caller's role for write-method blocking
		const method = c.req.method;
		if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
			// Skip if already resolved above for private spaces
			if (!c.get("spaceRole")) {
				const token = extractToken(c.req.raw.headers);
				let claims: EncryptIDClaims | null = null;
				if (token) {
					try { claims = await verifyToken(token); } catch {}
				}
				const resolved = await resolveCallerRole(space, claims);
				if (resolved) {
					c.set("spaceRole", resolved.role);
					c.set("isOwner", resolved.isOwner);
					if (resolved.role === "viewer") {
						return c.json({ error: "Write access required" }, 403);
					}
				}
			} else {
				const role = c.get("spaceRole") as SpaceRoleString | undefined;
				if (role === "viewer") {
					return c.json({ error: "Write access required" }, 403);
				}
			}
		}

		return next();
	});
	// Onboarding + enabledModules check for root path (root page only)
	if (mod.id !== "rspace") {
		app.use(`/:space/${mod.id}`, async (c, next) => {
			const space = c.req.param("space");
			if (!space || space === "api" || space.includes(".")) return next();

			// Block disabled modules (same check as sub-path middleware)
			await loadCommunity(space);
			const spaceDoc = getDocumentData(space);
			if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(mod.id)) {
				const accept = c.req.header("Accept") || "";
				if (accept.includes("text/html")) {
					const redir = c.get("isSubdomain") ? "/rspace" : `/${space}/rspace`;
					return c.redirect(redir);
				}
				return c.json({ error: "Module not enabled for this space" }, 404);
			}

			if (space === "demo") return next();
			if (c.req.method !== "GET") return next();
			const path = c.req.path;
			const root = `/${space}/${mod.id}`;
			if (path !== root && path !== root + '/') return next();
			if (!moduleHasData(space, mod)) {
				return c.html(renderOnboarding({
					moduleId: mod.id,
					moduleName: mod.name,
					moduleIcon: mod.icon,
					moduleDescription: mod.description,
					spaceSlug: space,
					modules: getModuleInfoList(),
					landingHTML: mod.landingPage?.(),
					onboardingActions: mod.onboardingActions,
				}));
			}
			return next();
		});
	}
	// Fragment mode: signal renderShell to return lightweight JSON instead of full shell HTML.
	// Used by TabCache for fast tab switching — skips the entire 2000-line shell template.
	app.use(`/:space/${mod.id}`, async (c, next) => {
		if (c.req.method !== "GET" || !c.req.query("fragment")) return next();
		// Set the global flag so renderShell returns JSON fragment
		setFragmentMode(true);
		await next();
		// renderShell already returned JSON — fix the content-type header
		if (c.res.headers.get("content-type")?.includes("text/html")) {
			const body = await c.res.text();
			c.res = new Response(body, {
				status: c.res.status,
				headers: { "content-type": "application/json" },
			});
		}
	});
	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) => {
	c.header("Cache-Control", "no-cache, no-store, must-revalidate");
	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 verifyToken(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 || "private",
			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 verifyToken(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
// Access control for private spaces is handled client-side (tokens in localStorage)
// and enforced at the WebSocket layer (prevents data sync for non-members).
app.get("/:space", (c) => {
	const space = c.req.param("space");
	// Don't serve dashboard for static file paths
	if (space.includes(".")) return c.notFound();
	// On production bare domain, this route is unreachable (caught by subdomain redirect above).
	// On localhost/dev: redirect authenticated users to canvas, show dashboard for guests.
	const token = extractToken(c.req.raw.headers);
	if (token) {
		return c.redirect(`/${space}/rspace`, 302);
	}
	// Demo space: show dashboard; all others: redirect to main landing
	if (space === "demo") {
		return c.html(renderSpaceDashboard(space, getModuleInfoList()));
	}
	return c.redirect("/", 302);
});

// ── Custom 404 page ──
app.notFound((c) => {
	const path = c.req.path;
	// API routes: return JSON 404
	if (path.startsWith("/api/") || c.req.header("accept")?.includes("application/json")) {
		return c.json({ error: "Not found" }, 404);
	}
	return c.html(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Page Not Found | rSpace</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0f172a;color:#e2e8f0}
.c{max-width:480px;text-align:center;padding:2rem}
h1{font-size:4rem;margin-bottom:0.5rem;background:linear-gradient(135deg,#5eead4,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
p{color:#94a3b8;line-height:1.6;margin-bottom:1.5rem}
a{color:#5eead4;text-decoration:none;padding:0.5rem 1.5rem;border:1px solid #5eead4;border-radius:8px;transition:all 0.2s}
a:hover{background:#5eead4;color:#0f172a}
.sub{font-size:0.85rem;color:#475569;margin-top:2rem}
</style>
</head>
<body><div class="c">
<h1>404</h1>
<p>This page doesn't exist — it may have moved or the URL might be wrong.</p>
<a href="/">Back to rSpace</a>
<p class="sub">${path}</p>
</div></body></html>`, 404);
});

// ── WebSocket types ──
interface WSData {
	communitySlug: string;
	peerId: string;
	claims: EncryptIDClaims | null;
	readOnly: boolean;
	spaceRole: 'viewer' | 'member' | 'moderator' | 'admin' | null;
	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
	// Anonymous connection tracking
	anonIp?: string;                    // set for anonymous connections, used for cleanup on close
}

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

// Track anonymous WebSocket connections per IP (max 3 per IP)
const wsAnonConnectionsByIP = new Map<string, number>();
const MAX_ANON_WS_PER_IP = (process.env.DISABLE_RATE_LIMIT === "1" || process.env.NODE_ENV === "test") ? 10000 : 3;

// Track announced peer info per community:  slug → serverPeerId → { clientPeerId, username, color }
const peerAnnouncements = new Map<string, Map<string, { clientPeerId: string; username: string; color: string }>>();

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) }));
			}
		}
	}
}

// ── System Clock ──

/** Broadcast a clock event to all connected clients across all communities. */
function broadcastClockEvent(channel: string, payload: ClockPayload): void {
	const msg = JSON.stringify({ type: "clock", channel, payload });
	for (const [_slug, clients] of communityClients) {
		for (const [_peerId, client] of clients) {
			if (client.readyState === WebSocket.OPEN) {
				try {
					client.send(msg);
				} catch {
					// Ignore send errors on closing sockets
				}
			}
		}
	}
}

const systemClock = new SystemClock(broadcastClockEvent);
systemClock.start();

// ── 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, url?: URL): Promise<Response | null> {
	const filePath = resolve(DIST_DIR, path);
	const file = Bun.file(filePath);
	if (await file.exists()) {
		const headers: Record<string, string> = { "Content-Type": getContentType(path) };
		if (url?.searchParams.has("v")) {
			headers["Cache-Control"] = "public, max-age=31536000, immutable";
		} else if (path.endsWith(".html") || path === "sw.js" || path === "manifest.json") {
			// HTML, service worker, and manifest must revalidate every time
			headers["Cache-Control"] = "no-cache";
		} else if (path.startsWith("assets/")) {
			// Vite content-hashed assets are safe to cache long-term
			headers["Cache-Control"] = "public, max-age=31536000, immutable";
		}
		return new Response(file, { headers });
	}
	return null;
}

// ── Module ID aliases (plural/misspelling → canonical) ──
const MODULE_ALIASES: Record<string, string> = { rsheet: "rsheets", auctions: "rauctions" };
function resolveModuleAlias(id: string): string { return MODULE_ALIASES[id] ?? id; }

// ── 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);
		// TLS is terminated by Cloudflare/Traefik — url.protocol is always http: internally
		const proto = hostClean.includes("rspace.online") || hostClean.includes(".online") ? "https:" : url.protocol;

		// ── 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, 302);
		}

		// ── WebSocket upgrade ──
		if (url.pathname.startsWith("/ws/")) {
			const communitySlug = url.pathname.split("/")[2];
			if (communitySlug) {
				const spaceConfig = await getSpaceConfig(communitySlug);
				const wsAuthOpts = process.env.JWT_SECRET ? { secret: process.env.JWT_SECRET } : {};
				const claims = await authenticateWSUpgrade(req, wsAuthOpts);
				let readOnly = false;
				let spaceRole: WSData['spaceRole'] = null;

				// Load doc early so we can check membership for private spaces
				await loadCommunity(communitySlug);
				const spaceData = getDocumentData(communitySlug);

				if (spaceConfig) {
					const vis = spaceConfig.visibility;
					if (vis === "permissioned" || vis === "private") {
						if (!claims) return new Response("Authentication required", { status: 401 });
					}
					// Reject non-members of private spaces
					if (vis === "private" && claims && spaceData) {
						const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
						const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid;
						let isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid];
						// Fallback: check EncryptID space_members (handles identity-invite claims not yet synced to Automerge)
						if (!isOwner && !isMember) {
							try {
								const memberRes = await fetch(`${ENCRYPTID_INTERNAL}/api/spaces/${encodeURIComponent(communitySlug)}/members/${encodeURIComponent(callerDid)}`);
								if (memberRes.ok) {
									const memberData = await memberRes.json() as { role: string; userDID: string };
									// Sync to Automerge so future connections don't need the fallback
									setMember(communitySlug, callerDid, memberData.role as any, (claims as any).username);
									isMember = { did: callerDid, role: memberData.role as "admin" | "viewer" | "member" | "moderator", joinedAt: Date.now() };
								}
							} catch {}
						}
						if (!isOwner && !isMember) {
							return new Response("You don't have access to this space", { status: 403 });
						}
					}
					if (vis === "public") {
						readOnly = !claims;
					}
				}

				// Resolve the caller's space role (re-read doc in case setMember was called during fallback sync)
				const spaceDataFresh = getDocumentData(communitySlug);
				if (spaceDataFresh || spaceData) {
					const sd = spaceDataFresh || spaceData;
					const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : undefined;
					if (claims && sd!.meta.ownerDID === claims.sub) {
						spaceRole = 'admin';
					} else if (claims && (sd!.members?.[claims.sub]?.role || (callerDid && sd!.members?.[callerDid]?.role))) {
						spaceRole = sd!.members?.[claims.sub]?.role || sd!.members?.[callerDid!]?.role;
					} else {
						// Non-member defaults by visibility
						const vis = spaceConfig?.visibility;
						if (vis === 'public' && claims) spaceRole = 'member';
						else if (vis === 'permissioned' && claims) spaceRole = 'viewer';
						else if (claims) spaceRole = 'viewer';
						else spaceRole = 'viewer'; // anonymous
					}
				}

				// Enforce read-only for viewers
				if (spaceRole === 'viewer') {
					readOnly = true;
				}

				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;
							}
						}
					}
				}

				// Anonymous WebSocket connection cap (max 3 per IP)
				let anonIp: string | undefined;
				if (!claims) {
					const wsIp = req.headers.get("cf-connecting-ip")
						|| req.headers.get("x-forwarded-for")?.split(",")[0].trim()
						|| "unknown";
					const current = wsAnonConnectionsByIP.get(wsIp) || 0;
					if (current >= MAX_ANON_WS_PER_IP) {
						return new Response("Too many anonymous connections", { status: 429 });
					}
					wsAnonConnectionsByIP.set(wsIp, current + 1);
					anonIp = wsIp;
				}

				const upgraded = server.upgrade(req, {
					data: { communitySlug, peerId, claims, readOnly, spaceRole, mode, nestFrom, nestPermissions, nestFilter, anonIp } as WSData,
				});
				if (upgraded) return undefined;

				// Upgrade failed — roll back anon counter
				if (anonIp) {
					const count = wsAnonConnectionsByIP.get(anonIp) || 1;
					if (count <= 1) wsAnonConnectionsByIP.delete(anonIp);
					else wsAnonConnectionsByIP.set(anonIp, count - 1);
				}
			}
			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;
		}

		// ── favicon.ico → favicon.png fallback ──
		if (url.pathname === "/favicon.ico") {
			const staticResponse = await serveStatic("favicon.png");
			if (staticResponse) return staticResponse;
		}

		// ── 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, url);
				if (staticResponse) return staticResponse;
			}
		}

		// ── Subdomain routing: {space}.rspace.online/{moduleId}/... ──
		if (subdomain) {
			// ── Guard: module IDs are not space slugs ──
			// Redirect rcred.rspace.online → demo.rspace.online/rcred (not a space)
			const allModuleIds = new Set(getAllModules().map(m => m.id));
			if (allModuleIds.has(subdomain.toLowerCase())) {
				const pathSuffix = url.pathname === "/" ? "" : url.pathname;
				return Response.redirect(`${proto}//demo.rspace.online/${subdomain}${pathSuffix}${url.search}`, 302);
			}

			// ── 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 verifyToken(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: "private",
									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: redirect authenticated users straight to rSpace canvas
			// (avoids rendering space dashboard just to JS-redirect, eliminating
			// the flash of different header + 2-3 redirect chain)
			if (pathSegments.length === 0) {
				const token = extractTokenFromRequest(req);
				if (token) {
					return Response.redirect(`${proto}//${url.host}/rspace`, 302);
				}
				// Demo space: show dashboard; all others: redirect to main landing
				if (subdomain === "demo") {
					return new Response(renderSpaceDashboard(subdomain, getModuleInfoList()), {
						headers: { "Content-Type": "text/html" },
					});
				}
				return Response.redirect(`${proto}//rspace.online/`, 302);
			}

			// Global routes pass through without subdomain prefix
			if (
				url.pathname.startsWith("/api/") ||
				url.pathname.startsWith("/data/") ||
				url.pathname.startsWith("/encryptid/") ||
				url.pathname.startsWith("/rtasks/check/") ||
				url.pathname.startsWith("/respond/") ||
				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);
			}

			// Template seeding: /{moduleId}/template → seed + redirect
			if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "template") {
				const moduleId = pathSegments[0].toLowerCase();
				try {
					await loadCommunity(subdomain);
					const seeded = seedTemplateShapes(subdomain);
					if (seeded) {
						broadcastAutomergeSync(subdomain);
						broadcastJsonSnapshot(subdomain);
					}
					// Seed module-specific demo data
					const mod = getModule(moduleId);
					if (mod?.seedTemplate) mod.seedTemplate(subdomain);
				} catch (e) {
					console.error(`[Template] On-demand seed failed for "${subdomain}":`, e);
				}
				const withoutTemplate = pathSegments.slice(0, -1).join("/");
				return Response.redirect(`${proto}//${url.host}/${withoutTemplate}`, 302);
			}

			// Demo route: /{moduleId}/demo → rewrite to /demo/{moduleId}
			if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "demo") {
				const moduleId = pathSegments[0].toLowerCase();
				const mod = getModule(moduleId);
				if (mod) {
					const rewrittenPath = `/demo/${moduleId}`;
					const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
					return app.fetch(new Request(rewrittenUrl, req));
				}
			}

			// If first segment matches the subdomain (space slug), the URL has a
			// redundant /{space}/... prefix.  For HTML navigation, redirect to strip
			// it so the address bar never shows the space slug.  For API/fetch
			// requests, silently rewrite to avoid breaking client-side fetches.
			// e.g. demo.rspace.online/demo/rspace → 302 → demo.rspace.online/rspace
			if (pathSegments[0].toLowerCase() === subdomain) {
				const accept = req.headers.get("Accept") || "";
				if (accept.includes("text/html") && !url.pathname.includes("/api/")) {
					const cleanPath = "/" + pathSegments.slice(1).join("/") + url.search;
					return Response.redirect(`https://${host}${cleanPath}`, 302);
				}
				// API/fetch — rewrite internally
				const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
				return app.fetch(new Request(rewrittenUrl, req));
			}

			// Block disabled modules before rewriting — redirect to space root
			const firstModId = resolveModuleAlias(pathSegments[0].toLowerCase());
			if (firstModId !== "rspace") {
				await loadCommunity(subdomain);
				const spaceDoc = getDocumentData(subdomain);
				if (spaceDoc?.meta?.enabledModules && !spaceDoc.meta.enabledModules.includes(firstModId)) {
					return Response.redirect(`https://${subdomain}.rspace.online/`, 302);
				}
			}

			// Normalize module ID to lowercase + resolve aliases (rTrips → rtrips, rsheet → rsheets)
			const normalizedPath = "/" + pathSegments.map((seg, i) =>
				i === 0 ? resolveModuleAlias(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);
			const subdomainResponse = await app.fetch(rewrittenReq);

			// If 404 on an HTML request for a known module sub-path, serve the
			// module shell so client-side SPA routing can handle it
			if (subdomainResponse.status === 404 && pathSegments.length >= 2) {
				const accept = req.headers.get("Accept") || "";
				if (accept.includes("text/html") && !normalizedPath.includes("/api/")) {
					const moduleRoot = `/${subdomain}/${resolveModuleAlias(pathSegments[0].toLowerCase())}`;
					const shellUrl = new URL(moduleRoot, `http://localhost:${PORT}`);
					const shellResponse = await app.fetch(new Request(shellUrl, {
						headers: req.headers,
						method: "GET",
					}));
					if (shellResponse.status === 200) return shellResponse;
				}
			}

			return subdomainResponse;
		}

		// ── Bare-domain routing: rspace.online/{...} ──
		// Only match canonical bare domain, not stacked subdomains like rspace.rspace.online
		if (!subdomain && (hostClean === "rspace.online" || hostClean === "www.rspace.online")) {
			// Top-level routes that must bypass module rewriting
			if (url.pathname.startsWith("/rtasks/check/") || url.pathname.startsWith("/respond/")) {
				return app.fetch(req);
			}

			const pathSegments = url.pathname.split("/").filter(Boolean);
			if (pathSegments.length >= 1) {
				const firstSegment = resolveModuleAlias(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} → go straight to demo interactive app
					if (pathSegments.length === 1) {
						const rewrittenPath = `/demo/${firstSegment}`;
						const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
						return app.fetch(new Request(rewrittenUrl, req));
					}
					// rspace.online/{moduleId}/sub-path
					const secondSegment = pathSegments[1]?.toLowerCase();

					// 1. API routes always pass through to demo
					if (secondSegment === "api") {
						const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
						const rewrittenPath = `/demo${normalizedPath}`;
						const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
						return app.fetch(new Request(rewrittenUrl, req));
					}

					// 2. If sub-path matches a subPageInfo → render info page
					const subPageInfo = mod.subPageInfos?.find(sp => sp.path === secondSegment);
					if (subPageInfo) {
						const html = renderSubPageInfo({
							subPage: subPageInfo,
							module: mod,
							modules: getModuleInfoList(),
						});
						return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
					}

					// 3. Fallback: rewrite to demo space (backward compat)
					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} or rspace.online/{space}/{...} → redirect to subdomain
				// (space is not a module ID — it's a space slug)
				// Skip for known server paths (api, admin, etc.)
				const serverPaths = new Set(["api", "admin", "admin-data", "admin-action", "modules", ".well-known", "about", "create-space", "new", "discover"]);
				if (!knownModuleIds.has(firstSegment) && !serverPaths.has(firstSegment)) {
					const space = firstSegment;
					if (pathSegments.length >= 2) {
						const secondSeg = pathSegments[1]?.toLowerCase();
						const isApiCall = secondSeg === "api" || pathSegments.some((s, i) => i >= 1 && s === "api");
						if (isApiCall) {
							// API calls: rewrite internally (avoid redirect + mixed-content)
							const rewrittenUrl = new URL(url.pathname + url.search, `http://localhost:${PORT}`);
							return app.fetch(new Request(rewrittenUrl, req));
						}
						// Page navigation: redirect to canonical subdomain URL
						const rest = "/" + pathSegments.slice(1).join("/");
						return Response.redirect(
							`${proto}//${space}.rspace.online${rest}${url.search}`, 301
						);
					}
					// Single segment: rspace.online/{space} → {space}.rspace.online/
					return Response.redirect(`${proto}//${space}.rspace.online/${url.search}`, 301);
				}
			}
		}

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

		// If Hono returns 404, serve the module shell for client-side routing
		// (module sub-paths like /demo/rcal/settings are handled by the SPA router)
		if (response.status === 404 && !url.pathname.startsWith("/api/")) {
			const accept = req.headers.get("Accept") || "";
			const parts = url.pathname.split("/").filter(Boolean);

			if (accept.includes("text/html") && parts.length >= 1 && !parts[0].includes(".")) {
				const knownModuleIds = getAllModules().map((m) => m.id);

				// Subdomain requests: URL is /{moduleId}/... (space is the subdomain)
				// Bare-domain requests: URL is /{space}/{moduleId}/...
				const moduleId = subdomain
					? resolveModuleAlias(parts[0].toLowerCase())
					: parts.length >= 2 ? parts[1].toLowerCase() : null;
				const space = subdomain || parts[0];

				if (moduleId && knownModuleIds.includes(moduleId)) {
					// Module sub-path: rewrite to the module root so the shell renders
					// and the client-side SPA router handles the sub-path
					const moduleRoot = `/${space}/${moduleId}`;
					const rewrittenUrl = new URL(moduleRoot, `http://localhost:${PORT}`);
					const shellResponse = await app.fetch(new Request(rewrittenUrl, {
						headers: req.headers,
						method: "GET",
					}));
					if (shellResponse.status === 200) return shellResponse;
				}

				// Canvas SPA fallback (only for /rspace sub-paths, not general 404s)
				if (parts[0] === "rspace" || (subdomain && parts[0] === "rspace")) {
					const canvasHtml = await serveStatic("canvas.html");
					if (canvasHtml) return canvasHtml;
				}
			}
		}

		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);

			// Register for notification delivery
			if (claims?.sub) registerUserConnection(claims.sub, ws);

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

			loadCommunity(communitySlug).then((doc) => {
				if (!doc) return;
				// Guard: peer may have disconnected during async load
				if (ws.readyState !== WebSocket.OPEN) 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;
				}

				// ── Yjs collaboration relay (pure relay — server doesn't parse Yjs binary) ──
			if (msg.type === "yjs-sync" || msg.type === "yjs-awareness") {
				const clients = communityClients.get(communitySlug);
				if (clients) {
					const relay = JSON.stringify({ ...msg, peerId });
					for (const [cid, client] of clients) {
						if (cid !== peerId && client.readyState === WebSocket.OPEN) {
							client.send(relay);
						}
					}
				}
				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" || msg.type === "presence-leave") {
					const clients = communityClients.get(communitySlug);
					if (clients) {
						const presenceMsg = JSON.stringify({ ...msg, peerId });
						for (const [clientPeerId, client] of clients) {
							if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
								client.send(presenceMsg);
							}
						}
					}
				} else if (msg.type === "announce") {
					// Client announcing its identity for the people-online panel
					const { peerId: clientPeerId, username, color } = msg;
					if (!clientPeerId || !username) return;

					let slugAnnouncements = peerAnnouncements.get(communitySlug);
					if (!slugAnnouncements) {
						slugAnnouncements = new Map();
						peerAnnouncements.set(communitySlug, slugAnnouncements);
					}
					slugAnnouncements.set(peerId, { clientPeerId, username, color });

					// Send full peer list back to the announcing client
					const peerList = Array.from(slugAnnouncements.values());
					ws.send(JSON.stringify({ type: "peer-list", peers: peerList }));

					// Broadcast peer-joined to all other clients
					const joinedMsg = JSON.stringify({ type: "peer-joined", peerId: clientPeerId, username, color });
					const clients2 = communityClients.get(communitySlug);
					if (clients2) {
						for (const [cPeerId, client] of clients2) {
							if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
								client.send(joinedMsg);
							}
						}
					}
				} else if (msg.type === "ping-user") {
					// Relay a "come here" ping to a specific peer
					const { targetPeerId: targetClientPeerId, viewport } = msg;
					const slugAnnouncements = peerAnnouncements.get(communitySlug);
					const senderInfo = slugAnnouncements?.get(peerId);
					if (!slugAnnouncements || !senderInfo) return;

					// Find the server peerId that matches the target client peerId
					const clients3 = communityClients.get(communitySlug);
					if (clients3) {
						for (const [serverPid, _client] of clients3) {
							const ann = slugAnnouncements.get(serverPid);
							if (ann && ann.clientPeerId === targetClientPeerId && _client.readyState === WebSocket.OPEN) {
								_client.send(JSON.stringify({
									type: "ping-user",
									fromPeerId: senderInfo.clientPeerId,
									fromUsername: senderInfo.username,
									viewport,
								}));

								// Persist ping as a notification
								const targetDid = _client.data.claims?.sub;
								if (targetDid && ws.data.claims?.sub) {
									notify({
										userDid: targetDid,
										category: 'social',
										eventType: 'ping_user',
										title: `${senderInfo.username} pinged you in "${communitySlug}"`,
										spaceSlug: communitySlug,
										actorDid: ws.data.claims.sub,
										actorUsername: senderInfo.username,
										actionUrl: `/rspace`,
										expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h
									}).catch(() => {});
								}

								break;
							}
						}
					}
				} 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, claims } = ws.data;

			// Decrement anonymous WS connection counter
			if (ws.data.anonIp) {
				const count = wsAnonConnectionsByIP.get(ws.data.anonIp) || 1;
				if (count <= 1) wsAnonConnectionsByIP.delete(ws.data.anonIp);
				else wsAnonConnectionsByIP.set(ws.data.anonIp, count - 1);
			}

			// Unregister from notification delivery
			if (claims?.sub) unregisterUserConnection(claims.sub, ws);

			// Broadcast peer-left before cleanup
			const slugAnnouncements = peerAnnouncements.get(communitySlug);
			if (slugAnnouncements) {
				const ann = slugAnnouncements.get(peerId);
				if (ann) {
					const leftMsg = JSON.stringify({ type: "peer-left", peerId: ann.clientPeerId });
					const clients = communityClients.get(communitySlug);
					if (clients) {
						for (const [cPeerId, client] of clients) {
							if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
								client.send(leftMsg);
							}
						}
					}
					slugAnnouncements.delete(peerId);
					if (slugAnnouncements.size === 0) peerAnnouncements.delete(communitySlug);
				}
			}

			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 ──

// Ensure generated files directory exists
import { mkdirSync } from "node:fs";
try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {}

import { initTokenService, seedCUSDC } from "./token-service";

// IMPORTANT: Load persisted docs FIRST, then run module onInit + seeding.
// Previously onInit ran before loadAllDocs, causing seed functions to see
// empty docs and re-create demo data that users had already deleted.
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
loadAllDocs(syncServer)
	.then(async () => {
		// Now that persisted docs are loaded, init modules (which may seed)
		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);
				}
			}
		}
		// Pass syncServer to OAuth handlers
		setNotionOAuthSyncServer(syncServer);
		setGoogleOAuthSyncServer(syncServer);
		setClickUpOAuthSyncServer(syncServer);
		setOAuthStatusSyncServer(syncServer);

		// Seed all modules' demo data so /demo routes always have content
		for (const mod of getAllModules()) {
			if (mod.seedTemplate) {
				try { mod.seedTemplate("demo"); } catch { /* already seeded */ }
			}
		}
		console.log("[Demo] All module demo data seeded");

		// Initialize CRDT token service and seed cUSDC
		initTokenService(syncServer);
		try {
			await seedCUSDC();
		} catch (e) {
			console.error("[TokenService] Seed failed:", e);
		}

		// Provision Mailcow forwarding aliases for all existing spaces
		const ENCRYPTID_INTERNAL_FOR_ALIAS = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
		try {
			const slugs = await listCommunities();
			let count = 0;
			for (const slug of slugs) {
				if (slug === "demo") continue;
				try {
					await fetch(`${ENCRYPTID_INTERNAL_FOR_ALIAS}/api/internal/spaces/${slug}/alias`, { method: "POST" });
					count++;
				} catch { /* encryptid not ready yet, will provision on next restart */ }
			}
			if (count > 0) console.log(`[SpaceAlias] Provisioned ${count} space aliases`);
		} catch (e) {
			console.error("[SpaceAlias] Startup provisioning failed:", e);
		}
	})
	.catch((e) => console.error("[DocStore] Startup load failed:", e));

// Restore relay mode for encrypted spaces + one-shot visibility fixes
(async () => {
	try {
		const slugs = await listCommunities();
		let relayCount = 0;
		for (const slug of slugs) {
			await loadCommunity(slug);
			const data = getDocumentData(slug);
			if (data?.meta?.encrypted) {
				syncServer.setRelayOnly(slug, true);
				relayCount++;
			}
		}
		if (relayCount > 0) {
			console.log(`[Encryption] ${relayCount} space(s) set to relay mode (encrypted)`);
		}

		// One-shot: fix spaces that should be permissioned
		const fixPermissioned = ["clf", "bcrg"];
		for (const slug of fixPermissioned) {
			const data = getDocumentData(slug);
			if (data && data.meta?.visibility !== "permissioned") {
				updateSpaceMeta(slug, { visibility: "permissioned" });
				console.log(`[VisFix] Set ${slug} to permissioned (was: ${data.meta?.visibility})`);
			}
		}
	} catch (e) {
		console.error("[Encryption] Failed to restore relay modes:", e);
	}
})();

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

// Start sidecar lifecycle manager — stops idle containers after 5min
startIdleWatcher();
