/**
 * Splat module — Gaussian splat viewer with x402 gated uploads.
 *
 * Routes are relative to mount point (/:space/splat in unified).
 * Three.js + GaussianSplats3D loaded via CDN importmap.
 *
 * All metadata is stored in Automerge documents via SyncServer.
 * 3D files (.ply, .splat, .spz) remain on the filesystem.
 */

import { Hono } from "hono";
import { resolve } from "node:path";
import { mkdir } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { renderLanding } from "./landing";
import { verifyToken, extractToken } from "../../server/auth";
import { setupX402FromEnv } from "../../shared/x402/hono-middleware";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
	splatScenesSchema,
	splatScenesDocId,
	type SplatScenesDoc,
	type SplatItem,
	type SourceFile,
} from './schemas';

let _syncServer: SyncServer | null = null;

const SPLATS_DIR = process.env.SPLATS_DIR || "/data/splats";
const SOURCES_DIR = resolve(SPLATS_DIR, "sources");
const VALID_FORMATS = ["ply", "splat", "spz"];
const VALID_MEDIA_TYPES = [
	"image/jpeg", "image/png", "image/heic",
	"video/mp4", "video/quicktime", "video/webm",
];
const VALID_MEDIA_EXTS = [".jpg", ".jpeg", ".png", ".heic", ".mp4", ".mov", ".webm"];
const MAX_PHOTOS = 100;
const MAX_MEDIA_SIZE = 2 * 1024 * 1024 * 1024; // 2GB per file

// ── Types ──

export interface SplatRow {
	id: string;
	slug: string;
	title: string;
	description: string | null;
	file_path: string;
	file_format: string;
	file_size_bytes: number;
	tags: string[];
	space_slug: string;
	contributor_id: string | null;
	contributor_name: string | null;
	source: string;
	status: string;
	view_count: number;
	payment_tx: string | null;
	payment_network: string | null;
	processing_status: string;
	processing_error: string | null;
	source_file_count: number;
	created_at: string;
}

// ── Helpers ──

function slugify(text: string): string {
	return text
		.toLowerCase()
		.replace(/[^a-z0-9]+/g, "-")
		.replace(/^-|-$/g, "")
		.slice(0, 80);
}

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

function getFileFormat(filename: string): string | null {
	const ext = filename.split(".").pop()?.toLowerCase();
	return ext && VALID_FORMATS.includes(ext) ? ext : null;
}

function getMimeType(format: string): string {
	switch (format) {
		case "ply": return "application/x-ply";
		case "splat": return "application/octet-stream";
		case "spz": return "application/octet-stream";
		default: return "application/octet-stream";
	}
}

// ── Automerge helpers ──

/**
 * Lazily create the Automerge doc for a space if it doesn't exist yet.
 */
function ensureDoc(space: string): SplatScenesDoc {
	const docId = splatScenesDocId(space);
	let doc = _syncServer!.getDoc<SplatScenesDoc>(docId);
	if (!doc) {
		doc = Automerge.change(Automerge.init<SplatScenesDoc>(), 'init', (d) => {
			const init = splatScenesSchema.init();
			d.meta = init.meta;
			d.meta.spaceSlug = space;
			d.items = {};
		});
		_syncServer!.setDoc(docId, doc);
	}
	return doc;
}

/**
 * Find a splat item by slug or id within a doc's items map.
 * Returns [itemKey, item] or undefined.
 */
function findItem(doc: SplatScenesDoc, idOrSlug: string): [string, SplatItem] | undefined {
	for (const [key, item] of Object.entries(doc.items)) {
		if (item.slug === idOrSlug || item.id === idOrSlug) {
			return [key, item];
		}
	}
	return undefined;
}

/**
 * Convert a SplatItem (camelCase) to a snake_case row for API responses,
 * preserving the shape the frontend expects.
 */
