/**
 * Space registry — CRUD for rSpace spaces.
 *
 * Spaces are stored as Automerge CRDT documents (extending the existing
 * community-store pattern). This module provides Hono routes for listing,
 * creating, and managing spaces.
 */

import { Hono } from "hono";
import { stat } from "node:fs/promises";
import { createTransport, type Transporter } from "nodemailer";
import {
	communityExists,
	createCommunity,
	deleteCommunity,
	loadCommunity,
	getDocumentData,
	listCommunities,
	addNestedSpace,
	updateNestedSpace,
	removeNestedSpace,
	getNestPolicy,
	updateNestPolicy,
	updateSpaceMeta,
	capPermissions,
	findNestedIn,
	setEncryption,
	setMember,
	removeMember,
	DEFAULT_COMMUNITY_NEST_POLICY,
	addShapes,
	addConnection,
	updateConnection,
	removeConnection,
	getConnectionPolicy,
	updateConnectionPolicy,
} from "./community-store";
import type {
	SpaceVisibility,
	NestPermissions,
	NestPolicy,
	SpaceRef,
	PendingNestRequest,
	NestRequestStatus,
} from "./community-store";
import type { FlowKind } from "../lib/layer-types";
import type {
	ConnectionPolicy,
	ConnectionPermissions,
	ConnectionDirection,
	ConnectionState,
	SpaceConnection,
	PendingConnectionRequest,
	ConnectionRequestStatus,
} from "../lib/connection-types";
import {
	DEFAULT_PERSONAL_CONNECTION_POLICY,
	DEFAULT_COMMUNITY_CONNECTION_POLICY,
	invertDirection,
	computeMembranePermeability,
} from "../lib/connection-types";
import { verifyToken, extractToken } from "./auth";
import type { EncryptIDClaims } from "./auth";
import { getAllModules, getModule } from "../shared/module";
import type { SpaceLifecycleContext } from "../shared/module";
import { syncServer } from "./sync-instance";

import { notify, notifySpaceAdmins } from "./notification-service";

const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";

// ── Role types and helpers ──

export type SpaceRoleString = 'viewer' | 'member' | 'moderator' | 'admin';

const ROLE_LEVELS: Record<SpaceRoleString, number> = {
	viewer: 0,
	member: 1,
	moderator: 2,
	admin: 3,
};

export function roleAtLeast(actual: SpaceRoleString, required: SpaceRoleString): boolean {
	return ROLE_LEVELS[actual] >= ROLE_LEVELS[required];
}

export async function resolveCallerRole(
	slug: string,
	claims: EncryptIDClaims | null,
): Promise<{ role: SpaceRoleString; isOwner: boolean } | null> {
	await loadCommunity(slug);
	const data = getDocumentData(slug);
	if (!data) return null;

	if (!claims) return { role: 'viewer', isOwner: false };

	// Check both claims.sub (raw userId) and claims.did (did:key:...) for
	// compatibility — auto-provisioned spaces store ownerDID as did:key:,
	// while API-created spaces store it as raw userId.
	const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
	const isOwner = data.meta.ownerDID === claims.sub || data.meta.ownerDID === callerDid;
	if (isOwner) return { role: 'admin', isOwner: true };

	const member = data.members?.[claims.sub] || data.members?.[callerDid];
	if (member) return { role: member.role, isOwner: false };

	// Non-member defaults
	return { role: 'viewer', isOwner: false };
}

// ── Unified space creation ──

export interface CreateSpaceOpts {
	name: string;
	slug: string;
	ownerDID: string;
	visibility?: SpaceVisibility;
	enabledModules?: string[];
	source?: 'api' | 'auto-provision' | 'subdomain' | 'internal';
}

export type CreateSpaceResult =
	| { ok: true; slug: string; name: string; visibility: SpaceVisibility; ownerDID: string }
	| { ok: false; error: string; status: 400 | 409 };

/**
 * Unified space creation: validate → create → notify modules.
 * All creation endpoints should call this instead of duplicating logic.
 */
export async function createSpace(opts: CreateSpaceOpts): Promise<CreateSpaceResult> {
	const { name, slug, ownerDID, visibility = 'private', enabledModules, source = 'api' } = opts;

	if (!name || !slug) return { ok: false, error: "Name and slug are required", status: 400 };
	if (!/^[a-z0-9-]+$/.test(slug)) return { ok: false, error: "Slug must contain only lowercase letters, numbers, and hyphens", status: 400 };
	if (await communityExists(slug)) return { ok: false, error: "Space already exists", status: 409 };

	// Personal spaces (auto-provisioned) get conservative connection policy;
	// community spaces get the broader default.
	const isPersonal = source === 'auto-provision';
	const connectionPolicy = isPersonal
		? DEFAULT_PERSONAL_CONNECTION_POLICY
		: DEFAULT_COMMUNITY_CONNECTION_POLICY;

	await createCommunity(name, slug, ownerDID, visibility, { connectionPolicy });

	// If enabledModules specified, update the community doc
	if (enabledModules) {
		updateSpaceMeta(slug, { enabledModules });
	}

	// Build lifecycle context and notify all modules
	const ctx: SpaceLifecycleContext = {
		spaceSlug: slug,
		ownerDID,
		enabledModules: enabledModules || getAllModules().map(m => m.id),
		syncServer,
	};

	for (const mod of getAllModules()) {
		if (mod.onSpaceCreate) {
			try {
				await mod.onSpaceCreate(ctx);
			} catch (e) {
				console.error(`[createSpace:${source}] Module ${mod.id} onSpaceCreate failed:`, e);
			}
		}
	}

	// NOTE: New spaces start with a blank canvas.
	// Template shapes and module demo data are NOT seeded here.
	// The rinbox onSpaceCreate hook provisions a {slug}@rspace.online mailbox.

	console.log(`[createSpace:${source}] Created space: ${slug}`);
	return { ok: true, slug, name, visibility, ownerDID };
}

/** Build lifecycle context for an existing space. */
function buildLifecycleContext(slug: string, doc: ReturnType<typeof getDocumentData>): SpaceLifecycleContext {
	return {
		spaceSlug: slug,
		ownerDID: doc?.meta?.ownerDID ?? null,
		enabledModules: doc?.meta?.enabledModules || getAllModules().map(m => m.id),
		syncServer,
	};
}

// ── In-memory pending nest requests (move to DB later) ──
const nestRequests = new Map<string, PendingNestRequest>();
let nestRequestCounter = 0;

// ── In-memory pending connection requests (move to DB later) ──
const connectionRequests = new Map<string, PendingConnectionRequest>();
let connectionRequestCounter = 0;

// ── In-memory access requests (move to DB later) ──
interface AccessRequest {
	id: string;
	spaceSlug: string;
	requesterDID: string;
	requesterUsername: string;
	message?: string;
	status: "pending" | "approved" | "denied";
	createdAt: number;
	resolvedAt?: number;
	resolvedBy?: string;
}
const accessRequests = new Map<string, AccessRequest>();
let accessRequestCounter = 0;

const spaces = new Hono();

// ── List spaces (public + user's own/member spaces) ──

