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

// 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;
	// Allow arbitrary shape-specific properties from toJSON()
	[key: string]: unknown;
}

// ── 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;
	};
	/** Currently active layer ID */
	activeLayerId?: string;
	/** Layer view mode: flat (tabs) or stack (side view) */
	layerViewMode?: "flat" | "stack";
}

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();
	#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;
	#wsUrl: string | null = null;

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

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

	/**
	 * 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.dispatchEvent(new CustomEvent("disconnected"));

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

		this.#ws.onerror = (error) => {
			console.error("[CommunitySync] WebSocket error:", 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 "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);

		// Listen for transform events
		shape.addEventListener("folk-transform", ((e: CustomEvent) => {
			this.#handleShapeChange(shape);
		}) as EventListener);

		// Listen for content changes (for markdown shapes)
		shape.addEventListener("content-change", ((e: CustomEvent) => {
			this.#handleShapeChange(shape);
		}) as EventListener);

		// Add to document if not exists
		if (!this.#doc.shapes[shape.id]) {
			this.#updateShapeInDoc(shape);
		}
	}

	/**
	 * Unregister a shape
	 */
	unregisterShape(shapeId: string): void {
		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 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));
		});

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

		// 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.#scheduleSave();
		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 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;
			}
		});

		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.#scheduleSave();
		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 {
		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.#removeShapeFromDOM(shapeId);
		this.dispatchEvent(new CustomEvent("shape-state-changed", {
			detail: { shapeId, state: 'deleted', data: this.#doc.shapes?.[shapeId] }
		}));
		this.#scheduleSave();
		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 || {};

		for (const [id, shapeData] of Object.entries(shapes)) {
			const d = shapeData as Record<string, unknown>;
			if (d.deleted === true) continue; // Deleted: not in DOM
			this.#applyShapeToDOM(shapeData);
			// If forgotten (faded), emit state-changed so canvas can apply visual
			const fb = d.forgottenBy;
			if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) {
				this.dispatchEvent(new CustomEvent("shape-state-changed", {
					detail: { shapeId: id, state: 'forgotten', data: shapeData }
				}));
			}
		}

		// 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.
		if (this.#syncedDebounceTimer) clearTimeout(this.#syncedDebounceTimer);
		this.#syncedDebounceTimer = setTimeout(() => {
			this.#syncedDebounceTimer = null;
			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 {
		for (const patch of patches) {
			const path = patch.path;

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

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

	/**
	 * 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
	 */
	#updateShapeElement(shape: FolkShape, data: ShapeData): void {
		// Temporarily remove event listeners to avoid feedback loop
		const isOurChange =
			shape.x === data.x &&
			shape.y === data.y &&
			shape.width === data.width &&
			shape.height === data.height &&
			shape.rotation === data.rotation;

		if (isOurChange && !("content" in data)) {
			return; // No change needed
		}

		// Update position and size
		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;

		// Update content for markdown shapes
		if ("content" in data && "content" in shape) {
			const shapeWithContent = shape as any;
			if (shapeWithContent.content !== data.content) {
				shapeWithContent.content = data.content;
			}
		}

		// Update arrow-specific properties
		if (data.type === "folk-arrow") {
			const arrow = shape as any;
			if (data.sourceId !== undefined && arrow.sourceId !== data.sourceId) {
				arrow.sourceId = data.sourceId;
			}
			if (data.targetId !== undefined && arrow.targetId !== data.targetId) {
				arrow.targetId = data.targetId;
			}
			if (data.color !== undefined && arrow.color !== data.color) {
				arrow.color = data.color;
			}
			if (data.strokeWidth !== undefined && arrow.strokeWidth !== data.strokeWidth) {
				arrow.strokeWidth = data.strokeWidth;
			}
		}

		// Update wrapper-specific properties
		if (data.type === "folk-wrapper") {
			const wrapper = shape as any;
			if (data.title !== undefined && wrapper.title !== data.title) {
				wrapper.title = data.title;
			}
			if (data.icon !== undefined && wrapper.icon !== data.icon) {
				wrapper.icon = data.icon;
			}
			if (data.primaryColor !== undefined && wrapper.primaryColor !== data.primaryColor) {
				wrapper.primaryColor = data.primaryColor;
			}
			if (data.isMinimized !== undefined && wrapper.isMinimized !== data.isMinimized) {
				wrapper.isMinimized = data.isMinimized;
			}
			if (data.isPinned !== undefined && wrapper.isPinned !== data.isPinned) {
				wrapper.isPinned = data.isPinned;
			}
			if (data.tags !== undefined) {
				wrapper.tags = data.tags;
			}
		}

		// Update token mint properties
		if (data.type === "folk-token-mint") {
			const mint = shape as any;
			if (data.tokenName !== undefined && mint.tokenName !== data.tokenName) mint.tokenName = data.tokenName;
			if (data.tokenSymbol !== undefined && mint.tokenSymbol !== data.tokenSymbol) mint.tokenSymbol = data.tokenSymbol;
			if (data.description !== undefined && mint.description !== data.description) mint.description = data.description;
			if (data.totalSupply !== undefined && mint.totalSupply !== data.totalSupply) mint.totalSupply = data.totalSupply;
			if (data.issuedSupply !== undefined && mint.issuedSupply !== data.issuedSupply) mint.issuedSupply = data.issuedSupply;
			if (data.tokenColor !== undefined && mint.tokenColor !== data.tokenColor) mint.tokenColor = data.tokenColor;
			if (data.tokenIcon !== undefined && mint.tokenIcon !== data.tokenIcon) mint.tokenIcon = data.tokenIcon;
		}

		// Update token ledger properties
		if (data.type === "folk-token-ledger") {
			const ledger = shape as any;
			if (data.mintId !== undefined && ledger.mintId !== data.mintId) ledger.mintId = data.mintId;
			if (data.entries !== undefined) ledger.entries = data.entries;
		}

		// Update choice-vote properties
		if (data.type === "folk-choice-vote") {
			const vote = shape as any;
			if (data.title !== undefined && vote.title !== data.title) vote.title = data.title;
			if (data.options !== undefined) vote.options = data.options;
			if (data.mode !== undefined && vote.mode !== data.mode) vote.mode = data.mode;
			if (data.budget !== undefined && vote.budget !== data.budget) vote.budget = data.budget;
			if (data.votes !== undefined) vote.votes = data.votes;
		}

		// Update choice-rank properties
		if (data.type === "folk-choice-rank") {
			const rank = shape as any;
			if (data.title !== undefined && rank.title !== data.title) rank.title = data.title;
			if (data.options !== undefined) rank.options = data.options;
			if (data.rankings !== undefined) rank.rankings = data.rankings;
		}

		// Update nested canvas properties
		if (data.type === "folk-canvas") {
			const canvas = shape as any;
			if (data.sourceSlug !== undefined && canvas.sourceSlug !== data.sourceSlug) canvas.sourceSlug = data.sourceSlug;
			if (data.sourceDID !== undefined && canvas.sourceDID !== data.sourceDID) canvas.sourceDID = data.sourceDID;
			if (data.permissions !== undefined) canvas.permissions = data.permissions;
			if (data.collapsed !== undefined && canvas.collapsed !== data.collapsed) canvas.collapsed = data.collapsed;
			if (data.label !== undefined && canvas.label !== data.label) canvas.label = data.label;
		}

		// Update choice-spider properties
		if (data.type === "folk-choice-spider") {
			const spider = shape as any;
			if (data.title !== undefined && spider.title !== data.title) spider.title = data.title;
			if (data.options !== undefined) spider.options = data.options;
			if (data.criteria !== undefined) spider.criteria = data.criteria;
			if (data.scores !== undefined) spider.scores = data.scores;
		}

		// Update rApp embed properties
		if (data.type === "folk-rapp") {
			const rapp = shape as any;
			if (data.moduleId !== undefined && rapp.moduleId !== data.moduleId) rapp.moduleId = data.moduleId;
			if (data.spaceSlug !== undefined && rapp.spaceSlug !== data.spaceSlug) rapp.spaceSlug = data.spaceSlug;
		}

		// Update feed shape properties
		if (data.type === "folk-feed") {
			const feed = shape as any;
			if (data.sourceLayer !== undefined && feed.sourceLayer !== data.sourceLayer) feed.sourceLayer = data.sourceLayer;
			if (data.sourceModule !== undefined && feed.sourceModule !== data.sourceModule) feed.sourceModule = data.sourceModule;
			if (data.feedId !== undefined && feed.feedId !== data.feedId) feed.feedId = data.feedId;
			if (data.flowKind !== undefined && feed.flowKind !== data.flowKind) feed.flowKind = data.flowKind;
			if (data.feedFilter !== undefined && feed.feedFilter !== data.feedFilter) feed.feedFilter = data.feedFilter;
			if (data.maxItems !== undefined && feed.maxItems !== data.maxItems) feed.maxItems = data.maxItems;
			if (data.refreshInterval !== undefined && feed.refreshInterval !== data.refreshInterval) feed.refreshInterval = data.refreshInterval;
		}

		// Update social-post properties
		if (data.type === "folk-social-post") {
			const post = shape as any;
			if (data.platform !== undefined && post.platform !== data.platform) post.platform = data.platform;
			if (data.postType !== undefined && post.postType !== data.postType) post.postType = data.postType;
			if (data.mediaUrl !== undefined && post.mediaUrl !== data.mediaUrl) post.mediaUrl = data.mediaUrl;
			if (data.mediaType !== undefined && post.mediaType !== data.mediaType) post.mediaType = data.mediaType;
			if (data.scheduledAt !== undefined && post.scheduledAt !== data.scheduledAt) post.scheduledAt = data.scheduledAt;
			if (data.status !== undefined && post.status !== data.status) post.status = data.status;
			if (data.hashtags !== undefined) post.hashtags = data.hashtags;
			if (data.stepNumber !== undefined && post.stepNumber !== data.stepNumber) post.stepNumber = data.stepNumber;
		}

		// Update workflow-block properties
		if (data.type === "folk-workflow-block") {
			const block = shape as any;
			if (data.blockType !== undefined && block.blockType !== data.blockType) block.blockType = data.blockType;
			if (data.label !== undefined && block.label !== data.label) block.label = data.label;
			if (data.inputs !== undefined) block.inputs = data.inputs;
			if (data.outputs !== undefined) block.outputs = data.outputs;
			if (data.config !== undefined) block.config = data.config;
		}
	}

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

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

	// ── 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 */
	setActiveLayer(layerId: string): void {
		this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Switch to layer ${layerId}`), (doc) => {
			doc.activeLayerId = layerId;
		});
		this.#scheduleSave();
		this.#syncToServer();
		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);
	}

	// ── History API ──

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

	/**
	 * 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
		}
	}

}