function itemToRow(item: SplatItem): SplatRow {
	return {
		id: item.id,
		slug: item.slug,
		title: item.title,
		description: item.description || null,
		file_path: item.filePath,
		file_format: item.fileFormat,
		file_size_bytes: item.fileSizeBytes,
		tags: item.tags ?? [],
		space_slug: item.spaceSlug,
		contributor_id: item.contributorId,
		contributor_name: item.contributorName,
		source: item.source ?? 'upload',
		status: item.status,
		view_count: item.viewCount,
		payment_tx: item.paymentTx,
		payment_network: item.paymentNetwork,
		processing_status: item.processingStatus ?? 'ready',
		processing_error: item.processingError,
		source_file_count: item.sourceFileCount,
		created_at: new Date(item.createdAt).toISOString(),
	};
}

/**
 * Return the subset of SplatRow fields used in list/gallery responses.
 */
function itemToListRow(item: SplatItem, spaceSlug?: string, isSubdomain?: boolean) {
	const effectiveSlug = spaceSlug || item.spaceSlug;
	const fileUrl = item.filePath
		? (isSubdomain ? `/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}` : `/${effectiveSlug}/rsplat/api/splats/${item.slug}/${item.slug}.${item.fileFormat}`)
		: null;
	return {
		id: item.id,
		slug: item.slug,
		title: item.title,
		description: item.description || null,
		file_format: item.fileFormat,
		file_url: fileUrl,
		file_size_bytes: item.fileSizeBytes,
		tags: item.tags ?? [],
		contributor_name: item.contributorName,
		thumbnail_url: item.thumbnailUrl || null,
		view_count: item.viewCount,
		processing_status: item.processingStatus ?? 'ready',
		source_file_count: item.sourceFileCount,
		created_at: new Date(item.createdAt).toISOString(),
	};
}

// ── CDN importmap for Three.js + GaussianSplats3D ──

const MODEL_VIEWER_SCRIPT = `<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>`;

const IMPORTMAP = `<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
    "@mkkellogg/gaussian-splats-3d": "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.7/build/gaussian-splats-3d.module.js"
  }
}
</script>`;

// ── x402 middleware ──

const x402Middleware = setupX402FromEnv({
	description: "Upload Gaussian splat file",
	resource: "/api/splats",
});

// ── Routes ──

const routes = new Hono();