spaces.get("/", async (c) => {
	const slugs = await listCommunities();

	// Check if user is authenticated
	const token = extractToken(c.req.raw.headers);
	let claims: EncryptIDClaims | null = null;
	if (token) {
		try {
			claims = await verifyToken(token);
		} catch {
			// Invalid token — treat as unauthenticated
		}
	}

	const spacesList = [];
	const seenSlugs = new Set<string>();
	const username = claims?.username?.toLowerCase();

	for (const slug of slugs) {
		await loadCommunity(slug);
		const data = getDocumentData(slug);
		if (data?.meta) {
			// Deduplicate by slug
			if (seenSlugs.has(slug)) continue;
			seenSlugs.add(slug);

			let vis = data.meta.visibility || "private";
			// Demo space is always public
			if (slug === "demo") vis = "public";
			// Check both claims.sub (raw userId) and did:key: format for
			// compatibility — auto-provisioned spaces store ownerDID as did:key:
			const callerDid = claims ? ((claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`) : "";
			const isOwner = !!(claims && (data.meta.ownerDID === claims.sub || data.meta.ownerDID === callerDid));
			const memberEntry = claims ? (data.members?.[claims.sub] || data.members?.[callerDid]) : undefined;
			const isMember = !!memberEntry;

			// Owner's personal space (slug matches username) is always private
			if (isOwner && username && slug === username) {
				vis = "private";
			}

			// Determine accessibility
			const isPublicSpace = vis === "public";
			const isPermissioned = vis === "permissioned";
			const accessible = isPublicSpace || isOwner || isMember;
			if (!accessible) continue;

			// For unauthenticated: only show demo
			if (!claims && slug !== "demo") continue;

			// Public spaces are shown in the PUBLIC section for all authenticated users

			// Determine relationship
			const relationship = isOwner
				? "owner" as const
				: isMember
					? "member" as const
					: slug === "demo"
						? "demo" as const
						: "other" as const;

			// Check for pending access request
			const pendingRequest = claims
				? Array.from(accessRequests.values()).some(
					(r) => r.spaceSlug === slug && r.requesterDID === claims.sub && r.status === "pending"
				)
				: false;

			spacesList.push({
				slug: data.meta.slug,
				name: data.meta.name,
				visibility: vis,
				createdAt: data.meta.createdAt,
				role: isOwner ? "owner" : memberEntry?.role || undefined,
				accessible,
				relationship,
				pendingRequest: pendingRequest || undefined,
				isPersonal: !!(username && slug === username),
			});
		}
	}

	// Sort: private first (red), then permissioned (yellow), then public (green)
	// Within each group: user's own spaces first, then alphabetically
	const visOrder: Record<string, number> = { private: 0, permissioned: 1, public: 2 };
	spacesList.sort((a, b) => {
		const va = visOrder[a.visibility || "private"] ?? 0;
		const vb = visOrder[b.visibility || "private"] ?? 0;
		if (va !== vb) return va - vb;
		if (a.role && !b.role) return -1;
		if (!a.role && b.role) return 1;
		if (a.slug === "demo") return -1;
		if (b.slug === "demo") return 1;
		return a.name.localeCompare(b.name);
	});

	return c.json({ spaces: spacesList });
});

// ── Create a new space (requires auth) ──

spaces.post("/", 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 body = await c.req.json<{
		name?: string;
		slug?: string;
		visibility?: SpaceVisibility;
		enabledModules?: string[];
	}>();

	const { name, slug, visibility = "private", enabledModules } = body;

	const validVisibilities: SpaceVisibility[] = ["public", "permissioned", "private"];
	if (visibility && !validVisibilities.includes(visibility)) {
		return c.json({ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, 400);
	}

	const result = await createSpace({
		name: name || "",
		slug: slug || "",
		ownerDID: claims.sub,
		visibility,
		enabledModules,
		source: 'api',
	});

	if (!result.ok) return c.json({ error: result.error }, result.status);

	return c.json({
		slug: result.slug,
		name: result.name,
		visibility: result.visibility,
		ownerDID: result.ownerDID,
		url: `https://${result.slug}.rspace.online/rspace`,
	}, 201);
});

// ── Module configuration per space ──

spaces.get("/:slug/modules", async (c) => {
	const slug = c.req.param("slug");
	await loadCommunity(slug);
	const doc = getDocumentData(slug);
	if (!doc?.meta) return c.json({ error: "Space not found" }, 404);

	const allModules = getAllModules();
	const enabled = doc.meta.enabledModules; // null = all
	const overrides = doc.meta.moduleScopeOverrides || {};

	const savedSettings = doc.meta.moduleSettings || {};

	const modules = allModules.map(mod => ({
		id: mod.id,
		name: mod.name,
		icon: mod.icon,
		enabled: enabled ? enabled.includes(mod.id) : true,
		scoping: {
			defaultScope: mod.scoping.defaultScope,
			userConfigurable: mod.scoping.userConfigurable,
			currentScope: overrides[mod.id] || mod.scoping.defaultScope,
		},
		...(mod.settingsSchema ? { settingsSchema: mod.settingsSchema } : {}),
		...(savedSettings[mod.id] ? { settings: savedSettings[mod.id] } : {}),
	}));

	return c.json({ modules, enabledModules: enabled });
});

spaces.patch("/:slug/modules", async (c) => {
	const slug = c.req.param("slug");

	// Auth: only space owner or admin can configure modules
	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); }

	await loadCommunity(slug);
	const doc = getDocumentData(slug);
	if (!doc?.meta) return c.json({ error: "Space not found" }, 404);
	if (doc.meta.ownerDID && doc.meta.ownerDID !== claims.sub) {
		return c.json({ error: "Only the space owner can configure modules" }, 403);
	}

	const body = await c.req.json<{
		enabledModules?: string[] | null;
		scopeOverrides?: Record<string, 'space' | 'global'>;
		moduleSettings?: Record<string, Record<string, string | boolean>>;
	}>();

	const updates: Partial<typeof doc.meta> = {};

	// Validate and set enabledModules
	if (body.enabledModules !== undefined) {
		if (body.enabledModules === null) {
			updates.enabledModules = undefined; // reset to "all enabled"
		} else {
			const validIds = new Set(getAllModules().map(m => m.id));
			const invalid = body.enabledModules.filter(id => !validIds.has(id));
			if (invalid.length) return c.json({ error: `Unknown modules: ${invalid.join(", ")}` }, 400);
			// Always include rspace (core module)
			const enabled = new Set(body.enabledModules);
			enabled.add("rspace");
			updates.enabledModules = Array.from(enabled);
		}
	}

	// Validate and set scope overrides
	if (body.scopeOverrides) {
		const newOverrides: Record<string, 'space' | 'global'> = { ...(doc.meta.moduleScopeOverrides || {}) };
		for (const [modId, scope] of Object.entries(body.scopeOverrides)) {
			const mod = getModule(modId);
			if (!mod) return c.json({ error: `Unknown module: ${modId}` }, 400);
			if (!mod.scoping.userConfigurable) {
				return c.json({ error: `Module ${modId} does not support scope configuration` }, 400);
			}
			if (scope !== 'space' && scope !== 'global') {
				return c.json({ error: `Invalid scope '${scope}' for ${modId}` }, 400);
			}
			newOverrides[modId] = scope;
		}
		updates.moduleScopeOverrides = newOverrides;
	}

	// Validate and merge module settings
	if (body.moduleSettings) {
		const existing = doc.meta.moduleSettings || {};
		const merged = { ...existing };
		for (const [modId, settings] of Object.entries(body.moduleSettings)) {
			const mod = getModule(modId);
			if (!mod) return c.json({ error: `Unknown module: ${modId}` }, 400);
			if (!mod.settingsSchema) return c.json({ error: `Module ${modId} has no settings schema` }, 400);
			const validKeys = new Set(mod.settingsSchema.map(f => f.key));
			for (const key of Object.keys(settings)) {
				if (!validKeys.has(key)) return c.json({ error: `Unknown setting '${key}' for module ${modId}` }, 400);
			}
			merged[modId] = { ...(existing[modId] || {}), ...settings };
		}
		updates.moduleSettings = merged;
	}

	if (Object.keys(updates).length > 0) {
		updateSpaceMeta(slug, updates);
	}

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

// ── Static routes must be defined BEFORE /:slug to avoid matching as a slug ──

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

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

// ── Admin: list ALL spaces with detailed stats ──

spaces.get("/admin", 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 (!isAdmin(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;

		// Get file size on disk
		let fileSizeBytes = 0;
		try {
			const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
			fileSizeBytes = s.size;
		} catch {
			try {
				const s = await stat(`${STORAGE_DIR}/${slug}.json`);
				fileSizeBytes = s.size;
			} catch { /* not on disk yet */ }
		}

		// Count shapes by type
		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,
		});
	}

	// Sort by creation date descending (newest first)
	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 });
});

// ── Admin: force-delete a space (bypasses owner check) ──

spaces.delete("/admin/:slug", async (c) => {
	const slug = c.req.param("slug");
	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 (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403);

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

	// Notify modules
	for (const mod of getAllModules()) {
		if (mod.onSpaceDelete) {
			try { await mod.onSpaceDelete(buildLifecycleContext(slug, data)); } catch (e) {
				console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e);
			}
		}
	}

	// Clean up in-memory request maps
	for (const [id, req] of accessRequests) {
		if (req.spaceSlug === slug) accessRequests.delete(id);
	}
	for (const [id, req] of nestRequests) {
		if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id);
	}
	for (const [id, req] of connectionRequests) {
		if (req.fromSlug === slug || req.toSlug === slug) connectionRequests.delete(id);
	}

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

// ── Get pending access requests for spaces the user owns ──

