import * as Automerge from "@automerge/automerge";
import type { FolkShape } from "./folk-shape";
import type { OfflineStore } from "./offline-store";
import type { Layer, LayerFlow, FlowKind } from "./layer-types";
import type {
	SpaceConnection,
	ConnectionState,
	MembranePermeability,
} from "./connection-types";
import { computeMembranePermeability } from "./connection-types";
import { makeChangeMessage, parseChangeMessage } from "../shared/local-first/change-message";
import type { HistoryEntry } from "../shared/components/rstack-history-panel";
import type { EventEntry } from "./event-bus";
import type { CommentPinData } from "../shared/comment-pin-types";

// Shape data stored in Automerge document
export interface ShapeData {
	type: string;
	id: string;
	x: number;
	y: number;
	width: number;
	height: number;
	rotation: number;
	content?: string;
	// Arrow-specific
	sourceId?: string;
	targetId?: string;
	color?: string;
	strokeWidth?: number;
	// Wrapper-specific
	title?: string;
	icon?: string;
	primaryColor?: string;
	isMinimized?: boolean;
	isPinned?: boolean;
	tags?: string[];
	// Whiteboard SVG drawing
	svgMarkup?: string;
	// Data pipe fields (arrow port connections)
	sourcePort?: string;
	targetPort?: string;
	// Shape port values (for data piping)
	ports?: Record<string, unknown>;
	// Event bus subscriptions (channel names this shape listens to)
	subscriptions?: string[];
	// Group membership
	groupId?: string;
	// Allow arbitrary shape-specific properties from toJSON()
	[key: string]: unknown;
}

// ── Undo/Redo entry ──

export interface UndoEntry {
	shapeId: string;
	before: ShapeData | null;  // null = shape didn't exist (creation)
	after: ShapeData | null;   // null = shape hard-deleted
	ts: number;
}

// ── Nested space types (client-side) ──

export interface NestPermissions {
	read: boolean;
	write: boolean;
	addShapes: boolean;
	deleteShapes: boolean;
	reshare: boolean;
	expiry?: number;
}

export interface SpaceRefFilter {
	shapeTypes?: string[];
	shapeIds?: string[];
	tags?: string[];
	moduleIds?: string[];
}

export interface SpaceRef {
	id: string;
	sourceSlug: string;
	sourceDID?: string;
	filter?: SpaceRefFilter;
	x: number;
	y: number;
	width: number;
	height: number;
	rotation: number;
	permissions: NestPermissions;
	collapsed?: boolean;
	label?: string;
	createdAt: number;
	createdBy: string;
}

// Automerge document structure
export interface CommunityDoc {
	meta: {
		name: string;
		slug: string;
		createdAt: string;
		enabledModules?: string[];
		description?: string;
		avatar?: string;
	};
	shapes: {
		[id: string]: ShapeData;
	};
	nestedSpaces?: {
		[refId: string]: SpaceRef;
	};
	/** Tab/layer system — each layer is an rApp page in this space */
	layers?: {
		[id: string]: Layer;
	};
	/** Inter-layer flows (economic, trust, data, etc.) */
	flows?: {
		[id: string]: LayerFlow;
	};
	/** Bilateral typed connections to other spaces */
	connections?: {
		[connId: string]: SpaceConnection;
	};
	/** Named shape groups (semantic clusters) */
	groups?: {
		[groupId: string]: {
			id: string; name: string; color: string; icon: string;
			memberIds: string[]; collapsed: boolean;
			isTemplate: boolean; templateName?: string;
			createdAt: number; updatedAt: number;
		};
	};
	/** Currently active layer ID */
	activeLayerId?: string;
	/** Layer view mode: flat (tabs) or stack (side view) */
	layerViewMode?: "flat" | "stack";
	/** Pub/sub event log — bounded ring buffer (last 100 entries) */
	eventLog?: EventEntry[];
	/** Comment pins — Figma-style overlay markers */
	commentPins?: { [pinId: string]: CommentPinData };
	/** Saved applet templates (reusable wired shape groups) */
	templates?: {
		[templateId: string]: import("../shared/applet-types").AppletTemplateRecord;
	};
}

type SyncState = Automerge.SyncState;

/**
 * CommunitySync - Bridges FolkJS shapes with Automerge CRDT sync
 *
 * Handles:
 * - Local shape changes → Automerge document → WebSocket broadcast
 * - Remote Automerge sync messages → Local document → DOM updates
 */
export class CommunitySync extends EventTarget {
	#doc: Automerge.Doc<CommunityDoc>;
	#syncState: SyncState;
	#ws: WebSocket | null = null;
	#disconnectedIntentionally = false;
	#communitySlug: string;
	#shapes: Map<string, FolkShape> = new Map();
	#shapeListeners: Map<string, { transform: EventListener; content: EventListener }> = new Map();
	#changeCount = 0;
	#pendingChanges: boolean = false;
	#reconnectAttempts = 0;
	#maxReconnectAttempts = 5;
	#reconnectDelay = 1000;
	#offlineStore: OfflineStore | null = null;
	#saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
	#syncedDebounceTimer: ReturnType<typeof setTimeout> | null = null;
	#initialSyncFired = false;
	#wsUrl: string | null = null;
	#localDID: string = '';

	// ── Undo/Redo state ──
	#undoStack: UndoEntry[] = [];
	#redoStack: UndoEntry[] = [];
	#maxUndoDepth = 50;
	#isUndoRedoing = false;