// ── API: List splats ──
routes.get("/api/splats", async (c) => {
	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const tag = c.req.query("tag");
	const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
	const offset = parseInt(c.req.query("offset") || "0");

	const doc = ensureDoc(dataSpace);

	let items = Object.values(doc.items)
		.filter((item) => item.status === 'published');

	if (tag) {
		items = items.filter((item) => item.tags?.includes(tag));
	}

	// Sort by createdAt descending
	items.sort((a, b) => b.createdAt - a.createdAt);

	// Apply offset and limit
	const paged = items.slice(offset, offset + limit);

	return c.json({ splats: paged.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain"))) });
});

// ── API: Get splat details ──
routes.get("/api/splats/:id", async (c) => {
	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const id = c.req.param("id");

	const doc = ensureDoc(dataSpace);
	const found = findItem(doc, id);

	if (!found || found[1].status !== 'published') {
		return c.json({ error: "Splat not found" }, 404);
	}

	const [itemKey, item] = found;

	// Increment view count
	const docId = splatScenesDocId(dataSpace);
	_syncServer!.changeDoc<SplatScenesDoc>(docId, 'increment view count', (d) => {
		d.items[itemKey].viewCount += 1;
	});

	return c.json(itemToRow(item));
});

// ── API: Serve splat file ──
// Matches both /api/splats/:id/file and /api/splats/:id/:filename (e.g. rainbow-sphere.splat)
routes.get("/api/splats/:id/:filename", async (c) => {
	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const id = c.req.param("id");

	const doc = ensureDoc(dataSpace);
	const found = findItem(doc, id);

	if (!found || found[1].status !== 'published') {
		return c.json({ error: "Splat not found" }, 404);
	}

	const splat = found[1];
	const filepath = resolve(SPLATS_DIR, splat.filePath);
	const file = Bun.file(filepath);

	if (!(await file.exists())) {
		return c.json({ error: "Splat file not found on disk" }, 404);
	}

	return new Response(file, {
		headers: {
			"Content-Type": getMimeType(splat.fileFormat),
			"Content-Disposition": `inline; filename="${splat.slug}.${splat.fileFormat}"`,
			"Content-Length": String(file.size),
			"Access-Control-Allow-Origin": "*",
			"Cache-Control": "public, max-age=86400",
		},
	});
});

// ── API: Upload splat (EncryptID auth + optional x402) ──
routes.post("/api/splats", async (c) => {
	// Auth check
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);

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

	// x402 check (if enabled)
	if (x402Middleware) {
		const paymentResult = await new Promise<Response | null>((resolve) => {
			const fakeNext = async () => { resolve(null); };
			x402Middleware(c, fakeNext).then((res) => {
				if (res instanceof Response) resolve(res);
			});
		});
		if (paymentResult) return paymentResult;
	}

	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const formData = await c.req.formData();
	const file = formData.get("file") as File | null;
	const title = (formData.get("title") as string || "").trim();
	const description = (formData.get("description") as string || "").trim() || null;
	const tagsRaw = (formData.get("tags") as string || "").trim();

	if (!file) {
		return c.json({ error: "Splat file required" }, 400);
	}

	const format = getFileFormat(file.name);
	if (!format) {
		return c.json({ error: "Invalid file format. Accepted: .ply, .splat, .spz" }, 400);
	}

	if (!title) {
		return c.json({ error: "Title required" }, 400);
	}

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

	const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
	const shortId = randomUUID().slice(0, 8);
	let slug = slugify(title);

	// Check slug collision in Automerge doc
	const doc = ensureDoc(dataSpace);
	const slugExists = Object.values(doc.items).some((item) => item.slug === slug);
	if (slugExists) {
		slug = `${slug}-${shortId}`;
	}

	// Save file to disk
	await mkdir(SPLATS_DIR, { recursive: true });
	const filename = `${slug}.${format}`;
	const filepath = resolve(SPLATS_DIR, filename);
	const buffer = Buffer.from(await file.arrayBuffer());
	await Bun.write(filepath, buffer);

	// Insert into Automerge doc
	const splatId = randomUUID();
	const now = Date.now();
	const paymentTx = (c as any).get("x402Payment") || null;

	const docId = splatScenesDocId(dataSpace);
	_syncServer!.changeDoc<SplatScenesDoc>(docId, 'add splat', (d) => {
		d.items[splatId] = {
			id: splatId,
			slug,
			title,
			description: description ?? '',
			filePath: filename,
			fileFormat: format,
			fileSizeBytes: buffer.length,
			tags,
			spaceSlug,
			contributorId: claims.sub,
			contributorName: claims.username || null,
			source: 'upload',
			status: 'published',
			viewCount: 0,
			paymentTx,
			paymentNetwork: null,
			createdAt: now,
			processingStatus: 'ready',
			processingError: null,
			sourceFileCount: 0,
			sourceFiles: [],
			thumbnailUrl: null,
		};
	});

	return c.json({
		id: splatId,
		slug,
		title,
		description,
		file_format: format,
		file_size_bytes: buffer.length,
		tags,
		created_at: new Date(now).toISOString(),
	}, 201);
});

// ── API: Upload photos/video for splatting ──
routes.post("/api/splats/from-media", async (c) => {
	// Auth check
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);

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

	// x402 check (if enabled)
	if (x402Middleware) {
		const paymentResult = await new Promise<Response | null>((resolve) => {
			const fakeNext = async () => { resolve(null); };
			x402Middleware(c, fakeNext).then((res) => {
				if (res instanceof Response) resolve(res);
			});
		});
		if (paymentResult) return paymentResult;
	}

	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const formData = await c.req.formData();
	const title = (formData.get("title") as string || "").trim();
	const description = (formData.get("description") as string || "").trim() || null;
	const tagsRaw = (formData.get("tags") as string || "").trim();

	if (!title) {
		return c.json({ error: "Title required" }, 400);
	}

	// Collect all files from formdata
	const files: File[] = [];
	for (const [key, value] of formData.entries()) {
		if (key === "files" && (value as unknown) instanceof File) {
			files.push(value as unknown as File);
		}
	}

	if (files.length === 0) {
		return c.json({ error: "At least one photo or video file required" }, 400);
	}

	// Validate file types
	const hasVideo = files.some((f) => f.type.startsWith("video/"));
	if (hasVideo && files.length > 1) {
		return c.json({ error: "Only 1 video file allowed per upload" }, 400);
	}
	if (!hasVideo && files.length > MAX_PHOTOS) {
		return c.json({ error: `Maximum ${MAX_PHOTOS} photos per upload` }, 400);
	}

	for (const f of files) {
		const ext = "." + f.name.split(".").pop()?.toLowerCase();
		if (!VALID_MEDIA_EXTS.includes(ext)) {
			return c.json({ error: `Invalid file type: ${f.name}. Accepted: ${VALID_MEDIA_EXTS.join(", ")}` }, 400);
		}
		if (f.size > MAX_MEDIA_SIZE) {
			return c.json({ error: `File too large: ${f.name}. Maximum 2GB.` }, 400);
		}
	}

	const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
	const shortId = randomUUID().slice(0, 8);
	let slug = slugify(title);

	// Check slug collision in Automerge doc
	const doc = ensureDoc(dataSpace);
	const slugExists = Object.values(doc.items).some((item) => item.slug === slug);
	if (slugExists) {
		slug = `${slug}-${shortId}`;
	}

	// Save source files to disk
	const sourceDir = resolve(SOURCES_DIR, slug);
	await mkdir(sourceDir, { recursive: true });

	const sourceFileEntries: SourceFile[] = [];
	const sfId = () => randomUUID();
	const splatId = randomUUID();
	const now = Date.now();

	for (const f of files) {
		const safeName = f.name.replace(/[^a-zA-Z0-9._-]/g, "_");
		const filepath = resolve(sourceDir, safeName);
		const buffer = Buffer.from(await f.arrayBuffer());
		await Bun.write(filepath, buffer);
		sourceFileEntries.push({
			id: sfId(),
			splatId,
			filePath: `sources/${slug}/${safeName}`,
			fileName: f.name,
			mimeType: f.type,
			fileSizeBytes: buffer.length,
			createdAt: now,
		});
	}

	// Insert splat record (pending processing) into Automerge doc
	const paymentTx = (c as any).get("x402Payment") || null;
	const docId = splatScenesDocId(dataSpace);
	_syncServer!.changeDoc<SplatScenesDoc>(docId, 'add splat from media', (d) => {
		d.items[splatId] = {
			id: splatId,
			slug,
			title,
			description: description ?? '',
			filePath: '',
			fileFormat: 'ply',
			fileSizeBytes: 0,
			tags,
			spaceSlug,
			contributorId: claims.sub,
			contributorName: claims.username || null,
			source: 'media',
			status: 'published',
			viewCount: 0,
			paymentTx,
			paymentNetwork: null,
			createdAt: now,
			processingStatus: 'pending',
			processingError: null,
			sourceFileCount: files.length,
			sourceFiles: sourceFileEntries,
			thumbnailUrl: null,
		};
	});

	return c.json({
		id: splatId,
		slug,
		title,
		description,
		file_format: 'ply',
		tags,
		processing_status: 'pending',
		source_file_count: files.length,
		created_at: new Date(now).toISOString(),
	}, 201);
});