spaces.get("/notifications", 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); }

	const slugs = await listCommunities();
	const ownedSlugs = new Set<string>();

	for (const slug of slugs) {
		await loadCommunity(slug);
		const data = getDocumentData(slug);
		if (data?.meta?.ownerDID === claims.sub) {
			ownedSlugs.add(slug);
		}
	}

	const requests = Array.from(accessRequests.values()).filter(
		(r) => ownedSlugs.has(r.spaceSlug) && r.status === "pending"
	);

	return c.json({ requests });
});

// ── Get space info ──

spaces.get("/:slug", async (c) => {
	const slug = c.req.param("slug");
	await loadCommunity(slug);
	const data = getDocumentData(slug);

	if (!data) {
		return c.json({ error: "Space not found" }, 404);
	}

	return c.json({
		slug: data.meta.slug,
		name: data.meta.name,
		visibility: data.meta.visibility,
		description: data.meta.description || "",
		createdAt: data.meta.createdAt,
		ownerDID: data.meta.ownerDID,
		memberCount: Object.keys(data.members || {}).length,
	});
});

// ── Delete a space (owner only) ──

spaces.delete("/:slug", async (c) => {
	const slug = c.req.param("slug");
	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); }

	// Protect core spaces
	if (slug === "demo" || slug === "commonshub") {
		return c.json({ error: "Cannot delete this space" }, 403);
	}

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

	// Must be owner
	if (data.meta.ownerDID !== claims.sub) {
		return c.json({ error: "Only the space owner can delete a space" }, 403);
	}

	// Notify all modules about deletion
	for (const mod of getAllModules()) {
		if (mod.onSpaceDelete) {
			try { await mod.onSpaceDelete(buildLifecycleContext(slug, data)); } catch (e) {
				console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e);
			}
		}
	}

	// Clean up in-memory request maps
	for (const [id, req] of accessRequests) {
		if (req.spaceSlug === slug) accessRequests.delete(id);
	}
	for (const [id, req] of nestRequests) {
		if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id);
	}
	for (const [id, req] of connectionRequests) {
		if (req.fromSlug === slug || req.toSlug === slug) connectionRequests.delete(id);
	}

	await deleteCommunity(slug);

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

// ── Update space metadata (owner/admin) ──

spaces.patch("/:slug", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	const body = await c.req.json<{ name?: string; visibility?: SpaceVisibility; description?: string; discoverable?: boolean }>();

	if (body.visibility) {
		const valid: SpaceVisibility[] = ["public", "permissioned", "private"];
		if (!valid.includes(body.visibility)) {
			return c.json({ error: `Invalid visibility. Must be one of: ${valid.join(", ")}` }, 400);
		}
	}

	const { name, visibility, description, discoverable } = body;
	updateSpaceMeta(slug, { name, visibility, description, discoverable });
	const updated = getDocumentData(slug);

	return c.json({
		ok: true,
		name: updated?.meta.name,
		visibility: updated?.meta.visibility,
		description: updated?.meta.description,
		discoverable: updated?.meta.discoverable,
	});
});

// ── List members of a space (owner/member) ──

spaces.get("/:slug/members", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	const isOwner = data.meta.ownerDID === claims.sub;
	const memberEntry = data.members?.[claims.sub];
	if (!isOwner && !memberEntry) {
		return c.json({ error: "Access denied" }, 403);
	}

	const rawMembers = Object.values(data.members || {}).map((m) => ({
		...m,
		isOwner: m.did === data.meta.ownerDID,
	}));

	// Enrich members missing displayName by looking up username from EncryptID.
	// Historical records (esp. owners) didn't store it; without this the UI falls
	// back to a truncated DID.
	const members = await Promise.all(rawMembers.map(async (m) => {
		if (m.displayName) return m;
		try {
			const r = await fetch(`${ENCRYPTID_URL}/api/internal/user-email/${encodeURIComponent(m.did)}`);
			if (!r.ok) return m;
			const info = await r.json() as { username?: string | null; displayName?: string | null };
			const name = info.displayName || info.username;
			return name ? { ...m, displayName: name } : m;
		} catch {
			return m;
		}
	}));

	return c.json({ members });
});

// ── Change a member's role (owner/admin) ──

spaces.patch("/:slug/members/:did", async (c) => {
	const slug = c.req.param("slug");
	const did = decodeURIComponent(c.req.param("did"));
	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); }

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

	const isOwner = data.meta.ownerDID === claims.sub;
	const callerMember = data.members?.[claims.sub];
	if (!isOwner && callerMember?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	// Cannot change the owner's role
	if (did === data.meta.ownerDID) {
		return c.json({ error: "Cannot change the owner's role" }, 403);
	}

	const body = await c.req.json<{ role: "viewer" | "member" | "moderator" | "admin" }>();
	const validRoles = ["viewer", "member", "moderator", "admin"];
	if (!validRoles.includes(body.role)) {
		return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400);
	}

	setMember(slug, did, body.role);

	// Notify the member about their role change
	notify({
		userDid: did,
		category: 'space',
		eventType: 'role_changed',
		title: `Your role in "${slug}" changed to ${body.role}`,
		spaceSlug: slug,
		actorDid: claims.sub,
		actorUsername: claims.username,
		actionUrl: `/rspace`,
		metadata: { newRole: body.role },
	}).catch(() => {});

	// Sync space email alias after role change
	fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ userDid: did, role: body.role }),
	}).catch(() => {});

	return c.json({ ok: true, did, role: body.role });
});

// ── Remove a member (owner/admin) ──