	constructor(communitySlug: string, offlineStore?: OfflineStore) {
		super();
		this.#communitySlug = communitySlug;

		// Initialize empty Automerge document
		this.#doc = Automerge.init<CommunityDoc>();
		this.#doc = Automerge.change(this.#doc, "Initialize community", (doc) => {
			doc.meta = {
				name: communitySlug,
				slug: communitySlug,
				createdAt: new Date().toISOString(),
			};
			doc.shapes = {};
		});

		this.#syncState = Automerge.initSyncState();

		if (offlineStore) {
			this.#offlineStore = offlineStore;
		}
	}

	/** Set the local user's DID so forgotten-shape filtering is per-user. */
	setLocalDID(did: string): void {
		this.#localDID = did;
	}

	/**
	 * Load document and sync state from offline cache.
	 * Call BEFORE connect() to show cached content immediately.
	 * @returns true if cached data was loaded.
	 */
	async initFromCache(): Promise<boolean> {
		if (!this.#offlineStore) return false;

		try {
			const docBinary = await this.#offlineStore.loadDoc(this.#communitySlug);
			if (!docBinary) return false;

			this.#doc = Automerge.load<CommunityDoc>(docBinary);

			// Try to restore sync state for incremental reconnection
			const syncStateBinary = await this.#offlineStore.loadSyncState(this.#communitySlug);
			if (syncStateBinary) {
				this.#syncState = Automerge.decodeSyncState(syncStateBinary);
			}

			// Apply cached doc to DOM
			this.#applyDocToDOM();

			this.dispatchEvent(new CustomEvent("offline-loaded", {
				detail: { slug: this.#communitySlug }
			}));

			return true;
		} catch (e) {
			console.error("[CommunitySync] Failed to load from cache:", e);
			return false;
		}
	}

	get doc(): Automerge.Doc<CommunityDoc> {
		return this.#doc;
	}

	get shapes(): Map<string, FolkShape> {
		return this.#shapes;
	}

	/**
	 * Apply a pre-built Automerge doc change (used by GroupManager and other
	 * subsystems that need batch mutations beyond the shape-level API).
	 */
	_applyDocChange(newDoc: Automerge.Doc<CommunityDoc>): void {
		this.#doc = newDoc;
		this.#scheduleSave();
		this.#syncToServer();
		this.#applyDocToDOM();
	}

	/**
	 * Connect to WebSocket server for real-time sync
	 */
	connect(wsUrl: string): void {
		this.#wsUrl = wsUrl;

		if (this.#ws?.readyState === WebSocket.OPEN) {
			return;
		}

		// Attach auth token for private/authenticated spaces
		let authUrl = wsUrl;
		try {
			const sess = JSON.parse(localStorage.getItem('encryptid_session') || '');
			if (sess?.accessToken) {
				const sep = wsUrl.includes('?') ? '&' : '?';
				authUrl = `${wsUrl}${sep}token=${encodeURIComponent(sess.accessToken)}`;
			}
		} catch {}

		this.#ws = new WebSocket(authUrl);
		this.#ws.binaryType = "arraybuffer";

		this.#ws.onopen = () => {
			console.log(`[CommunitySync] Connected to ${this.#communitySlug}`);
			this.#reconnectAttempts = 0;

			// Request initial sync
			this.#requestSync();

			this.dispatchEvent(new CustomEvent("connected"));

			// Re-announce presence on every (re)connect
			if (this.#announceData) {
				this.#send({ type: "announce", ...this.#announceData });
			}

			if (this.#offlineStore) {
				this.#offlineStore.markSynced(this.#communitySlug);
			}
		};

		this.#ws.onmessage = (event) => {
			this.#handleMessage(event.data);
		};

		this.#ws.onclose = () => {
			console.log(`[CommunitySync] Disconnected from ${this.#communitySlug}`);
			this.#initialSyncFired = false;
			this.dispatchEvent(new CustomEvent("disconnected"));

			if (!this.#disconnectedIntentionally) {
				this.#attemptReconnect(wsUrl);
			}
		};

		this.#ws.onerror = (error) => {
			console.warn("[CommunitySync] WebSocket error (will reconnect):", error);
			this.dispatchEvent(new CustomEvent("error", { detail: error }));
		};
	}

	#attemptReconnect(wsUrl: string): void {
		// When offline store is available, keep retrying forever (user has local persistence)
		// Without offline store, give up after maxReconnectAttempts
		if (!this.#offlineStore && this.#reconnectAttempts >= this.#maxReconnectAttempts) {
			console.error("[CommunitySync] Max reconnect attempts reached");
			return;
		}

		this.#reconnectAttempts++;
		const maxDelay = this.#offlineStore ? 30000 : 16000;
		const delay = Math.min(
			this.#reconnectDelay * Math.pow(2, this.#reconnectAttempts - 1),
			maxDelay
		);

		console.log(`[CommunitySync] Reconnecting in ${delay}ms (attempt ${this.#reconnectAttempts})`);

		setTimeout(() => {
			this.connect(wsUrl);
		}, delay);
	}

	/**
	 * Request sync from server (sends our sync state)
	 */
	#requestSync(): void {
		const [nextSyncState, syncMessage] = Automerge.generateSyncMessage(
			this.#doc,
			this.#syncState
		);

		this.#syncState = nextSyncState;
		this.#persistSyncState();

		if (syncMessage) {
			this.#send({
				type: "sync",
				data: Array.from(syncMessage),
			});
		}
	}

	/**
	 * Handle incoming WebSocket messages
	 */
	#handleMessage(data: ArrayBuffer | string): void {
		try {
			// Handle binary Automerge sync messages
			if (data instanceof ArrayBuffer) {
				const message = new Uint8Array(data);
				this.#applySyncMessage(message);
				return;
			}

			// Handle JSON messages
			const msg = JSON.parse(data as string);

			switch (msg.type) {
				case "sync":
					// Server sending sync message as JSON array
					if (Array.isArray(msg.data)) {
						const syncMessage = new Uint8Array(msg.data);
						this.#applySyncMessage(syncMessage);
					}
					break;

				case "full-sync":
					// Server sending full document — merge to preserve local changes
					if (msg.doc) {
						const binary = new Uint8Array(msg.doc);
						const serverDoc = Automerge.load<CommunityDoc>(binary);
						this.#doc = Automerge.merge(this.#doc, serverDoc);
						this.#syncState = Automerge.initSyncState();
						this.#applyDocToDOM();
						this.#scheduleSave();
					}
					break;

				case "presence":
					// Handle presence updates (cursors, selections)
					this.dispatchEvent(new CustomEvent("presence", { detail: msg }));
					break;

				case "peer-list":
					this.dispatchEvent(new CustomEvent("peer-list", { detail: msg }));
					break;

				case "peer-joined":
					this.dispatchEvent(new CustomEvent("peer-joined", { detail: msg }));
					break;

				case "peer-left":
					this.dispatchEvent(new CustomEvent("peer-left", { detail: msg }));
					break;

				case "ping-user":
					this.dispatchEvent(new CustomEvent("ping-user", { detail: msg }));
					break;

				case "clock":
					// Ephemeral clock event from server — dispatch for event bus delivery
					this.dispatchEvent(new CustomEvent("clock-event", {
						detail: { channel: msg.channel, payload: msg.payload }
					}));
					break;

				case "notification":
					// Dispatch to window so the notification bell component picks it up
					window.dispatchEvent(new CustomEvent("rspace-notification", { detail: msg }));
					break;
			}
		} catch (e) {
			console.error("[CommunitySync] Failed to handle message:", e);
		}
	}

	/**
	 * Apply incoming Automerge sync message
	 */
	#applySyncMessage(message: Uint8Array): void {
		const oldDoc = this.#doc;
		const result = Automerge.receiveSyncMessage(
			this.#doc,
			this.#syncState,
			message
		);

		this.#doc = result[0];
		this.#syncState = result[1];

		// Persist after receiving remote changes
		this.#scheduleSave();
		this.#persistSyncState();

		// Apply changes to DOM if the document changed
		if (this.#doc !== oldDoc) {
			const patch = result[2] as { patches: Automerge.Patch[] } | null;
			if (patch && patch.patches && patch.patches.length > 0) {
				this.#applyPatchesToDOM(patch.patches);
			} else {
				// Automerge 2.x receiveSyncMessage may not return patches;
				// fall back to full document-to-DOM reconciliation
				this.#applyDocToDOM();
			}
		}

		// Generate response if needed
		const [nextSyncState, responseMessage] = Automerge.generateSyncMessage(
			this.#doc,
			this.#syncState
		);

		this.#syncState = nextSyncState;
		this.#persistSyncState();

		if (responseMessage) {
			this.#send({
				type: "sync",
				data: Array.from(responseMessage),
			});
		}
	}

	/**
	 * Send message over WebSocket
	 */
	#send(message: object): void {
		if (this.#ws?.readyState === WebSocket.OPEN) {
			this.#ws.send(JSON.stringify(message));
		}
	}

	/**
	 * Send presence update (cursor position, selection)
	 */
	sendPresence(data: { cursor?: { x: number; y: number }; selection?: string; username?: string; color?: string }): void {
		this.#send({
			type: "presence",
			...data,
		});
	}

	// ── People Online (announce / ping) ──

	#announceData: { peerId: string; username: string; color: string } | null = null;

	/**
	 * Store announce payload and send immediately if already connected.
	 */
	setAnnounceData(data: { peerId: string; username: string; color: string }): void {
		this.#announceData = data;
		if (this.#ws?.readyState === WebSocket.OPEN) {
			this.#send({ type: "announce", ...data });
		}
	}

	/**
	 * Ask the server to relay a "come here" ping to a specific peer.
	 */
	sendPingUser(targetPeerId: string, viewport: { x: number; y: number }): void {
		this.#send({ type: "ping-user", targetPeerId, viewport });
	}

	/**
	 * Send a keep-alive ping to prevent WebSocket idle timeout
	 */
	ping(): void {
		this.#send({ type: "ping" });
	}

	/**
	 * Register a shape element for syncing
	 */
	registerShape(shape: FolkShape): void {
		this.#shapes.set(shape.id, shape);

		// Remove stale listeners if shape is re-registered
		const old = this.#shapeListeners.get(shape.id);
		if (old) {
			shape.removeEventListener("folk-transform", old.transform);
			shape.removeEventListener("content-change", old.content);
		}

		// Create named listener refs so they can be removed later
		const transformListener = (() => {
			this.#handleShapeChange(shape);
		}) as EventListener;
		const contentListener = (() => {
			this.#handleShapeChange(shape);
		}) as EventListener;

		this.#shapeListeners.set(shape.id, { transform: transformListener, content: contentListener });

		shape.addEventListener("folk-transform", transformListener);
		shape.addEventListener("content-change", contentListener);

		// Add to document if not exists
		if (!this.#doc.shapes[shape.id]) {
			this.#updateShapeInDoc(shape);
			// Record creation for undo (before=null means shape was new)
			const afterData = this.#cloneShapeData(shape.id);
			this.#pushUndo(shape.id, null, afterData);
		}
	}

	/**
	 * Unregister a shape
	 */
	unregisterShape(shapeId: string): void {
		const shape = this.#shapes.get(shapeId);
		const listeners = this.#shapeListeners.get(shapeId);
		if (shape && listeners) {
			shape.removeEventListener("folk-transform", listeners.transform);
			shape.removeEventListener("content-change", listeners.content);
		}
		this.#shapeListeners.delete(shapeId);
		this.#shapes.delete(shapeId);
	}

	/**
	 * Handle local shape change - update Automerge doc and sync
	 */
	#handleShapeChange(shape: FolkShape): void {
		this.#updateShapeInDoc(shape);
		this.#syncToServer();

		// Broadcast to parent frame (for rtrips.online integration)
		const shapeData = this.#shapeToData(shape);
		this.#postMessageToParent("shape-updated", shapeData);
	}

	/**
	 * Update shape data in Automerge document
	 */
	#updateShapeInDoc(shape: FolkShape): void {
		const beforeData = this.#cloneShapeData(shape.id);
		const shapeData = this.#shapeToData(shape);

		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shape.id}`), (doc) => {
			if (!doc.shapes) doc.shapes = {};
			doc.shapes[shape.id] = JSON.parse(JSON.stringify(shapeData));
		});

		// Compact Automerge history periodically to prevent unbounded WASM heap growth
		this.#changeCount++;
		if (this.#changeCount % 500 === 0) {
			this.#doc = Automerge.clone(this.#doc);
		}

		// Record for undo (skip if this is a brand-new shape — registerShape handles that)
		if (beforeData) {
			this.#pushUndo(shape.id, beforeData, this.#cloneShapeData(shape.id));
		}

		this.#scheduleSave();
	}

	/**
	 * Convert FolkShape to serializable data using the shape's own toJSON() method.
	 * This ensures all shape-specific properties are captured for every shape type.
	 */
	#shapeToData(shape: FolkShape): ShapeData {
		const json = (shape as any).toJSON?.() ?? {};

		const data: ShapeData = {
			type: (json.type as string) || shape.tagName.toLowerCase(),
			id: (json.id as string) || shape.id,
			x: (json.x as number) ?? shape.x,
			y: (json.y as number) ?? shape.y,
			width: (json.width as number) ?? shape.width,
			height: (json.height as number) ?? shape.height,
			rotation: (json.rotation as number) ?? shape.rotation,
		};

		// Merge all extra properties from toJSON
		for (const [key, value] of Object.entries(json)) {
			if (!(key in data)) {
				data[key] = typeof value === 'object' && value !== null
					? JSON.parse(JSON.stringify(value))
					: value;
			}
		}

		return data;
	}

	/**
	 * Sync local changes to server
	 */
	#syncToServer(): void {
		const [nextSyncState, syncMessage] = Automerge.generateSyncMessage(
			this.#doc,
			this.#syncState
		);

		this.#syncState = nextSyncState;
		this.#persistSyncState();

		if (syncMessage) {
			this.#send({
				type: "sync",
				data: Array.from(syncMessage),
			});
		}
	}

	/**
	 * Delete a shape — now aliases to forgetShape for backward compat.
	 * For true hard-delete, use hardDeleteShape().
	 */
	deleteShape(shapeId: string, did?: string): void {
		this.forgetShape(shapeId, did || 'unknown');
	}

	/**
	 * Add raw shape data directly (for shapes without DOM elements, like wb-svg drawings).
	 */
	addShapeData(shapeData: ShapeData): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add shape ${shapeData.id}`), (doc) => {
			if (!doc.shapes) doc.shapes = {};
			doc.shapes[shapeData.id] = shapeData;
		});
		this.#scheduleSave();
		this.#syncToServer();
	}

	/**
	 * FUN: Update — explicitly update specific fields of a shape.
	 * Use this for programmatic updates (API calls, module callbacks).
	 * Shape transform/content changes are auto-captured via registerShape().
	 */
	updateShape(shapeId: string, fields: Record<string, unknown>): void {
		const existing = this.#doc.shapes?.[shapeId];
		if (!existing) return;

		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shapeId}`), (doc) => {
			if (doc.shapes && doc.shapes[shapeId]) {
				for (const [key, value] of Object.entries(fields)) {
					(doc.shapes[shapeId] as Record<string, unknown>)[key] = value;
				}
			}
		});

		// Sync the updated shape to DOM and server
		const shape = this.#shapes.get(shapeId);
		if (shape) {
			this.#updateShapeElement(shape, this.#doc.shapes[shapeId]);
		}
		this.#scheduleSave();
		this.#syncToServer();
	}

	/**
	 * Forget a shape — add DID to forgottenBy map. Shape fades but stays in DOM.
	 * Three-state: present → forgotten (faded) → deleted
	 */
	forgetShape(shapeId: string, did: string): void {
		const beforeData = this.#cloneShapeData(shapeId);
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Forget shape ${shapeId}`), (doc) => {
			if (doc.shapes && doc.shapes[shapeId]) {
				const shape = doc.shapes[shapeId] as Record<string, unknown>;
				if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') {
					shape.forgottenBy = {};
				}
				(shape.forgottenBy as Record<string, number>)[did] = Date.now();
				// Legacy compat
				shape.forgotten = true;
				shape.forgottenAt = Date.now();
			}
		});

		this.#pushUndo(shapeId, beforeData, this.#cloneShapeData(shapeId));

		// Don't remove from DOM — just update visual state
		this.dispatchEvent(new CustomEvent("shape-state-changed", {
			detail: { shapeId, state: 'forgotten', data: this.#doc.shapes?.[shapeId] }
		}));
		this.#saveImmediate();
		this.#syncToServer();
	}

	/**
	 * Remember a forgotten shape — clear forgottenBy + deleted, restore to present.
	 */
	rememberShape(shapeId: string): void {
		const shapeData = this.#doc.shapes?.[shapeId];
		if (!shapeData) return;

		const beforeData = this.#cloneShapeData(shapeId);
		const wasDeleted = !!(shapeData as Record<string, unknown>).deleted;

		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remember shape ${shapeId}`), (doc) => {
			if (doc.shapes && doc.shapes[shapeId]) {
				const shape = doc.shapes[shapeId] as Record<string, unknown>;
				shape.forgottenBy = {};
				shape.deleted = false;
				// Legacy compat
				shape.forgotten = false;
				shape.forgottenAt = 0;
			}
		});

		this.#pushUndo(shapeId, beforeData, this.#cloneShapeData(shapeId));

		if (wasDeleted) {
			// Re-add to DOM if was hard-deleted
			this.#applyShapeToDOM(this.#doc.shapes[shapeId]);
		}

		this.dispatchEvent(new CustomEvent("shape-state-changed", {
			detail: { shapeId, state: 'present', data: this.#doc.shapes?.[shapeId] }
		}));
		this.#saveImmediate();
		this.#syncToServer();
	}

	/**
	 * Hard-delete a shape — set deleted: true, remove from DOM.
	 * Shape stays in Automerge doc for restore from memory panel.
	 */
	hardDeleteShape(shapeId: string): void {
		const beforeData = this.#cloneShapeData(shapeId);
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Delete shape ${shapeId}`), (doc) => {
			if (doc.shapes && doc.shapes[shapeId]) {
				(doc.shapes[shapeId] as Record<string, unknown>).deleted = true;
			}
		});

		this.#pushUndo(shapeId, beforeData, null);

		this.#removeShapeFromDOM(shapeId);
		this.dispatchEvent(new CustomEvent("shape-state-changed", {
			detail: { shapeId, state: 'deleted', data: this.#doc.shapes?.[shapeId] }
		}));
		this.#saveImmediate();
		this.#syncToServer();
	}

	/**
	 * Bulk forget/delete shapes in a single Automerge transaction.
	 * Shapes already forgotten get hard-deleted; others get soft-forgotten.
	 */
	bulkForget(shapeIds: string[], did: string): void {
		const changes: Array<{ id: string; before: ShapeData | null; action: 'forget' | 'delete' }> = [];
		for (const id of shapeIds) {
			const state = this.getShapeVisualState(id);
			changes.push({ id, before: this.#cloneShapeData(id), action: state === 'forgotten' ? 'delete' : 'forget' });
		}

		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Bulk forget ${shapeIds.length} shapes`), (doc) => {
			if (!doc.shapes) return;
			for (const c of changes) {
				const shape = doc.shapes[c.id] as Record<string, unknown> | undefined;
				if (!shape) continue;
				if (c.action === 'delete') {
					shape.deleted = true;
				} else {
					if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') {
						shape.forgottenBy = {};
					}
					(shape.forgottenBy as Record<string, number>)[did] = Date.now();
					shape.forgotten = true;
					shape.forgottenAt = Date.now();
				}
			}
		});

		// Post-transaction: undo stack, DOM updates, events
		for (const c of changes) {
			if (c.action === 'delete') {
				this.#pushUndo(c.id, c.before, null);
				this.#removeShapeFromDOM(c.id);
			} else {
				this.#pushUndo(c.id, c.before, this.#cloneShapeData(c.id));
			}
			this.dispatchEvent(new CustomEvent("shape-state-changed", {
				detail: { shapeId: c.id, state: c.action === 'delete' ? 'deleted' : 'forgotten', data: this.#doc.shapes?.[c.id] }
			}));
		}

		this.#saveImmediate();
		this.#syncToServer();
	}

	/**
	 * Get the visual state of a shape: 'present' | 'forgotten' | 'deleted'
	 */
	getShapeVisualState(shapeId: string): 'present' | 'forgotten' | 'deleted' {
		const data = this.#doc.shapes?.[shapeId] as Record<string, unknown> | undefined;
		if (!data) return 'deleted';
		if (data.deleted === true) return 'deleted';
		const fb = data.forgottenBy;
		if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return 'forgotten';
		return 'present';
	}

	/**
	 * Check if a specific user has forgotten a shape
	 */
	hasUserForgotten(shapeId: string, did: string): boolean {
		const data = this.#doc.shapes?.[shapeId] as Record<string, unknown> | undefined;
		if (!data) return false;
		const fb = data.forgottenBy as Record<string, number> | undefined;
		return !!(fb && fb[did]);
	}

	/**
	 * Get all forgotten (faded but not deleted) shapes
	 */
	getFadedShapes(): ShapeData[] {
		const shapes = this.#doc.shapes || {};
		return Object.values(shapes).filter(s => {
			const d = s as Record<string, unknown>;
			if (d.deleted === true) return false;
			const fb = d.forgottenBy;
			return fb && typeof fb === 'object' && Object.keys(fb).length > 0;
		});
	}

	/**
	 * Get all hard-deleted shapes (for memory panel "Deleted" section)
	 */
	getDeletedShapes(): ShapeData[] {
		const shapes = this.#doc.shapes || {};
		return Object.values(shapes).filter(s => (s as Record<string, unknown>).deleted === true);
	}

	/**
	 * Get all forgotten shapes — includes both faded and deleted (for backward compat).
	 */
	getForgottenShapes(): ShapeData[] {
		const shapes = this.#doc.shapes || {};
		return Object.values(shapes).filter(s => {
			const d = s as Record<string, unknown>;
			if (d.deleted === true) return true;
			const fb = d.forgottenBy;
			if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return true;
			return !!d.forgotten;
		});
	}

	/**
	 * Apply full document to DOM (for initial load).
	 * Three-state: deleted shapes are skipped, forgotten shapes are rendered faded.
	 */
	#applyDocToDOM(): void {
		const shapes = this.#doc.shapes || {};
		const validIds = new Set<string>();

		for (const [id, shapeData] of Object.entries(shapes)) {
			const d = shapeData as Record<string, unknown>;
			if (d.deleted === true) continue; // Deleted: not in DOM
			// Skip shapes this user has forgotten — one delete = gone from their view
			const fb = d.forgottenBy;
			if (this.#localDID && fb && typeof fb === 'object'
				&& (fb as Record<string, number>)[this.#localDID]) {
				continue;
			}
			validIds.add(id);
			this.#applyShapeToDOM(shapeData);
			// If forgotten by others (but not this user), emit state-changed for fade visual
			if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) {
				this.dispatchEvent(new CustomEvent("shape-state-changed", {
					detail: { shapeId: id, state: 'forgotten', data: shapeData }
				}));
			}
		}

		// Prune stale DOM shapes that are deleted, forgotten, or no longer in the doc
		for (const id of this.#shapes.keys()) {
			if (!validIds.has(id)) {
				this.#removeShapeFromDOM(id);
			}
		}

		// Notify event bus if there are any events to process
		if (this.#doc.eventLog && this.#doc.eventLog.length > 0) {
			this.dispatchEvent(new CustomEvent("eventlog-changed"));
		}

		// Notify comment pin manager of any pin data
		if (this.#doc.commentPins && Object.keys(this.#doc.commentPins).length > 0) {
			this.dispatchEvent(new CustomEvent("comment-pins-changed"));
		}

		// Debounce the synced event — during initial sync negotiation, #applyDocToDOM()
		// is called for every Automerge sync message (100+ round-trips). Debounce to
		// fire once after the burst settles. Only fires once per connection cycle.
		if (this.#initialSyncFired) return;
		if (this.#syncedDebounceTimer) clearTimeout(this.#syncedDebounceTimer);
		this.#syncedDebounceTimer = setTimeout(() => {
			this.#syncedDebounceTimer = null;
			this.#initialSyncFired = true;
			this.dispatchEvent(new CustomEvent("synced", { detail: { shapes } }));
		}, 300);
	}

	/**
	 * Apply Automerge patches to DOM.
	 * Three-state: forgotten shapes stay in DOM (faded), deleted shapes are removed.
	 */
	#applyPatchesToDOM(patches: Automerge.Patch[]): void {
		let eventLogChanged = false;
		let commentPinsChanged = false;

		for (const patch of patches) {
			const path = patch.path;

			// Detect eventLog changes for the event bus
			if (path[0] === "eventLog") {
				eventLogChanged = true;
				continue;
			}

			// Detect commentPins changes
			if (path[0] === "commentPins") {
				commentPinsChanged = true;
				continue;
			}

			// Handle shape updates: ["shapes", shapeId, ...]
			if (path[0] === "shapes" && typeof path[1] === "string") {
				const shapeId = path[1];
				const shapeData = this.#doc.shapes?.[shapeId];

				if (patch.action === "del" && path.length === 2) {
					// Shape truly removed from Automerge doc
					this.#removeShapeFromDOM(shapeId);
				} else if (shapeData) {
					const d = shapeData as Record<string, unknown>;
					const state = this.getShapeVisualState(shapeId);

					// Skip shapes this user has forgotten — don't create/update DOM
					const fb = d.forgottenBy;
					if (this.#localDID && fb && typeof fb === 'object'
						&& (fb as Record<string, number>)[this.#localDID]) {
						this.#removeShapeFromDOM(shapeId);
						continue;
					}

					if (state === 'deleted') {
						// Hard-deleted: remove from DOM
						this.#removeShapeFromDOM(shapeId);
						this.dispatchEvent(new CustomEvent("shape-state-changed", {
							detail: { shapeId, state: 'deleted', data: shapeData }
						}));
					} else if (state === 'forgotten') {
						// Forgotten by others: keep in DOM, emit state change for fade visual
						this.#applyShapeToDOM(shapeData);
						this.dispatchEvent(new CustomEvent("shape-state-changed", {
							detail: { shapeId, state: 'forgotten', data: shapeData }
						}));
						this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } }));
					} else {
						// Present: render normally
						this.#applyShapeToDOM(shapeData);
						this.dispatchEvent(new CustomEvent("shape-state-changed", {
							detail: { shapeId, state: 'present', data: shapeData }
						}));
						this.#postMessageToParent("shape-updated", shapeData);
					}
				}
			}
		}

		// Notify event bus of new remote events
		if (eventLogChanged) {
			this.dispatchEvent(new CustomEvent("eventlog-changed"));
		}

		// Notify comment pin manager of remote pin changes
		if (commentPinsChanged) {
			this.dispatchEvent(new CustomEvent("comment-pins-changed"));
		}
	}

	/**
	 * Apply shape data to DOM element
	 */
	#applyShapeToDOM(shapeData: ShapeData): void {
		let shape = this.#shapes.get(shapeData.id);

		if (!shape) {
			// FUN: New — instantiate shape element
			shape = this.#newShapeElement(shapeData);
			if (shape) {
				this.#shapes.set(shapeData.id, shape);
				this.dispatchEvent(new CustomEvent("shape-new", { detail: { shape, data: shapeData } }));
			}
			return;
		}

		// Update existing shape (avoid triggering our own change events)
		this.#updateShapeElement(shape, shapeData);
	}

	/**
	 * FUN: New — emit event for the canvas to instantiate a new shape from data
	 */
	#newShapeElement(data: ShapeData): FolkShape | undefined {
		this.dispatchEvent(new CustomEvent("new-shape", { detail: data }));
		return undefined;
	}

	/**
	 * Update shape element without triggering change events.
	 * Delegates to each shape's applyData() method (co-located with toJSON/fromData).
	 */
	#updateShapeElement(shape: FolkShape, data: ShapeData): void {
		if (typeof (shape as any).applyData === "function") {
			(shape as any).applyData(data);
		} else {
			// Fallback for shapes without applyData (e.g. wb-svg)
			if (shape.x !== data.x) shape.x = data.x;
			if (shape.y !== data.y) shape.y = data.y;
			if (shape.width !== data.width) shape.width = data.width;
			if (shape.height !== data.height) shape.height = data.height;
			if (shape.rotation !== data.rotation) shape.rotation = data.rotation;
		}
	}

	/**
	 * FUN: Forget — remove shape from visible DOM (shape remains in Automerge doc)
	 */
	#removeShapeFromDOM(shapeId: string): void {
		const shape = this.#shapes.get(shapeId);
		if (shape) {
			this.#shapes.delete(shapeId);
			this.dispatchEvent(new CustomEvent("shape-removed", { detail: { shapeId, shape } }));
		}
	}

	/**
	 * Save current state immediately. Call from beforeunload handler.
	 * Uses synchronous localStorage fallback since IndexedDB may not complete.
	 */
	saveBeforeUnload(): void {
		if (this.#saveDebounceTimer) {
			clearTimeout(this.#saveDebounceTimer);
			this.#saveDebounceTimer = null;
		}

		try {
			const binary = Automerge.save(this.#doc);

			// Synchronous localStorage fallback (guaranteed to complete)
			if (this.#offlineStore) {
				this.#offlineStore.saveDocEmergency(this.#communitySlug, binary);
			}

			// Also fire off IndexedDB save (may not complete before page dies)
			if (this.#offlineStore) {
				this.#offlineStore.saveDocImmediate(this.#communitySlug, binary);
			}

			// Push any pending Automerge changes to the server
			this.#syncToServer();
		} catch (e) {
			console.warn("[CommunitySync] Failed to save before unload:", e);
		}
	}

	#scheduleSave(): void {
		if (!this.#offlineStore) return;

		if (this.#saveDebounceTimer) {
			clearTimeout(this.#saveDebounceTimer);
		}

		this.#saveDebounceTimer = setTimeout(() => {
			this.#saveDebounceTimer = null;
			this.#offlineStore!.saveDoc(
				this.#communitySlug,
				Automerge.save(this.#doc)
			);
		}, 2000);
	}

	/** Flush doc to IndexedDB immediately (no debounce). Use for destructive ops. */
	#saveImmediate(): void {
		if (!this.#offlineStore) return;
		if (this.#saveDebounceTimer) {
			clearTimeout(this.#saveDebounceTimer);
			this.#saveDebounceTimer = null;
		}
		const binary = Automerge.save(this.#doc);
		this.#offlineStore.saveDocImmediate(this.#communitySlug, binary);
		this.#offlineStore.saveDocEmergency(this.#communitySlug, binary);
	}

	#persistSyncState(): void {
		if (!this.#offlineStore) return;

		try {
			const encoded = Automerge.encodeSyncState(this.#syncState);
			this.#offlineStore.saveSyncState(this.#communitySlug, encoded);
		} catch (e) {
			console.warn("[CommunitySync] Failed to save sync state:", e);
		}
	}

	// ── Undo/Redo API ──

	/**
	 * Record an undo entry. Batches rapid changes to the same shape (<500ms)
	 * by keeping the original `before` and updating the timestamp.
	 */
	#pushUndo(shapeId: string, before: ShapeData | null, after: ShapeData | null): void {
		if (this.#isUndoRedoing) return;

		const now = Date.now();
		const top = this.#undoStack[this.#undoStack.length - 1];

		// Batch: same shape within 500ms — keep original `before`, update after + ts
		if (top && top.shapeId === shapeId && (now - top.ts) < 500) {
			top.after = after;
			top.ts = now;
			return;
		}

		this.#undoStack.push({ shapeId, before, after, ts: now });
		if (this.#undoStack.length > this.#maxUndoDepth) {
			this.#undoStack.shift();
		}
		// Any new change clears redo
		this.#redoStack.length = 0;
	}

	/** Deep-clone shape data from the Automerge doc (returns null if absent). */
	#cloneShapeData(shapeId: string): ShapeData | null {
		const data = this.#doc.shapes?.[shapeId];
		if (!data) return null;
		return JSON.parse(JSON.stringify(data));
	}

	/** Undo the last local shape operation. */
	undo(): void {
		const entry = this.#undoStack.pop();
		if (!entry) return;

		this.#isUndoRedoing = true;
		try {
			if (entry.before === null) {
				// Was a creation — soft-delete (forget) the shape
				if (this.#doc.shapes?.[entry.shapeId]) {
					this.forgetShape(entry.shapeId, 'undo');
					// Snapshot after for redo
					entry.after = this.#cloneShapeData(entry.shapeId);
				}
			} else if (entry.after === null || (entry.after as Record<string, unknown>).deleted === true) {
				// Was a hard-delete — restore via rememberShape
				this.rememberShape(entry.shapeId);
				// Also restore full data if we have it
				if (entry.before) {
					this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Undo delete ${entry.shapeId}`), (doc) => {
						if (doc.shapes && doc.shapes[entry.shapeId]) {
							const restored = JSON.parse(JSON.stringify(entry.before));
							for (const [key, value] of Object.entries(restored)) {
								(doc.shapes[entry.shapeId] as Record<string, unknown>)[key] = value;
							}
						}
					});
					const shape = this.#shapes.get(entry.shapeId);
					if (shape) this.#updateShapeElement(shape, entry.before);
				}
			} else if ((entry.after as Record<string, unknown>).forgottenBy &&
				Object.keys((entry.after as Record<string, unknown>).forgottenBy as Record<string, unknown>).length > 0) {
				// Was a forget — restore via rememberShape
				this.rememberShape(entry.shapeId);
			} else {
				// Was a property change — restore `before` data
				this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Undo ${entry.shapeId}`), (doc) => {
					if (doc.shapes) {
						doc.shapes[entry.shapeId] = JSON.parse(JSON.stringify(entry.before));
					}
				});
				const shape = this.#shapes.get(entry.shapeId);
				if (shape && entry.before) this.#updateShapeElement(shape, entry.before);
			}

			this.#redoStack.push(entry);
			this.#scheduleSave();
			this.#syncToServer();
		} finally {
			this.#isUndoRedoing = false;
		}
	}

	/** Redo the last undone operation. */
	redo(): void {
		const entry = this.#redoStack.pop();
		if (!entry) return;

		this.#isUndoRedoing = true;
		try {
			if (entry.before === null && entry.after) {
				// Was a creation that got undone (forgotten) — remember it back
				this.rememberShape(entry.shapeId);
			} else if (entry.after === null || (entry.after as Record<string, unknown>).deleted === true) {
				// Re-delete
				this.hardDeleteShape(entry.shapeId);
			} else if ((entry.after as Record<string, unknown>).forgottenBy &&
				Object.keys((entry.after as Record<string, unknown>).forgottenBy as Record<string, unknown>).length > 0) {
				// Re-forget
				this.forgetShape(entry.shapeId, 'undo');
			} else {
				// Re-apply `after` data
				this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Redo ${entry.shapeId}`), (doc) => {
					if (doc.shapes) {
						doc.shapes[entry.shapeId] = JSON.parse(JSON.stringify(entry.after));
					}
				});
				const shape = this.#shapes.get(entry.shapeId);
				if (shape && entry.after) this.#updateShapeElement(shape, entry.after);
			}

			this.#undoStack.push(entry);
			this.#scheduleSave();
			this.#syncToServer();
		} finally {
			this.#isUndoRedoing = false;
		}
	}

	get canUndo(): boolean { return this.#undoStack.length > 0; }
	get canRedo(): boolean { return this.#redoStack.length > 0; }

	// ── Layer & Flow API ──

	/** Add a layer to the document */
	addLayer(layer: Layer): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add layer ${layer.id}`), (doc) => {
			if (!doc.layers) doc.layers = {};
			doc.layers[layer.id] = layer;
		});
		this.#scheduleSave();
		this.#syncToServer();
		this.dispatchEvent(new CustomEvent("layer-added", { detail: layer }));
	}

	/** Remove a layer */
	removeLayer(layerId: string): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove layer ${layerId}`), (doc) => {
			if (doc.layers && doc.layers[layerId]) {
				delete doc.layers[layerId];
			}
			// Remove flows connected to this layer
			if (doc.flows) {
				for (const [fid, flow] of Object.entries(doc.flows)) {
					if (flow.sourceLayerId === layerId || flow.targetLayerId === layerId) {
						delete doc.flows[fid];
					}
				}
			}
			// If active layer was removed, switch to first remaining
			if (doc.activeLayerId === layerId) {
				const remaining = doc.layers ? Object.keys(doc.layers) : [];
				doc.activeLayerId = remaining[0] || "";
			}
		});
		this.#scheduleSave();
		this.#syncToServer();
		this.dispatchEvent(new CustomEvent("layer-removed", { detail: { layerId } }));
	}

	/** Update a layer's properties */
	updateLayer(layerId: string, updates: Partial<Layer>): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update layer ${layerId}`), (doc) => {
			if (doc.layers && doc.layers[layerId]) {
				for (const [key, value] of Object.entries(updates)) {
					(doc.layers[layerId] as unknown as Record<string, unknown>)[key] = value;
				}
			}
		});
		this.#scheduleSave();
		this.#syncToServer();
	}

	/** Set active layer — local-only, never broadcast to other tabs/devices */
	setActiveLayer(layerId: string): void {
		this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } }));
	}

	/** Set layer view mode */
	setLayerViewMode(mode: "flat" | "stack"): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Set view mode ${mode}`), (doc) => {
			doc.layerViewMode = mode;
		});
		this.#scheduleSave();
		this.#syncToServer();
	}

	/** Add a flow between layers */
	addFlow(flow: LayerFlow): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add flow ${flow.id}`), (doc) => {
			if (!doc.flows) doc.flows = {};
			doc.flows[flow.id] = flow;
		});
		this.#scheduleSave();
		this.#syncToServer();
		this.dispatchEvent(new CustomEvent("flow-added", { detail: flow }));
	}

	/** Remove a flow */
	removeFlow(flowId: string): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove flow ${flowId}`), (doc) => {
			if (doc.flows && doc.flows[flowId]) {
				delete doc.flows[flowId];
			}
		});
		this.#scheduleSave();
		this.#syncToServer();
	}

	/** Update flow properties */
	updateFlow(flowId: string, updates: Partial<LayerFlow>): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update flow ${flowId}`), (doc) => {
			if (doc.flows && doc.flows[flowId]) {
				for (const [key, value] of Object.entries(updates)) {
					(doc.flows[flowId] as unknown as Record<string, unknown>)[key] = value;
				}
			}
		});
		this.#scheduleSave();
		this.#syncToServer();
	}

	/** Get all layers (sorted by order) */
	getLayers(): Layer[] {
		const layers = this.#doc.layers || {};
		return Object.values(layers).sort((a, b) => a.order - b.order);
	}

	/** Get all flows */
	getFlows(): LayerFlow[] {
		const flows = this.#doc.flows || {};
		return Object.values(flows);
	}

	/** Get flows for a specific layer (as source or target) */
	getFlowsForLayer(layerId: string): LayerFlow[] {
		return this.getFlows().filter(
			f => f.sourceLayerId === layerId || f.targetLayerId === layerId
		);
	}

	// ── Connection API ──

	/** Add a connection to the document */
	addConnection(conn: SpaceConnection): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Add connection ${conn.id}`), (doc) => {
			if (!doc.connections) doc.connections = {};
			doc.connections[conn.id] = conn;
		});
		this.#scheduleSave();
		this.#syncToServer();
		this.dispatchEvent(new CustomEvent("connection-added", { detail: conn }));
	}

	/** Remove a connection */
	removeConnection(connId: string): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remove connection ${connId}`), (doc) => {
			if (doc.connections && doc.connections[connId]) {
				delete doc.connections[connId];
			}
		});
		this.#scheduleSave();
		this.#syncToServer();
		this.dispatchEvent(new CustomEvent("connection-removed", { detail: { connId } }));
	}

	/** Update connection properties */
	updateConnection(connId: string, updates: Partial<SpaceConnection>): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update connection ${connId}`), (doc) => {
			if (doc.connections && doc.connections[connId]) {
				for (const [key, value] of Object.entries(updates)) {
					if (key === 'id') continue;
					(doc.connections[connId] as unknown as Record<string, unknown>)[key] = value;
				}
			}
		});
		this.#scheduleSave();
		this.#syncToServer();
		this.dispatchEvent(new CustomEvent("connection-updated", { detail: { connId, updates } }));
	}

	/** Get all connections */
	getConnections(): SpaceConnection[] {
		const connections = this.#doc.connections || {};
		return Object.values(connections);
	}

	/** Get connections filtered by state */
	getConnectionsByState(state: ConnectionState): SpaceConnection[] {
		return this.getConnections().filter(c => c.state === state);
	}

	/** Get connections filtered by flow kind */
	getConnectionsByFlowKind(kind: FlowKind): SpaceConnection[] {
		return this.getConnections().filter(c => c.flowKinds.includes(kind));
	}

	/** Compute membrane permeability from all connections */
	getMembranePermeability(): MembranePermeability {
		return computeMembranePermeability(this.getConnections(), this.#communitySlug);
	}

	// ── Event Bus API ──

	/** Max event log entries before oldest are pruned. */
	static readonly MAX_EVENT_LOG = 100;

	/** Append an event entry to the CRDT eventLog (ring-buffered). */
	appendEvent(entry: EventEntry): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Event: ${entry.channel}`), (doc) => {
			if (!doc.eventLog) doc.eventLog = [];
			doc.eventLog.push(entry as any);
			// Ring buffer: prune oldest entries beyond limit
			while (doc.eventLog.length > CommunitySync.MAX_EVENT_LOG) {
				doc.eventLog.splice(0, 1);
			}
		});
		this.#scheduleSave();
		this.#syncToServer();
	}

	/** Read the current event log. */
	getEventLog(): EventEntry[] {
		return (this.#doc.eventLog as EventEntry[] | undefined) || [];
	}

	/** Set event subscriptions for a shape. */
	setShapeSubscriptions(shapeId: string, channels: string[]): void {
		if (!this.#doc.shapes?.[shapeId]) return;
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Subscribe ${shapeId}`), (doc) => {
			if (doc.shapes[shapeId]) {
				(doc.shapes[shapeId] as Record<string, unknown>).subscriptions = channels;
			}
		});
		this.#scheduleSave();
		this.#syncToServer();
	}

	/** Get event subscriptions for a shape. */
	getShapeSubscriptions(shapeId: string): string[] {
		const data = this.#doc.shapes?.[shapeId] as Record<string, unknown> | undefined;
		return (data?.subscriptions as string[] | undefined) || [];
	}

	/** Get all shape IDs subscribed to a given channel. */
	getShapesSubscribedTo(channel: string): string[] {
		const shapes = this.#doc.shapes || {};
		const result: string[] = [];
		for (const [id, data] of Object.entries(shapes)) {
			const subs = (data as Record<string, unknown>).subscriptions as string[] | undefined;
			if (subs && subs.includes(channel)) {
				result.push(id);
			}
		}
		return result;
	}

	/** Get a shape DOM element by ID (for event delivery). */
	getShapeElement(shapeId: string): FolkShape | undefined {
		return this.#shapes.get(shapeId);
	}

	// ── History API ──

	/**
	 * View document state at a specific point in history.
	 */
	viewAt(heads: string[]): CommunityDoc {
		return Automerge.view(this.#doc, heads);
	}

	/**
	 * Revert document content to the state at a given change hash.
	 * Creates a forward change (preserving full history) and syncs to peers.
	 * Meta (space name/slug/config) is preserved — only content is reverted.
	 */
	revertToHash(hash: string): void {
		const snapshot = Automerge.view(this.#doc, [hash]);
		const data = JSON.parse(JSON.stringify(snapshot)) as CommunityDoc;

		const newDoc = Automerge.change(this.#doc, makeChangeMessage(`Revert to change ${hash.slice(0, 8)}`), (doc) => {
			doc.shapes = data.shapes || {};
			if (data.layers) doc.layers = data.layers;
			if (data.flows) doc.flows = data.flows;
			if (data.connections) doc.connections = data.connections;
			if (data.groups) doc.groups = data.groups;
			if (data.nestedSpaces) doc.nestedSpaces = data.nestedSpaces;
			if (data.activeLayerId !== undefined) doc.activeLayerId = data.activeLayerId;
			if (data.layerViewMode !== undefined) doc.layerViewMode = data.layerViewMode;
			if (data.commentPins) doc.commentPins = data.commentPins;
			// Preserve meta — don't revert space name/slug/config
		});

		this._applyDocChange(newDoc);
		this.#undoStack.length = 0;
		this.#redoStack.length = 0;
	}

	/**
	 * Get parsed history entries for the activity feed.
	 */
	getHistoryEntries(limit?: number): HistoryEntry[] {
		try {
			const changes = Automerge.getHistory(this.#doc);
			const entries: HistoryEntry[] = changes.map((entry, i) => {
				const change = entry.change;
				const parsed = parseChangeMessage(change.message || null);
				return {
					hash: change.hash || `change-${i}`,
					actor: change.actor || "unknown",
					username: parsed.user || "Unknown",
					time: parsed.ts || (change.time ? change.time * 1000 : 0),
					message: parsed.text,
					opsCount: change.ops?.length || 0,
					seq: change.seq || i,
				};
			});
			return limit ? entries.slice(-limit) : entries;
		} catch {
			return [];
		}
	}

	/**
	 * Disconnect from server
	 */
	disconnect(): void {
		this.#disconnectedIntentionally = true;
		if (this.#ws) {
			this.#ws.close();
			this.#ws = null;
		}
	}

	/**
	 * Get document as binary for storage
	 */
	getDocumentBinary(): Uint8Array {
		return Automerge.save(this.#doc);
	}

	/**
	 * Load document from binary
	 */
	loadDocumentBinary(binary: Uint8Array): void {
		this.#doc = Automerge.load<CommunityDoc>(binary);
		this.#syncState = Automerge.initSyncState();
		this.#applyDocToDOM();
	}

	/**
	 * Broadcast shape updates to parent frame (for iframe embedding in rtrips.online)
	 */
	#postMessageToParent(type: string, data: ShapeData): void {
		if (typeof window === "undefined" || window.parent === window) return;
		try {
			window.parent.postMessage(
				{
					source: "rspace-canvas",
					type,
					communitySlug: this.#communitySlug,
					shapeId: data.id,
					data,
				},
				"*"
			);
		} catch {
			// postMessage may fail in certain security contexts
		}
	}

}