// ── API: Save generated 3D model to gallery ──
routes.post("/api/splats/save-generated", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);

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

	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const { url, title, description, thumbnail_url } = await c.req.json();

	if (!url || !title?.trim()) {
		return c.json({ error: "url and title required" }, 400);
	}

	// Verify the file exists on disk (must be a generated file)
	const generatedDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
	const filename = url.split("/").pop();
	if (!filename || !filename.startsWith("splat-")) {
		return c.json({ error: "Invalid generated file URL" }, 400);
	}

	const srcPath = resolve(generatedDir, filename);
	const srcFile = Bun.file(srcPath);
	if (!(await srcFile.exists())) {
		return c.json({ error: "Generated file not found" }, 404);
	}

	// Determine format and create slug
	const ext = filename.split(".").pop() || "glb";
	const slug = slugify(title.trim());
	const shortId = randomUUID().slice(0, 8);
	const doc = ensureDoc(dataSpace);
	const slugExists = Object.values(doc.items).some((item) => item.slug === slug);
	const finalSlug = slugExists ? `${slug}-${shortId}` : slug;

	// Copy file to splats directory
	await mkdir(SPLATS_DIR, { recursive: true });
	const destFilename = `${finalSlug}.${ext}`;
	const destPath = resolve(SPLATS_DIR, destFilename);
	const buf = await srcFile.arrayBuffer();
	await Bun.write(destPath, buf);

	// Add to Automerge
	const splatId = randomUUID();
	const now = Date.now();
	const docId = splatScenesDocId(dataSpace);
	_syncServer!.changeDoc<SplatScenesDoc>(docId, 'save generated splat', (d) => {
		d.items[splatId] = {
			id: splatId,
			slug: finalSlug,
			title: title.trim(),
			description: (description || "AI-generated 3D model").trim(),
			filePath: destFilename,
			fileFormat: ext,
			fileSizeBytes: buf.byteLength,
			tags: ["ai-generated"],
			spaceSlug,
			contributorId: claims.sub,
			contributorName: claims.username || null,
			source: 'ai-generated',
			status: 'published',
			viewCount: 0,
			paymentTx: null,
			paymentNetwork: null,
			createdAt: now,
			processingStatus: 'ready',
			processingError: null,
			sourceFileCount: 0,
			sourceFiles: [],
			thumbnailUrl: thumbnail_url || null,
		};
	});

	return c.json({
		id: splatId,
		slug: finalSlug,
		title: title.trim(),
		file_format: ext,
		file_size_bytes: buf.byteLength,
		created_at: new Date(now).toISOString(),
	}, 201);
});