spaces.delete("/:slug/members/:did", async (c) => {
	const slug = c.req.param("slug");
	const did = decodeURIComponent(c.req.param("did"));
	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); }

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

	const isOwner = data.meta.ownerDID === claims.sub;
	const callerMember = data.members?.[claims.sub];
	if (!isOwner && callerMember?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	// Cannot remove the owner
	if (did === data.meta.ownerDID) {
		return c.json({ error: "Cannot remove the space owner" }, 403);
	}

	removeMember(slug, did);

	// Notify the removed member
	notify({
		userDid: did,
		category: 'space',
		eventType: 'member_left',
		title: `You were removed from "${slug}"`,
		spaceSlug: slug,
		actorDid: claims.sub,
		actorUsername: claims.username,
	}).catch(() => {});

	// Remove member's email forwarding preference + resync alias
	fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/email-forwarding/${encodeURIComponent(did)}`, {
		method: "DELETE",
	}).catch(() => {});

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

// ── Get access requests for a specific space (owner/admin) ──

spaces.get("/:slug/access-requests", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	const isOwner = data.meta.ownerDID === claims.sub;
	const member = data.members?.[claims.sub];
	if (!isOwner && member?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	const requests = Array.from(accessRequests.values())
		.filter((r) => r.spaceSlug === slug);

	return c.json({ requests });
});

// ══════════════════════════════════════════════════════════════════════════════
// NESTING API
// ══════════════════════════════════════════════════════════════════════════════

// ── Get nest policy for a space ──

spaces.get("/:slug/nest-policy", async (c) => {
	const slug = c.req.param("slug");
	await loadCommunity(slug);
	const policy = getNestPolicy(slug);
	if (!policy) return c.json({ error: "Space not found" }, 404);
	return c.json({ nestPolicy: policy });
});

// ── Update nest policy (admin only) ──

spaces.patch("/:slug/nest-policy", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	// Must be admin or owner
	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required" }, 403);
	}

	const body = await c.req.json<Partial<NestPolicy>>();
	updateNestPolicy(slug, body);

	return c.json({ ok: true, nestPolicy: getNestPolicy(slug) });
});

// ── List nested spaces in a space ──

spaces.get("/:slug/nest", async (c) => {
	const slug = c.req.param("slug");
	await loadCommunity(slug);
	const data = getDocumentData(slug);
	if (!data) return c.json({ error: "Space not found" }, 404);

	const refs = Object.values(data.nestedSpaces || {});
	return c.json({ nestedSpaces: refs });
});

// ── Nest a space (create SpaceRef) — respects source space's NestPolicy ──

spaces.post("/:slug/nest", async (c) => {
	const targetSlug = c.req.param("slug");
	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); }

	const body = await c.req.json<{
		sourceSlug: string;
		permissions?: Partial<NestPermissions>;
		filter?: SpaceRef['filter'];
		x?: number; y?: number;
		width?: number; height?: number;
		rotation?: number;
		label?: string;
		collapsed?: boolean;
		message?: string;
	}>();

	const { sourceSlug } = body;
	if (!sourceSlug) return c.json({ error: "sourceSlug is required" }, 400);

	// Load both spaces
	await loadCommunity(targetSlug);
	await loadCommunity(sourceSlug);
	const targetData = getDocumentData(targetSlug);
	const sourceData = getDocumentData(sourceSlug);
	if (!targetData) return c.json({ error: "Target space not found" }, 404);
	if (!sourceData) return c.json({ error: "Source space not found" }, 404);

	// Check: requester must be admin or moderator in the TARGET space
	const targetMember = targetData.members?.[claims.sub];
	const isTargetOwner = targetData.meta.ownerDID === claims.sub;
	if (!isTargetOwner && targetMember?.role !== 'admin' && targetMember?.role !== 'moderator') {
		return c.json({ error: "Admin or moderator role required in the target space" }, 403);
	}

	// Get source space's nest policy
	const policy = sourceData.meta.nestPolicy || DEFAULT_COMMUNITY_NEST_POLICY;

	// Check blocklist
	if (policy.blocklist?.includes(targetSlug)) {
		return c.json({ error: "Source space has blocked nesting into this space" }, 403);
	}

	// Check consent level
	const isOnAllowlist = policy.allowlist?.includes(targetSlug);

	if (policy.consent === 'closed' && !isOnAllowlist) {
		return c.json({ error: "Source space does not allow nesting" }, 403);
	}

	if (policy.consent === 'members' && !isOnAllowlist) {
		const sourceMember = sourceData.members?.[claims.sub];
		const isSourceOwner = sourceData.meta.ownerDID === claims.sub;
		if (!isSourceOwner && !sourceMember) {
			return c.json({ error: "Must be a member of the source space to nest it" }, 403);
		}
	}

	// Build requested permissions, capped by source policy's ceiling
	const requestedPerms: NestPermissions = {
		read: body.permissions?.read ?? true,
		write: body.permissions?.write ?? false,
		addShapes: body.permissions?.addShapes ?? false,
		deleteShapes: body.permissions?.deleteShapes ?? false,
		reshare: body.permissions?.reshare ?? false,
		expiry: body.permissions?.expiry,
	};
	const cappedPerms = capPermissions(requestedPerms, policy.defaultPermissions);

	// If consent is 'approval' and not on allowlist, create a pending request
	if (policy.consent === 'approval' && !isOnAllowlist) {
		const reqId = `nest-req-${++nestRequestCounter}`;
		const request: PendingNestRequest = {
			id: reqId,
			sourceSlug,
			targetSlug,
			requestedBy: claims.sub,
			requestedPermissions: cappedPerms,
			message: body.message,
			status: 'pending',
			createdAt: Date.now(),
		};
		nestRequests.set(reqId, request);

		return c.json({
			status: 'pending',
			requestId: reqId,
			message: 'Nest request created. Awaiting source space admin approval.',
			cappedPermissions: cappedPerms,
		}, 202);
	}

	// Consent is 'open', 'members' (passed), or allowlisted — create immediately
	const refId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
	const ref: SpaceRef = {
		id: refId,
		sourceSlug,
		sourceDID: claims.sub,
		filter: body.filter,
		x: body.x ?? 100,
		y: body.y ?? 100,
		width: body.width ?? 600,
		height: body.height ?? 400,
		rotation: body.rotation ?? 0,
		permissions: cappedPerms,
		collapsed: body.collapsed ?? false,
		label: body.label,
		createdAt: Date.now(),
		createdBy: claims.sub,
	};

	addNestedSpace(targetSlug, ref);

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

// ── Get a specific nested space ref ──

spaces.get("/:slug/nest/:refId", async (c) => {
	const slug = c.req.param("slug");
	const refId = c.req.param("refId");
	await loadCommunity(slug);
	const data = getDocumentData(slug);
	if (!data) return c.json({ error: "Space not found" }, 404);

	const ref = data.nestedSpaces?.[refId];
	if (!ref) return c.json({ error: "Nested space ref not found" }, 404);

	return c.json({ ref });
});

// ── Update a nested space ref (permissions, filter, position) ──

spaces.patch("/:slug/nest/:refId", async (c) => {
	const slug = c.req.param("slug");
	const refId = c.req.param("refId");
	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); }

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

	const ref = data.nestedSpaces?.[refId];
	if (!ref) return c.json({ error: "Nested space ref not found" }, 404);

	// Must be admin/moderator in the nesting space OR the creator of this ref
	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	const isRefCreator = ref.createdBy === claims.sub;
	if (!isOwner && member?.role !== 'admin' && member?.role !== 'moderator' && !isRefCreator) {
		return c.json({ error: "Insufficient permissions" }, 403);
	}

	const body = await c.req.json<Partial<SpaceRef>>();
	updateNestedSpace(slug, refId, body);

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

// ── Remove a nested space ref (un-nest) ──

spaces.delete("/:slug/nest/:refId", async (c) => {
	const slug = c.req.param("slug");
	const refId = c.req.param("refId");
	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); }

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

	const ref = data.nestedSpaces?.[refId];
	if (!ref) return c.json({ error: "Nested space ref not found" }, 404);

	// Can be removed by:
	// 1. Admin/owner of the nesting (target) space
	// 2. Creator of this ref
	// 3. Admin/owner of the SOURCE space (sovereignty guarantee)
	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	const isRefCreator = ref.createdBy === claims.sub;

	let isSourceAdmin = false;
	await loadCommunity(ref.sourceSlug);
	const sourceData = getDocumentData(ref.sourceSlug);
	if (sourceData) {
		const sourceMember = sourceData.members?.[claims.sub];
		isSourceAdmin = sourceData.meta.ownerDID === claims.sub || sourceMember?.role === 'admin';
	}

	if (!isOwner && member?.role !== 'admin' && !isRefCreator && !isSourceAdmin) {
		return c.json({ error: "Insufficient permissions" }, 403);
	}

	removeNestedSpace(slug, refId);

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

// ── Reverse lookup: where is this space nested? ──

spaces.get("/:slug/nested-in", async (c) => {
	const slug = c.req.param("slug");
	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); }

	// Must be admin/owner of the space to see where it's nested
	await loadCommunity(slug);
	const data = getDocumentData(slug);
	if (!data) return c.json({ error: "Space not found" }, 404);

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required" }, 403);
	}

	const nestedIn = await findNestedIn(slug);
	return c.json({ nestedIn });
});

// ══════════════════════════════════════════════════════════════════════════════
// NEST REQUEST API (approval flow)
// ══════════════════════════════════════════════════════════════════════════════

// ── List pending nest requests for a space (admin only) ──

spaces.get("/:slug/nest-requests", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required" }, 403);
	}

	// Find requests where this space is the SOURCE (someone wants to nest us)
	const requests = Array.from(nestRequests.values())
		.filter(r => r.sourceSlug === slug);

	return c.json({ requests });
});

// ── Approve or deny a nest request ──

spaces.patch("/:slug/nest-requests/:reqId", async (c) => {
	const slug = c.req.param("slug");
	const reqId = c.req.param("reqId");
	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); }

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

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required" }, 403);
	}

	const request = nestRequests.get(reqId);
	if (!request || request.sourceSlug !== slug) {
		return c.json({ error: "Nest request not found" }, 404);
	}
	if (request.status !== 'pending') {
		return c.json({ error: `Request already ${request.status}` }, 400);
	}

	const body = await c.req.json<{
		action: 'approve' | 'deny';
		modifiedPermissions?: NestPermissions;
	}>();

	if (body.action === 'deny') {
		request.status = 'denied';
		request.resolvedAt = Date.now();
		request.resolvedBy = claims.sub;
		return c.json({ ok: true, status: 'denied' });
	}

	if (body.action === 'approve') {
		const finalPerms = body.modifiedPermissions || request.requestedPermissions;
		request.status = 'approved';
		request.resolvedAt = Date.now();
		request.resolvedBy = claims.sub;
		request.modifiedPermissions = body.modifiedPermissions;

		// Create the actual SpaceRef in the TARGET space
		await loadCommunity(request.targetSlug);
		const refId = `ref-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
		const ref: SpaceRef = {
			id: refId,
			sourceSlug: request.sourceSlug,
			sourceDID: request.requestedBy,
			x: 100,
			y: 100,
			width: 600,
			height: 400,
			rotation: 0,
			permissions: finalPerms,
			collapsed: false,
			createdAt: Date.now(),
			createdBy: request.requestedBy,
		};

		addNestedSpace(request.targetSlug, ref);

		return c.json({ ok: true, status: 'approved', ref });
	}

	return c.json({ error: "action must be 'approve' or 'deny'" }, 400);
});

// ══════════════════════════════════════════════════════════════════════════════
// CONNECTION API — bilateral typed relationships between spaces
// ══════════════════════════════════════════════════════════════════════════════

// ── Get connection policy for a space ──

spaces.get("/:slug/connection-policy", async (c) => {
	const slug = c.req.param("slug");
	await loadCommunity(slug);
	const policy = getConnectionPolicy(slug);
	if (!policy) return c.json({ error: "Space not found" }, 404);
	return c.json({ connectionPolicy: policy });
});

// ── Update connection policy (admin only) ──

spaces.patch("/:slug/connection-policy", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required" }, 403);
	}

	const body = await c.req.json<Partial<ConnectionPolicy>>();
	updateConnectionPolicy(slug, body);

	return c.json({ ok: true, connectionPolicy: getConnectionPolicy(slug) });
});

// ── List connections for a space (auth required) ──

spaces.get("/:slug/connections", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	// Must be at least a member to see connections
	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && !member) {
		return c.json({ error: "Must be a member to view connections" }, 403);
	}

	const connections = Object.values(data.connections || {});
	return c.json({ connections });
});

// ── Propose a connection (auth required, admin/moderator in FROM space) ──

spaces.post("/:slug/connections", async (c) => {
	const fromSlug = c.req.param("slug");
	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); }

	const body = await c.req.json<{
		toSlug: string;
		direction?: ConnectionDirection;
		flowKinds: FlowKind[];
		permissions?: Partial<ConnectionPermissions>;
		strength?: number;
		label?: string;
		economicFlows?: SpaceConnection['economicFlows'];
		delegations?: SpaceConnection['delegations'];
		informationFlow?: SpaceConnection['informationFlow'];
		meta?: Record<string, unknown>;
		message?: string;
	}>();

	const { toSlug } = body;
	if (!toSlug) return c.json({ error: "toSlug is required" }, 400);
	if (!body.flowKinds?.length) return c.json({ error: "flowKinds must be a non-empty array" }, 400);
	if (fromSlug === toSlug) return c.json({ error: "Cannot connect a space to itself" }, 400);

	// Load both spaces
	await loadCommunity(fromSlug);
	await loadCommunity(toSlug);
	const fromData = getDocumentData(fromSlug);
	const toData = getDocumentData(toSlug);
	if (!fromData) return c.json({ error: "Source space not found" }, 404);
	if (!toData) return c.json({ error: "Target space not found" }, 404);

	// Proposer must be admin or moderator in FROM space
	const fromMember = fromData.members?.[claims.sub];
	const isFromOwner = fromData.meta.ownerDID === claims.sub;
	if (!isFromOwner && fromMember?.role !== 'admin' && fromMember?.role !== 'moderator') {
		return c.json({ error: "Admin or moderator role required in the source space" }, 403);
	}

	// Check FROM space's outbound consent policy
	const fromPolicy = fromData.meta.connectionPolicy || DEFAULT_COMMUNITY_CONNECTION_POLICY;
	if (fromPolicy.outboundConsent === 'closed' && !fromPolicy.allowlist?.includes(toSlug)) {
		return c.json({ error: "Source space does not allow outbound connections" }, 403);
	}

	// Check TO space's inbound consent policy
	const toPolicy = toData.meta.connectionPolicy || DEFAULT_COMMUNITY_CONNECTION_POLICY;
	if (toPolicy.blocklist?.includes(fromSlug)) {
		return c.json({ error: "Target space has blocked connections from this space" }, 403);
	}
	if (toPolicy.inboundConsent === 'closed' && !toPolicy.allowlist?.includes(fromSlug)) {
		return c.json({ error: "Target space does not accept inbound connections" }, 403);
	}

	// Check flow kinds are accepted by target
	const rejectedKinds = body.flowKinds.filter(k => !toPolicy.acceptedFlowKinds.includes(k));
	if (rejectedKinds.length > 0) {
		return c.json({ error: `Target space does not accept flow kinds: ${rejectedKinds.join(', ')}` }, 403);
	}

	const direction: ConnectionDirection = body.direction || "bidirectional";
	const permissions: ConnectionPermissions = {
		read: body.permissions?.read ?? toPolicy.defaultPermissions.read,
		write: body.permissions?.write ?? toPolicy.defaultPermissions.write,
		configure: body.permissions?.configure ?? toPolicy.defaultPermissions.configure,
		pause: body.permissions?.pause ?? toPolicy.defaultPermissions.pause,
		revoke: body.permissions?.revoke ?? toPolicy.defaultPermissions.revoke,
		expiry: body.permissions?.expiry,
	};
	const strength = Math.max(0, Math.min(1, body.strength ?? 0.5));

	const isOnAllowlist = toPolicy.allowlist?.includes(fromSlug);

	// If inbound consent is 'approval' and not on allowlist, create a pending request
	if (toPolicy.inboundConsent === 'approval' && !isOnAllowlist) {
		const reqId = `conn-req-${++connectionRequestCounter}`;
		const request: PendingConnectionRequest = {
			id: reqId,
			fromSlug,
			toSlug,
			proposedBy: claims.sub,
			direction,
			flowKinds: body.flowKinds,
			permissions,
			strength,
			label: body.label,
			economicFlows: body.economicFlows,
			delegations: body.delegations,
			informationFlow: body.informationFlow,
			meta: body.meta,
			message: body.message,
			status: "pending",
			createdAt: Date.now(),
		};
		connectionRequests.set(reqId, request);

		return c.json({
			status: 'pending',
			requestId: reqId,
			message: 'Connection request created. Awaiting target space admin approval.',
		}, 202);
	}

	// If 'members' consent, check proposer is also member in target
	if (toPolicy.inboundConsent === 'members' && !isOnAllowlist) {
		const toMember = toData.members?.[claims.sub];
		const isToOwner = toData.meta.ownerDID === claims.sub;
		if (!isToOwner && !toMember) {
			return c.json({ error: "Must be a member of the target space for direct connection" }, 403);
		}
	}

	// Consent is 'open', 'members' (passed), or allowlisted — create immediately
	const connId = `conn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
	const now = Date.now();
	const conn: SpaceConnection = {
		id: connId,
		localSlug: fromSlug,
		remoteSlug: toSlug,
		remoteDID: toData.meta.ownerDID || undefined,
		direction,
		flowKinds: body.flowKinds,
		state: "active",
		economicFlows: body.economicFlows,
		delegations: body.delegations,
		informationFlow: body.informationFlow,
		permissions,
		strength,
		label: body.label,
		meta: body.meta,
		proposedBy: claims.sub,
		proposedAt: now,
		acceptedBy: claims.sub,
		acceptedAt: now,
	};

	// Bilateral write: store in BOTH spaces' docs
	addConnection(fromSlug, conn);
	addConnection(toSlug, {
		...conn,
		localSlug: toSlug,
		remoteSlug: fromSlug,
		remoteDID: fromData.meta.ownerDID || undefined,
		direction: invertDirection(direction),
	});

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

// ── Get a specific connection ──

spaces.get("/:slug/connections/:connId", async (c) => {
	const slug = c.req.param("slug");
	const connId = c.req.param("connId");
	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); }

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

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && !member) {
		return c.json({ error: "Must be a member to view connections" }, 403);
	}

	const conn = data.connections?.[connId];
	if (!conn) return c.json({ error: "Connection not found" }, 404);

	return c.json({ connection: conn });
});

// ── Update a connection (state, permissions, strength) ──

spaces.patch("/:slug/connections/:connId", async (c) => {
	const slug = c.req.param("slug");
	const connId = c.req.param("connId");
	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); }

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

	const conn = data.connections?.[connId];
	if (!conn) return c.json({ error: "Connection not found" }, 404);

	// Must be admin/moderator in this space
	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin' && member?.role !== 'moderator') {
		return c.json({ error: "Admin or moderator role required" }, 403);
	}

	// Revoked is terminal
	if (conn.state === 'revoked') {
		return c.json({ error: "Revoked connections cannot be modified — propose a new connection" }, 400);
	}

	const body = await c.req.json<{
		state?: ConnectionState;
		permissions?: Partial<ConnectionPermissions>;
		strength?: number;
		label?: string;
		stateReason?: string;
	}>();

	// Validate state transition: cannot go back from revoked (already checked), and can't set to 'proposed'
	if (body.state === 'proposed') {
		return c.json({ error: "Cannot set state back to 'proposed'" }, 400);
	}

	const updates: Partial<SpaceConnection> = {
		lastModifiedBy: claims.sub,
		lastModifiedAt: Date.now(),
	};
	if (body.state) updates.state = body.state;
	if (body.permissions) {
		updates.permissions = { ...conn.permissions, ...body.permissions };
	}
	if (body.strength !== undefined) updates.strength = Math.max(0, Math.min(1, body.strength));
	if (body.label !== undefined) updates.label = body.label;
	if (body.stateReason !== undefined) updates.stateReason = body.stateReason;

	// Update in this space's doc
	updateConnection(slug, connId, updates);

	// Bilateral update: also update remote space's copy
	const remoteSlug = conn.remoteSlug;
	await loadCommunity(remoteSlug);
	const remoteData = getDocumentData(remoteSlug);
	if (remoteData?.connections?.[connId]) {
		updateConnection(remoteSlug, connId, updates);
	}

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

// ── Revoke (delete) a connection — either side's admin can revoke ──

spaces.delete("/:slug/connections/:connId", async (c) => {
	const slug = c.req.param("slug");
	const connId = c.req.param("connId");
	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); }

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

	const conn = data.connections?.[connId];
	if (!conn) return c.json({ error: "Connection not found" }, 404);

	// Must be admin in THIS space (sovereignty guarantee: either side can revoke)
	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required to revoke connections" }, 403);
	}

	// Already revoked? No-op
	if (conn.state === 'revoked') {
		return c.json({ ok: true, message: "Connection already revoked" });
	}

	const revokeFields: Partial<SpaceConnection> = {
		state: 'revoked',
		lastModifiedBy: claims.sub,
		lastModifiedAt: Date.now(),
		stateReason: 'Revoked by admin',
	};

	// Bilateral revoke: update both docs to 'revoked' state
	updateConnection(slug, connId, revokeFields);

	const remoteSlug = conn.remoteSlug;
	await loadCommunity(remoteSlug);
	const remoteData = getDocumentData(remoteSlug);
	if (remoteData?.connections?.[connId]) {
		updateConnection(remoteSlug, connId, revokeFields);
	}

	return c.json({ ok: true, state: 'revoked' });
});

// ── List pending connection requests for a space (admin only) ──

spaces.get("/:slug/connection-requests", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required" }, 403);
	}

	// Find requests where this space is the TARGET (someone wants to connect to us)
	const requests = Array.from(connectionRequests.values())
		.filter(r => r.toSlug === slug);

	return c.json({ requests });
});

// ── Accept or reject a connection request ──

spaces.patch("/:slug/connection-requests/:reqId", async (c) => {
	const slug = c.req.param("slug");
	const reqId = c.req.param("reqId");
	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); }

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

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required" }, 403);
	}

	const request = connectionRequests.get(reqId);
	if (!request || request.toSlug !== slug) {
		return c.json({ error: "Connection request not found" }, 404);
	}
	if (request.status !== 'pending') {
		return c.json({ error: `Request already ${request.status}` }, 400);
	}

	const body = await c.req.json<{
		action: 'accept' | 'reject';
		modifiedPermissions?: ConnectionPermissions;
	}>();

	if (body.action === 'reject') {
		request.status = 'rejected';
		request.resolvedAt = Date.now();
		request.resolvedBy = claims.sub;
		return c.json({ ok: true, status: 'rejected' });
	}

	if (body.action === 'accept') {
		request.status = 'accepted';
		request.resolvedAt = Date.now();
		request.resolvedBy = claims.sub;

		const finalPerms = body.modifiedPermissions || request.permissions;
		const connId = `conn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
		const now = Date.now();

		// Load FROM space for DID
		await loadCommunity(request.fromSlug);
		const fromData = getDocumentData(request.fromSlug);

		const conn: SpaceConnection = {
			id: connId,
			localSlug: request.fromSlug,
			remoteSlug: request.toSlug,
			remoteDID: data.meta.ownerDID || undefined,
			direction: request.direction,
			flowKinds: request.flowKinds,
			state: "active",
			economicFlows: request.economicFlows,
			delegations: request.delegations,
			informationFlow: request.informationFlow,
			permissions: finalPerms,
			strength: request.strength,
			label: request.label,
			meta: request.meta,
			proposedBy: request.proposedBy,
			proposedAt: request.createdAt,
			acceptedBy: claims.sub,
			acceptedAt: now,
		};

		// Bilateral write: store in BOTH spaces' docs
		addConnection(request.fromSlug, conn);
		addConnection(request.toSlug, {
			...conn,
			localSlug: request.toSlug,
			remoteSlug: request.fromSlug,
			remoteDID: fromData?.meta.ownerDID || undefined,
			direction: invertDirection(request.direction),
		});

		return c.json({ ok: true, status: 'accepted', connection: conn });
	}

	return c.json({ error: "action must be 'accept' or 'reject'" }, 400);
});