// ── API: My history (authenticated user's splats) ──
routes.get("/api/splats/my-history", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);

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

	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const doc = ensureDoc(dataSpace);

	const items = Object.values(doc.items)
		.filter((item) => item.status === 'published' && item.contributorId === claims.sub)
		.sort((a, b) => b.createdAt - a.createdAt)
		.slice(0, 50);

	return c.json({ splats: items.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain"))) });
});

// ── API: Delete splat (owner only) ──
routes.delete("/api/splats/:id", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);

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

	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const id = c.req.param("id");

	const doc = ensureDoc(dataSpace);
	const found = findItem(doc, id);

	if (!found || found[1].status !== 'published') {
		return c.json({ error: "Splat not found" }, 404);
	}

	const [itemKey, item] = found;
	if (item.contributorId !== claims.sub) {
		return c.json({ error: "Not authorized" }, 403);
	}

	const docId = splatScenesDocId(dataSpace);
	_syncServer!.changeDoc<SplatScenesDoc>(docId, 'remove splat', (d) => {
		d.items[itemKey].status = 'removed';
	});

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

// ── Page: Gallery ──
routes.get("/", async (c) => {
	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;

	const doc = ensureDoc(dataSpace);

	const items = Object.values(doc.items)
		.filter((item) => item.status === 'published')
		.sort((a, b) => b.createdAt - a.createdAt)
		.slice(0, 50);

	const rows = items.map(i => itemToListRow(i, spaceSlug, c.get("isSubdomain")));
	const splatsJSON = JSON.stringify(rows);

	const html = renderShell({
		title: `${spaceSlug} — rSplat | rSpace`,
		moduleId: "rsplat",
		spaceSlug,
		body: `<folk-splat-viewer id="gallery" mode="gallery"></folk-splat-viewer>`,
		modules: getModuleInfoList(),
		theme: "dark",
		head: `
			<link rel="stylesheet" href="/modules/rsplat/splat.css?v=7">
			${IMPORTMAP}
			${MODEL_VIEWER_SCRIPT}
		`,
		scripts: `
			<script type="module">
				import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=11';
				const gallery = document.getElementById('gallery');
				gallery.splats = ${splatsJSON};
				gallery.spaceSlug = '${spaceSlug}';
			</script>
		`,
	});

	return c.html(html);
});