// ── Derived membrane permeability data ──

spaces.get("/:slug/membrane", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && !member) {
		return c.json({ error: "Must be a member to view membrane data" }, 403);
	}

	const connections = Object.values(data.connections || {});
	const permeability = computeMembranePermeability(connections, slug);

	return c.json({ membrane: permeability });
});

// ══════════════════════════════════════════════════════════════════════════════
// ENCRYPTION API
// ══════════════════════════════════════════════════════════════════════════════

// ── Get encryption status ──

spaces.get("/:slug/encryption", async (c) => {
	const slug = c.req.param("slug");
	await loadCommunity(slug);
	const data = getDocumentData(slug);
	if (!data) return c.json({ error: "Space not found" }, 404);

	return c.json({
		encrypted: !!data.meta.encrypted,
		encryptionKeyId: data.meta.encryptionKeyId || null,
	});
});

// ── Toggle encryption (admin only) ──

spaces.patch("/:slug/encryption", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	// Must be admin or owner
	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== 'admin') {
		return c.json({ error: "Admin access required" }, 403);
	}

	const body = await c.req.json<{ encrypted: boolean; encryptionKeyId?: string }>();
	setEncryption(slug, body.encrypted, body.encryptionKeyId);

	// Switch SyncServer to relay mode for encrypted spaces (server can't read ciphertext)
	syncServer.setRelayOnly(slug, body.encrypted);

	return c.json({
		ok: true,
		encrypted: body.encrypted,
		message: body.encrypted
			? "Space encryption enabled. Documents synced in relay mode (server cannot read content)."
			: "Space encryption disabled. Documents synced in participant mode.",
	});
});

// ══════════════════════════════════════════════════════════════════════════════
// ACCESS REQUEST API (space membership request flow)
// ══════════════════════════════════════════════════════════════════════════════

// ── Create an access request for a space ──

spaces.post("/:slug/access-requests", async (c) => {
	const slug = c.req.param("slug");
	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); }

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

	// Check user is not already owner or member
	if (data.meta.ownerDID === claims.sub) {
		return c.json({ error: "You are the owner of this space" }, 400);
	}
	const memberEntry = data.members?.[claims.sub];
	if (memberEntry) {
		return c.json({ error: "You are already a member of this space" }, 400);
	}

	// Check for duplicate pending request
	const existing = Array.from(accessRequests.values()).find(
		(r) => r.spaceSlug === slug && r.requesterDID === claims.sub && r.status === "pending"
	);
	if (existing) {
		return c.json({ error: "You already have a pending access request", requestId: existing.id }, 409);
	}

	const body = await c.req.json<{ message?: string }>().catch(() => ({} as { message?: string }));
	const reqId = `access-req-${++accessRequestCounter}`;
	const request: AccessRequest = {
		id: reqId,
		spaceSlug: slug,
		requesterDID: claims.sub,
		requesterUsername: claims.username || claims.sub.slice(0, 16),
		message: body.message,
		status: "pending",
		createdAt: Date.now(),
	};
	accessRequests.set(reqId, request);

	// Notify space admins about the access request
	notifySpaceAdmins(slug, {
		category: 'space',
		eventType: 'access_request',
		title: `${request.requesterUsername} requested access to "${slug}"`,
		body: body.message || undefined,
		spaceSlug: slug,
		actorDid: claims.sub,
		actorUsername: request.requesterUsername,
		actionUrl: `/rspace`,
		metadata: { requestId: reqId },
	}).catch(() => {});

	return c.json({ id: reqId, status: "pending" }, 201);
});

// ── Approve or deny an access request ──

spaces.patch("/:slug/access-requests/:reqId", async (c) => {
	const slug = c.req.param("slug");
	const reqId = c.req.param("reqId");
	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); }

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

	// Must be owner or admin
	const member = data.members?.[claims.sub];
	const isOwner = data.meta.ownerDID === claims.sub;
	if (!isOwner && member?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	const request = accessRequests.get(reqId);
	if (!request || request.spaceSlug !== slug) {
		return c.json({ error: "Access request not found" }, 404);
	}
	if (request.status !== "pending") {
		return c.json({ error: `Request already ${request.status}` }, 400);
	}

	const body = await c.req.json<{
		action: "approve" | "deny";
		role?: "viewer" | "member";
	}>();

	if (body.action === "deny") {
		request.status = "denied";
		request.resolvedAt = Date.now();
		request.resolvedBy = claims.sub;

		notify({
			userDid: request.requesterDID,
			category: 'space',
			eventType: 'access_denied',
			title: `Your request to join "${slug}" was denied`,
			spaceSlug: slug,
			actorDid: claims.sub,
			actorUsername: claims.username,
		}).catch(() => {});

		return c.json({ ok: true, status: "denied" });
	}

	if (body.action === "approve") {
		request.status = "approved";
		request.resolvedAt = Date.now();
		request.resolvedBy = claims.sub;

		// Add requester as member
		setMember(slug, request.requesterDID, body.role || "viewer", request.requesterUsername);

		notify({
			userDid: request.requesterDID,
			category: 'space',
			eventType: 'access_approved',
			title: `You've been granted access to "${slug}"`,
			body: `You were added as ${body.role || "viewer"}.`,
			spaceSlug: slug,
			actorDid: claims.sub,
			actorUsername: claims.username,
			actionUrl: `/rspace`,
			metadata: { role: body.role || "viewer" },
		}).catch(() => {});

		// Sync space email alias with approved member
		fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ userDid: request.requesterDID, role: body.role || "viewer" }),
		}).catch(() => {});

		return c.json({ ok: true, status: "approved" });
	}

	return c.json({ error: "action must be 'approve' or 'deny'" }, 400);
});

// ══════════════════════════════════════════════════════════════════════════════
// COPY SHAPES API
// ══════════════════════════════════════════════════════════════════════════════

spaces.post("/:slug/copy-shapes", async (c) => {
	const targetSlug = c.req.param("slug");
	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); }

	// Verify target space exists and user has write access
	await loadCommunity(targetSlug);
	const data = getDocumentData(targetSlug);
	if (!data) return c.json({ error: "Space not found" }, 404);

	const isOwner = data.meta.ownerDID === claims.sub;
	const member = data.members?.[claims.sub];
	if (!isOwner && !member) {
		return c.json({ error: "You don't have access to this space" }, 403);
	}

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

	const now = Date.now();
	const rand4 = () => Math.random().toString(36).slice(2, 6);

	// Build old-ID → new-ID map
	const idMap = new Map<string, string>();
	for (let i = 0; i < body.shapes.length; i++) {
		const oldId = body.shapes[i].id as string;
		if (oldId) {
			idMap.set(oldId, `copy-${now}-${i}-${rand4()}`);
		}
	}

	// Compute bounding box of non-arrow shapes to center around (500, 300)
	let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
	for (const s of body.shapes) {
		if (s.type === "folk-arrow") continue;
		const sx = (s.x as number) ?? 0;
		const sy = (s.y as number) ?? 0;
		const sw = (s.width as number) ?? 0;
		const sh = (s.height as number) ?? 0;
		if (sx < minX) minX = sx;
		if (sy < minY) minY = sy;
		if (sx + sw > maxX) maxX = sx + sw;
		if (sy + sh > maxY) maxY = sy + sh;
	}
	const centerX = (minX + maxX) / 2;
	const centerY = (minY + maxY) / 2;
	const offsetX = isFinite(centerX) ? 500 - centerX : 0;
	const offsetY = isFinite(centerY) ? 300 - centerY : 0;

	// Remap shapes
	const remapped: Record<string, unknown>[] = [];
	for (const s of body.shapes) {
		const clone = { ...s };
		const oldId = clone.id as string;
		const newId = idMap.get(oldId) || `copy-${now}-${remapped.length}-${rand4()}`;
		clone.id = newId;

		// Offset position
		if (typeof clone.x === "number") clone.x = (clone.x as number) + offsetX;
		if (typeof clone.y === "number") clone.y = (clone.y as number) + offsetY;

		// Remap arrow endpoints (only if both are in the copied set)
		if (clone.type === "folk-arrow") {
			const srcMapped = idMap.get(clone.sourceId as string);
			const tgtMapped = idMap.get(clone.targetId as string);
			if (srcMapped && tgtMapped) {
				clone.sourceId = srcMapped;
				clone.targetId = tgtMapped;
			}
		}

		// Update folk-rapp spaceSlug to target
		if (clone.type === "folk-rapp") {
			clone.spaceSlug = targetSlug;
		}

		// Strip forgotten/deleted fields (clean copy)
		delete clone.forgotten;
		delete clone.forgottenBy;
		delete clone.deleted;

		remapped.push(clone);
	}

	addShapes(targetSlug, remapped);

	return c.json({ ok: true, count: remapped.length }, 201);
});

// ── Invite by email ──

let inviteTransport: Transporter | null = null;

{
	const host = process.env.SMTP_HOST || "mail.rmail.online";
	const isInternal = host.includes('mailcow') || host.includes('postfix');
	if (process.env.SMTP_PASS || isInternal) {
		inviteTransport = createTransport({
			host,
			port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587),
			secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
			...(isInternal ? {} : {
				auth: {
					user: process.env.SMTP_USER || "noreply@rmail.online",
					pass: process.env.SMTP_PASS!,
				},
			}),
			tls: { rejectUnauthorized: false },
		});
	}
}

// ── Enhanced invite by email (with token + role) ──