// ── Shared viewer page renderer ──
function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string, isSubdomain?: boolean) {
	const doc = ensureDoc(dataSpace);
	const found = findItem(doc, idOrSlug);

	if (!found || found[1].status !== 'published') {
		const html = renderShell({
			title: "Splat not found | rSpace",
			moduleId: "rsplat",
			spaceSlug,
			body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Splat not found</h2><p><a href="/rsplat" style="color:#818cf8;">Back to gallery</a></p></div>`,
			modules: getModuleInfoList(),
			theme: "dark",
		});
		return { html, status: 404 as const };
	}

	const [itemKey, splat] = found;

	// Increment view count
	const docId = splatScenesDocId(dataSpace);
	_syncServer!.changeDoc<SplatScenesDoc>(docId, 'increment view count', (d) => {
		d.items[itemKey].viewCount += 1;
	});

	const fileUrl = isSubdomain ? `/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}` : `/${spaceSlug}/rsplat/api/splats/${splat.slug}/${splat.slug}.${splat.fileFormat}`;

	const html = renderShell({
		title: `${splat.title} | rSplat`,
		moduleId: "rsplat",
		spaceSlug,
		body: `
			<folk-splat-viewer
				id="viewer"
				mode="viewer"
				splat-url="${escapeAttr(fileUrl)}"
				splat-title="${escapeAttr(splat.title)}"
				splat-desc="${escapeAttr(splat.description || '')}"
				space-slug="${escapeAttr(spaceSlug)}"
			></folk-splat-viewer>
		`,
		modules: getModuleInfoList(),
		theme: "dark",
		head: `
			<link rel="stylesheet" href="/modules/rsplat/splat.css?v=7">
			${IMPORTMAP}
			${MODEL_VIEWER_SCRIPT}
		`,
		scripts: `
			<script type="module">
				import { FolkSplatViewer } from '/modules/rsplat/folk-splat-viewer.js?v=11';
			</script>
		`,
	});

	return { html, status: 200 as const };
}

// ── Page: Viewer (legacy /view/:id → 301 redirect to /:slug) ──
routes.get("/view/:id", async (c) => {
	const spaceSlug = c.req.param("space") || "demo";
	const id = c.req.param("id");
	const isSub = (c as any).get?.("isSubdomain");
	return c.redirect(isSub ? `/rsplat/${id}` : `/${spaceSlug}/rsplat/${id}`, 301);
});

// ── Seed template data ──

function seedTemplateSplat(space: string) {
	if (!_syncServer) return;
	const doc = ensureDoc(space);
	if (Object.keys(doc.items).length > 0) return;

	const docId = splatScenesDocId(space);
	const now = Date.now();

	const scenes: Array<{ title: string; slug: string; desc: string; tags: string[] }> = [
		{
			title: 'Community Garden Scan', slug: 'community-garden-scan',
			desc: 'A 3D Gaussian splat capture of the community garden space, captured with a phone camera walk-around.',
			tags: ['outdoor', 'community', 'garden'],
		},
		{
			title: 'Workshop Space Scan', slug: 'workshop-space-scan',
			desc: 'Interior scan of the maker workshop, showing CNC, 3D printer stations, and material shelving.',
			tags: ['indoor', 'workshop', 'makerspace'],
		},
	];

	_syncServer.changeDoc<SplatScenesDoc>(docId, 'seed template splats', (d) => {
		for (const s of scenes) {
			const id = crypto.randomUUID();
			d.items[id] = {
				id, slug: s.slug, title: s.title, description: s.desc,
				filePath: '', fileFormat: 'splat', fileSizeBytes: 0,
				tags: s.tags, spaceSlug: space,
				contributorId: 'did:demo:seed', contributorName: 'Demo',
				source: 'upload', status: 'published', viewCount: 0,
				paymentTx: null, paymentNetwork: null,
				createdAt: now, processingStatus: 'ready', processingError: null,
				sourceFileCount: 0, sourceFiles: [], thumbnailUrl: null,
			};
		}
	});

	console.log(`[Splat] Template seeded for "${space}": 2 splat entries`);
}

// ── Page: Viewer (clean /:slug URL — registered last to avoid shadowing api/view) ──
const RESERVED_SLUGS = new Set(["api", "view", "template"]);
routes.get("/:slug", async (c) => {
	const slug = c.req.param("slug");
	if (RESERVED_SLUGS.has(slug)) return c.notFound();

	const spaceSlug = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || spaceSlug;
	const result = renderViewerPage(spaceSlug, dataSpace, slug, c.get("isSubdomain"));
	return c.html(result.html, result.status);
});

export function getRecentSplatsForMI(space: string, limit = 5): { id: string; title: string; format: string; status: string; createdAt: number }[] {
	if (!_syncServer) return [];
	const docId = splatScenesDocId(space);
	const doc = _syncServer.getDoc<SplatScenesDoc>(docId);
	if (!doc) return [];
	return Object.values(doc.items)
		.sort((a, b) => b.createdAt - a.createdAt)
		.slice(0, limit)
		.map((s) => ({ id: s.id, title: s.title, format: s.fileFormat, status: s.status, createdAt: s.createdAt }));
}

// ── Module export ──

export const splatModule: RSpaceModule = {
	id: "rsplat",
	name: "rSplat",
	icon: "🔮",
	description: "3D Gaussian splat viewer",
	canvasShapes: ["folk-splat"],
	publicWrite: true,
	scoping: { defaultScope: 'global', userConfigurable: true },
	docSchemas: [{ pattern: '{space}:splat:scenes', description: 'Splat scene metadata', init: splatScenesSchema.init }],
	routes,
	landingPage: renderLanding,
	seedTemplate: seedTemplateSplat,
	feeds: [
		{ id: "splat-scenes", name: "3D Scenes", kind: "resource", description: "Gaussian splat 3D captures" },
		{ id: "splat-activity", name: "Capture Activity", kind: "data", description: "Upload and view events for 3D scenes" },
	],
	acceptsFeeds: ["data", "resource"],
	standaloneDomain: "rsplat.online",
	hidden: true,
	outputPaths: [
		{ path: "drawings", name: "Drawings", icon: "🔮", description: "3D Gaussian splat drawings" },
	],
	subPageInfos: [
		{
			path: "view",
			title: "Splat Viewer",
			icon: "🔮",
			tagline: "rSplat Tool",
			description: "Explore 3D Gaussian splat captures in an interactive WebGL viewer. Orbit, zoom, and inspect photorealistic 3D scenes.",
			features: [
				{ icon: "🖱️", title: "Interactive 3D", text: "Orbit, pan, and zoom through photorealistic 3D captures in your browser." },
				{ icon: "📷", title: "Multi-Angle Capture", text: "View scenes reconstructed from hundreds of photos using Gaussian splatting." },
				{ icon: "📤", title: "Upload & Share", text: "Upload .ply or .splat files and share interactive 3D views with anyone." },
			],
		},
	],
	async onInit(ctx) {
		_syncServer = ctx.syncServer;
		console.log("[Splat] Automerge document store ready");
	},
	onboardingActions: [
		{ label: "3D Splatting", icon: "🌀", description: "Upload a PLY or Splat file", type: 'upload' as const, upload: { accept: '.ply,.splat', endpoint: '/{space}/rsplat/api/scenes' } },
	],
	async onSpaceCreate(ctx: SpaceLifecycleContext) {
		// Eagerly create the Automerge doc for new spaces
		ensureDoc(ctx.spaceSlug);
	},
};