spaces.post("/:slug/invite", async (c) => {
	const { slug } = c.req.param();
	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); }

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

	const isOwner = data.meta.ownerDID === claims.sub;
	const callerMember = data.members?.[claims.sub];
	if (!isOwner && callerMember?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	const body = await c.req.json<{ email: string; role?: string }>();
	if (!body.email) return c.json({ error: "email is required" }, 400);

	const role = body.role || "member";
	const validRoles = ["viewer", "member", "moderator", "admin"];
	if (!validRoles.includes(role)) {
		return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400);
	}

	// Try identity invite (handles new-user registration + auto-space-join)
	try {
		const identityRes = await fetch(`${ENCRYPTID_URL}/api/invites/identity`, {
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				"Authorization": `Bearer ${token}`,
			},
			body: JSON.stringify({ email: body.email, spaceSlug: slug, spaceRole: role }),
		});

		if (identityRes.status === 201 || identityRes.ok) {
			// New user — EncryptID sent the /join email with space info
			return c.json({ ok: true, type: "identity-invite" });
		}

		if (identityRes.status === 409) {
			// Existing user — create invite (don't direct-add)
			const emailLookupRes = await fetch(
				`${ENCRYPTID_URL}/api/internal/user-by-email?email=${encodeURIComponent(body.email)}`,
			);
			if (!emailLookupRes.ok) {
				return c.json({ error: "User exists but could not be resolved" }, 500);
			}
			const existingUser = await emailLookupRes.json() as {
				id: string; did: string; username: string; displayName: string;
			};

			// Create space invite via EncryptID
			const inviteRes = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
				method: "POST",
				headers: {
					"Content-Type": "application/json",
					"Authorization": `Bearer ${token}`,
				},
				body: JSON.stringify({ email: body.email, role }),
			});
			if (!inviteRes.ok) {
				const invErr = await inviteRes.json().catch(() => ({})) as Record<string, unknown>;
				return c.json({ error: (invErr.error as string) || "Failed to create invite" }, 500);
			}
			const invite = await inviteRes.json() as { id: string; token: string; role: string };

			const acceptUrl = `https://${slug}.rspace.online?inviteToken=${invite.token}`;
			const inviterName = claims.username || "an admin";

			// In-app notification with accept action
			notify({
				userDid: existingUser.did,
				category: "space",
				eventType: "space_invite",
				title: `${inviterName} invited you to "${slug}"`,
				body: `You've been invited to join as ${role}. Accept to get access.`,
				spaceSlug: slug,
				actorDid: claims.sub,
				actorUsername: claims.username,
				actionUrl: acceptUrl,
				metadata: { inviteToken: invite.token, role },
			}).catch(() => {});

			// Send invite email from {slug}-agent@rspace.online
			if (inviteTransport) {
				try {
					const agentAddr = `${slug}-agent@rspace.online`;
					await inviteTransport.sendMail({
						from: `${slug} <${agentAddr}>`,
						replyTo: agentAddr,
						envelope: { from: process.env.SMTP_USER || "noreply@rmail.online", to: body.email },
						to: body.email,
						subject: `${inviterName} invited you to "${slug}" on rSpace`,
						html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
  <h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You're invited to ${slug}</h2>
  <p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> invited you to join the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
  <p style="color:#475569;line-height:1.6;">Accept the invitation to get access to all the collaborative tools — notes, maps, voting, calendar, and more.</p>
  <p style="text-align:center;margin:2rem 0;">
    <a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Accept Invitation</a>
  </p>
  <p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. — rSpace</p>
</div>`,
					});
				} catch (emailErr: any) {
					console.error("Invite email notification failed:", emailErr.message);
				}
			}

			return c.json({ ok: true, type: "invite", inviteToken: invite.token });
		}

		// Other error from identity invite
		const errBody = await identityRes.json().catch(() => ({})) as Record<string, unknown>;
		console.error("Identity invite failed:", identityRes.status, errBody);
		return c.json({ error: (errBody.error as string) || "Failed to send invite" }, 500);
	} catch (err: any) {
		console.error("Invite flow error:", err.message);
		return c.json({ error: "Failed to process invite" }, 500);
	}
});

// ── Add member by username (sends invite, requires acceptance) ──

spaces.post("/:slug/members/add", async (c) => {
	const { slug } = c.req.param();
	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); }

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

	const isOwner = data.meta.ownerDID === claims.sub;
	const callerMember = data.members?.[claims.sub];
	if (!isOwner && callerMember?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	const body = await c.req.json<{ username: string; role?: string }>();
	if (!body.username) return c.json({ error: "username is required" }, 400);

	const role = body.role || "member";
	const validRoles = ["viewer", "member", "moderator", "admin"];
	if (!validRoles.includes(role)) {
		return c.json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` }, 400);
	}

	// Look up user via EncryptID

	const lookupRes = await fetch(`${ENCRYPTID_URL}/api/users/lookup?username=${encodeURIComponent(body.username)}`, {
		headers: { "Authorization": `Bearer ${token}` },
	});
	if (!lookupRes.ok) {
		const errBody = await lookupRes.json().catch(() => ({})) as Record<string, unknown>;
		if (lookupRes.status === 404) {
			return c.json({ error: `User "${body.username}" not found` }, 404);
		}
		return c.json({ error: (errBody.error as string) || "User lookup failed" }, lookupRes.status as any);
	}
	const user = await lookupRes.json() as { id: string; did: string; username: string; displayName: string };

	if (!user.did) return c.json({ error: "User has no DID" }, 400);

	// Look up user's email for the invite
	let targetEmail: string | null = null;
	if (user.id) {
		try {
			const emailRes = await fetch(`${ENCRYPTID_URL}/api/internal/user-email/${user.id}`);
			if (emailRes.ok) {
				const emailData = await emailRes.json() as {
					recoveryEmail: string | null; profileEmail: string | null;
				};
				targetEmail = emailData.recoveryEmail || emailData.profileEmail;
			}
		} catch {}
	}

	// Create space invite via EncryptID
	const inviteRes = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
		method: "POST",
		headers: {
			"Content-Type": "application/json",
			"Authorization": `Bearer ${token}`,
		},
		body: JSON.stringify({ email: targetEmail || undefined, role }),
	});
	if (!inviteRes.ok) {
		const invErr = await inviteRes.json().catch(() => ({})) as Record<string, unknown>;
		return c.json({ error: (invErr.error as string) || "Failed to create invite" }, 500);
	}
	const invite = await inviteRes.json() as { id: string; token: string; role: string };

	const acceptUrl = `https://${slug}.rspace.online?inviteToken=${invite.token}`;
	const inviterName = claims.username || "an admin";

	// In-app notification with accept action
	notify({
		userDid: user.did,
		category: 'space',
		eventType: 'space_invite',
		title: `${inviterName} invited you to "${slug}"`,
		body: `You've been invited to join as ${role}. Accept to get access.`,
		spaceSlug: slug,
		actorDid: claims.sub,
		actorUsername: claims.username,
		actionUrl: acceptUrl,
		metadata: { inviteToken: invite.token, role },
	}).catch(() => {});

	// Send invite email from {slug}-agent@rspace.online (non-fatal)
	if (inviteTransport && targetEmail) {
		try {
			const agentAddr = `${slug}-agent@rspace.online`;
			await inviteTransport.sendMail({
				from: `${slug} <${agentAddr}>`,
				replyTo: agentAddr,
				envelope: { from: process.env.SMTP_USER || "noreply@rmail.online", to: targetEmail },
				to: targetEmail,
				subject: `${inviterName} invited you to "${slug}" on rSpace`,
				html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
  <h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You're invited to ${slug}</h2>
  <p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> invited you to join the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
  <p style="color:#475569;line-height:1.6;">Accept the invitation to get access to all the collaborative tools — notes, maps, voting, calendar, and more.</p>
  <p style="text-align:center;margin:2rem 0;">
    <a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Accept Invitation</a>
  </p>
  <p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. — rSpace</p>
</div>`,
			});
		} catch (emailErr: any) {
			console.error("Invite email notification failed:", emailErr.message);
		}
	}

	return c.json({ ok: true, type: "invite", inviteToken: invite.token, username: user.username });
});

// ── Accept invite via token ──

spaces.post("/:slug/invite/accept", async (c) => {
	const { slug } = c.req.param();
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required — sign in first" }, 401);

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

	const body = await c.req.json<{ inviteToken: string }>();
	if (!body.inviteToken) return c.json({ error: "inviteToken is required" }, 400);

	// Accept via EncryptID API

	const acceptRes = await fetch(`${ENCRYPTID_URL}/api/invites/${body.inviteToken}/accept`, {
		method: "POST",
		headers: { "Authorization": `Bearer ${token}` },
	});

	if (!acceptRes.ok) {
		const err = await acceptRes.json().catch(() => ({ error: "Failed to accept invite" }));
		return c.json(err as any, acceptRes.status as any);
	}

	const result = await acceptRes.json() as { ok: boolean; spaceSlug: string; role: string };

	// Also add to Automerge doc
	await loadCommunity(slug);
	setMember(slug, claims.sub, result.role as any, (claims as any).username);

	// Sync space email alias with accepted member
	fetch(`${ENCRYPTID_URL}/api/internal/spaces/${slug}/alias/sync`, {
		method: "POST",
		headers: { "Content-Type": "application/json" },
		body: JSON.stringify({ userDid: claims.sub, role: result.role }),
	}).catch(() => {});

	return c.json({ ok: true, spaceSlug: result.spaceSlug, role: result.role });
});

// ── Create generic invite link (no email required) ──

spaces.post("/:slug/invites", async (c) => {
	const { slug } = c.req.param();
	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); }

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

	const isOwner = data.meta.ownerDID === claims.sub;
	const callerMember = data.members?.[claims.sub];
	if (!isOwner && callerMember?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	const body = await c.req.json<{ role?: string; maxUses?: number; expiresInDays?: number }>().catch(() => ({}));
	const role = (body as any).role || "member";
	const maxUses = (body as any).maxUses || 0; // 0 = unlimited
	const expiresInDays = (body as any).expiresInDays || 7;

	try {
		const res = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				"Authorization": `Bearer ${token}`,
			},
			body: JSON.stringify({ role, maxUses, expiresInDays }),
		});
		const result = await res.json();
		if (!res.ok) {
			return c.json(result as any, res.status as any);
		}
		return c.json(result as any, 201);
	} catch {
		return c.json({ error: "Failed to create invite" }, 500);
	}
});

// ── List invites for settings panel ──

spaces.get("/:slug/invites", async (c) => {
	const { slug } = c.req.param();
	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); }

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

	const isOwner = data.meta.ownerDID === claims.sub;
	const callerMember = data.members?.[claims.sub];
	if (!isOwner && callerMember?.role !== "admin") {
		return c.json({ error: "Admin access required" }, 403);
	}

	// Fetch from EncryptID

	try {
		const res = await fetch(`${ENCRYPTID_URL}/api/spaces/${slug}/invites`, {
			headers: { "Authorization": `Bearer ${token}` },
		});
		const data = await res.json();
		return c.json(data as any);
	} catch {
		return c.json({ invites: [], total: 0 });
	}
});

export { spaces };
