/**
 * <rstack-collab-overlay> — drop-in multiplayer presence for all rApps.
 *
 * Features:
 *   - "N online" badge (top-right pill with colored dots)
 *   - People panel dropdown (click badge to open)
 *   - Online/Offline mode toggle in panel
 *   - Remote cursors (SVG arrows with username labels, page-relative)
 *   - Focus highlighting (colored outline rings on data-collab-id elements)
 *   - Auto-discovery via rspace-doc-subscribe events from runtime
 *   - Bridge API for canvas (external peer management via CommunitySync)
 *
 * Attributes:
 *   module-id  — current module identifier
 *   space      — current space slug
 *   doc-id     — explicit doc ID (fallback if auto-discovery misses)
 *   mode       — "badge-only" for iframe/proxy modules (no cursors)
 */

import type { AwarenessMessage } from '../local-first/sync';

// ── Peer color palette (same as canvas PresenceManager) ──
const PEER_COLORS = [
	'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
	'#ec4899', '#06b6d4', '#f97316', '#14b8a6', '#6366f1',
];

interface PeerState {
	peerId: string;
	username: string;
	color: string;
	cursor: { x: number; y: number } | null;
	selection: string | null;
	lastSeen: number;
	module?: string;   // which rApp they're in
	context?: string;  // human-readable view label (e.g. "My Notebook > Note Title")
	viewId?: string | null;  // sub-document view (e.g. noteId) — cursors hidden when mismatched
}

export class RStackCollabOverlay extends HTMLElement {
	#shadow: ShadowRoot;
	#peers = new Map<string, PeerState>();
	#docId: string | null = null;
	#moduleId: string | null = null;
	#localPeerId: string | null = null;
	#localColor = PEER_COLORS[0];
	#localUsername = 'Anonymous';
	#unsubAwareness: (() => void) | null = null;
	#unsubPresence: (() => void) | null = null;
	#unsubLeave: (() => void) | null = null;
	#unsubConnect: (() => void) | null = null;
	#unsubDisconnect: (() => void) | null = null;
	#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
	#lastCursor = { x: 0, y: 0 };
	#gcInterval: ReturnType<typeof setInterval> | null = null;
	#badgeOnly = false;
	#soloMode = false;
	#panelOpen = false;
	#connState: 'connected' | 'offline' | 'reconnecting' | 'connecting' = 'connecting';
	#externalPeers = false; // true for canvas — peers fed in via public API
	#openActionsId: string | null = null; // which peer's actions dropdown is open
	#spaceMembers: { did: string; displayName: string; role: string }[] = [];
	#space: string | null = null;
	#viewId: string | null = null; // sub-document view filter (e.g. noteId)

	constructor() {
		super();
		this.#shadow = this.attachShadow({ mode: 'open' });
	}

	connectedCallback() {
		this.#moduleId = this.getAttribute('module-id');
		this.#space = this.getAttribute('space');
		this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
		this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1';

		// Canvas page — peers fed externally via bridge API, skip runtime connection
		if (this.#moduleId === 'rspace') {
			this.#externalPeers = true;
			// Dispatch initial solo-mode event for canvas PresenceManager
			document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
			// Canvas: load members immediately (no runtime wait)
			this.#loadSpaceMembers();
		}

		// Resolve local identity
		this.#resolveIdentity();

		// Render initial (badge always visible)
		this.#render();
		this.#renderBadge();

		// GC timer starts lazily when first peer arrives (see #ensureGcTimer)

		if (!this.#externalPeers) {
			// Explicit doc-id attribute (fallback)
			const explicitDocId = this.getAttribute('doc-id');
			if (explicitDocId) this.#docId = explicitDocId;

			// Listen for runtime doc subscriptions (auto-discovery)
			window.addEventListener('rspace-doc-subscribe', this.#onDocSubscribe);

			// Try connecting to runtime
			this.#tryConnect();
		}

		// Listen for sub-document view changes (e.g. rDocs note navigation)
		window.addEventListener('rspace-view-change', this.#onViewChange);

		// Click-outside closes panel (listen on document, check composedPath for shadow DOM)
		document.addEventListener('click', this.#onDocumentClick);
	}

	disconnectedCallback() {
		document.removeEventListener('click', this.#onDocumentClick);
		window.removeEventListener('rspace-view-change', this.#onViewChange);
		if (!this.#externalPeers) {
			window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
			this.#unsubAwareness?.();
			this.#unsubPresence?.();
			this.#unsubLeave?.();
			this.#unsubConnect?.();
			this.#unsubDisconnect?.();
			this.#stopMouseTracking();
			this.#stopFocusTracking();
		}
		if (this.#gcInterval) clearInterval(this.#gcInterval);
		this.#gcInterval = null;
		if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval);
		this.#runtimePollInterval = null;
	}

	// ── Public bridge API (for canvas CommunitySync) ──

	updatePeer(peerId: string, username: string, color: string) {
		const existing = this.#peers.get(peerId);
		this.#peers.set(peerId, {
			peerId,
			username: username || existing?.username || 'Anonymous',
			color: color || existing?.color || this.#colorForPeer(peerId),
			cursor: existing?.cursor ?? null,
			selection: existing?.selection ?? null,
			lastSeen: Date.now(),
		});
		this.#ensureGcTimer();
		this.#renderBadge();
		if (this.#panelOpen) this.#renderPanel();
	}

	removePeer(peerId: string) {
		this.#peers.delete(peerId);
		if (this.#openActionsId === peerId) this.#openActionsId = null;
		this.#renderBadge();
		if (this.#panelOpen) this.#renderPanel();
	}

	setConnState(state: 'connected' | 'offline' | 'reconnecting' | 'connecting') {
		this.#connState = state;
		this.#renderBadge();
		if (this.#panelOpen) this.#renderPanel();
	}

	clearPeers() {
		this.#peers.clear();
		this.#openActionsId = null;
		this.#renderBadge();
		if (this.#panelOpen) this.#renderPanel();
	}

	// ── Auto-discovery ──

	#onDocSubscribe = (e: Event) => {
		const { docId } = (e as CustomEvent).detail;
		if (!this.#docId && docId) {
			this.#docId = docId;
			this.#connectToDoc();
		}
	};

	#onViewChange = (e: Event) => {
		const { viewId } = (e as CustomEvent).detail ?? {};
		this.#viewId = viewId || null;
		// Re-broadcast so peers learn our current viewId
		this.#broadcastPresence(this.#lastCursor, undefined);
		// Re-render cursors immediately (hides/shows based on new viewId)
		if (!this.#badgeOnly) {
			this.#renderCursors();
			this.#renderFocusRings();
		}
		if (this.#panelOpen) this.#renderPanel();
	};

	// ── Runtime connection ──

	#runtimePollInterval: ReturnType<typeof setInterval> | null = null;

	#tryConnect() {
		const runtime = (window as any).__rspaceOfflineRuntime;
		if (runtime?.isInitialized) {
			this.#onRuntimeReady(runtime);
		} else {
			// Poll until runtime is ready, give up after ~10s (demo/standalone pages have no runtime)
			let polls = 0;
			this.#runtimePollInterval = setInterval(() => {
				const rt = (window as any).__rspaceOfflineRuntime;
				if (rt?.isInitialized) {
					clearInterval(this.#runtimePollInterval!);
					this.#runtimePollInterval = null;
					this.#onRuntimeReady(rt);
					return;
				}
				polls++;
				if (polls >= 20) {
					// No runtime after ~10s — standalone/demo mode
					clearInterval(this.#runtimePollInterval!);
					this.#runtimePollInterval = null;
					this.#connState = 'connected';
					this.#renderBadge();
					if (this.#panelOpen) this.#renderPanel();
				}
			}, 500);
		}
	}

	#onRuntimeReady(runtime: any) {
		this.#localPeerId = runtime.peerId;
		// Assign a deterministic color from peer ID
		this.#localColor = this.#colorForPeer(this.#localPeerId!);
		// Set initial state from actual runtime connection status
		this.#connState = runtime.isOnline ? 'connected' : 'offline';

		// Track ongoing connection state changes
		this.#unsubConnect = runtime.onConnect(() => this.setConnState('connected'));
		this.#unsubDisconnect = runtime.onDisconnect(() => {
			this.setConnState(navigator.onLine ? 'reconnecting' : 'offline');
		});

		// Load space members for offline display
		this.#loadSpaceMembers();

		if (this.#docId) {
			this.#connectToDoc();
		}

		// Listen for space-wide presence broadcasts (module context from all rApps)
		this.#unsubPresence = runtime.onCustomMessage('presence', (msg: any) => {
			if (!msg.username) return;
			// Use peerId from server relay, or fall back to a hash of username
			const pid = msg.peerId || `anon-${msg.username}`;
			if (pid === this.#localPeerId) return; // ignore self

			const existing = this.#peers.get(pid);
			this.#peers.set(pid, {
				peerId: pid,
				username: msg.username || existing?.username || 'Anonymous',
				color: msg.color || existing?.color || this.#colorForPeer(pid),
				cursor: existing?.cursor ?? null,
				selection: existing?.selection ?? null,
				lastSeen: Date.now(),
				module: msg.module || existing?.module,
				context: msg.context || existing?.context,
			});
			this.#ensureGcTimer();
			this.#renderBadge();
			if (this.#panelOpen) this.#renderPanel();
		});

		// Listen for explicit leave signals (immediate cleanup, no GC wait)
		this.#unsubLeave = runtime.onCustomMessage('presence-leave', (msg: any) => {
			const pid = msg.peerId;
			if (!pid || pid === this.#localPeerId) return;
			if (this.#peers.has(pid)) {
				this.#peers.delete(pid);
				this.#renderBadge();
				if (!this.#badgeOnly) {
					this.#renderCursors();
					this.#renderFocusRings();
				}
				if (this.#panelOpen) this.#renderPanel();
			}
		});
	}

	async #loadSpaceMembers(retry = true) {
		let members: Record<string, { role: string; displayName?: string }> = {};

		// Try CRDT doc first (same pattern as rstack-space-settings)
		const sync = (window as any).__communitySync;
		if (sync?.doc?.members) {
			members = sync.doc.members;
		} else if (this.#space) {
			// Fallback: fetch from API
			const host = window.location.hostname;
			const isSubdomain = host.split('.').length >= 3 && !host.startsWith('www.');
			const metaUrl = isSubdomain ? '/rspace/api/meta' : `/${this.#space}/rspace/api/meta`;
			try {
				const token = localStorage.getItem('encryptid_token');
				const res = await fetch(metaUrl, {
					headers: token ? { Authorization: `Bearer ${token}` } : {},
				});
				if (res.ok) {
					const json = await res.json();
					if (json.meta?.members) members = json.meta.members;
				}
			} catch { /* offline or error */ }
		}

		const dids = Object.keys(members);
		if (!dids.length) {
			// CRDT doc may not be synced yet — retry once after 5s
			if (retry) setTimeout(() => this.#loadSpaceMembers(false), 5000);
			return;
		}

		// Resolve display names via EncryptID
		let resolved: Record<string, { username: string; displayName: string }> = {};
		try {
			const res = await fetch('/api/users/resolve-dids', {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ dids }),
			});
			if (res.ok) resolved = await res.json();
		} catch { /* offline */ }

		this.#spaceMembers = dids.map(did => ({
			did,
			displayName: resolved[did]?.displayName || resolved[did]?.username || members[did].displayName || did.slice(0, 12),
			role: members[did].role || 'member',
		}));

		this.#renderBadge();
		if (this.#panelOpen) this.#renderPanel();
	}

	#connectToDoc() {
		const runtime = (window as any).__rspaceOfflineRuntime;
		if (!runtime || !this.#docId) return;

		// Unsubscribe from previous
		this.#unsubAwareness?.();

		// Listen for remote awareness
		this.#unsubAwareness = runtime.onAwareness(this.#docId, (msg: AwarenessMessage) => {
			if (msg.peer === this.#localPeerId) return; // ignore self
			this.#handleRemoteAwareness(msg);
		});

		// Start broadcasting local presence
		if (!this.#badgeOnly) {
			this.#startMouseTracking();
			this.#startFocusTracking();
		}

		// Send initial presence (announce we're here)
		this.#broadcastPresence();
	}

	// ── Identity ──

	#resolveIdentity() {
		try {
			const raw = localStorage.getItem('encryptid_session');
			if (raw) {
				const session = JSON.parse(raw);
				if (session?.claims?.username) this.#localUsername = session.claims.username;
				else if (session?.username) this.#localUsername = session.username;
			}
			// Fallback: rstack-identity also stores username separately
			if (this.#localUsername === 'Anonymous') {
				const stored = localStorage.getItem('rspace-username');
				if (stored) this.#localUsername = stored;
			}
		} catch { /* no session */ }
	}

	// ── Remote awareness handling ──

	#handleRemoteAwareness(msg: AwarenessMessage) {
		if (this.#soloMode) return; // suppress incoming awareness in solo mode

		const existing = this.#peers.get(msg.peer);
		const peer: PeerState = {
			peerId: msg.peer,
			username: msg.username || existing?.username || 'Anonymous',
			color: msg.color || existing?.color || this.#colorForPeer(msg.peer),
			cursor: msg.cursor ?? existing?.cursor ?? null,
			selection: msg.selection ?? existing?.selection ?? null,
			lastSeen: Date.now(),
			viewId: msg.viewId ?? existing?.viewId ?? null,
		};
		this.#peers.set(msg.peer, peer);
		this.#ensureGcTimer();
		this.#renderBadge();
		if (!this.#badgeOnly) {
			this.#renderCursors();
			this.#renderFocusRings();
		}
	}

	// ── Local broadcasting ──

	/** Effective view identifier — viewId if set, else moduleId. Used to scope cursor visibility. */
	get #effectiveViewId(): string | null {
		return this.#viewId || this.#moduleId || null;
	}

	#broadcastPresence(cursor?: { x: number; y: number }, selection?: string) {
		if (this.#soloMode) return; // suppress outgoing awareness in solo mode

		const runtime = (window as any).__rspaceOfflineRuntime;
		if (!runtime || !this.#docId) return;

		runtime.sendAwareness(this.#docId, {
			cursor,
			selection,
			username: this.#localUsername,
			color: this.#localColor,
			viewId: this.#effectiveViewId,
		});
	}

	// ── Pointer tracking (15Hz throttle) — covers mouse, touch, and pen ──

	#pointerHandler = (e: PointerEvent) => {
		this.#lastCursor = { x: e.clientX + window.scrollX, y: e.clientY + window.scrollY };
	};

	#startMouseTracking() {
		document.addEventListener('pointermove', this.#pointerHandler, { passive: true });
		// Broadcast at 15Hz
		this.#mouseMoveTimer = setInterval(() => {
			this.#broadcastPresence(this.#lastCursor, undefined);
		}, 67); // ~15Hz
	}

	#stopMouseTracking() {
		document.removeEventListener('pointermove', this.#pointerHandler);
		if (this.#mouseMoveTimer) clearInterval(this.#mouseMoveTimer);
		this.#mouseMoveTimer = null;
	}

	// ── Focus tracking on data-collab-id elements ──

	#focusHandler = (e: FocusEvent) => {
		const real = (e.composedPath()[0] as HTMLElement);
		const target = real?.closest?.('[data-collab-id]');
		if (target) {
			const collabId = target.getAttribute('data-collab-id');
			if (collabId) this.#broadcastPresence(undefined, collabId);
		}
	};

	#blurHandler = () => {
		this.#broadcastPresence(undefined, '');
	};

	#startFocusTracking() {
		document.addEventListener('focusin', this.#focusHandler, { passive: true });
		document.addEventListener('pointerdown', this.#clickHandler, { passive: true });
		document.addEventListener('focusout', this.#blurHandler, { passive: true });
	}

	#stopFocusTracking() {
		document.removeEventListener('focusin', this.#focusHandler);
		document.removeEventListener('pointerdown', this.#clickHandler);
		document.removeEventListener('focusout', this.#blurHandler);
	}

	// Also track taps/clicks on data-collab-id (many elements aren't focusable)
	#clickHandler = (e: PointerEvent) => {
		const real = (e.composedPath()[0] as HTMLElement);
		const target = real?.closest?.('[data-collab-id]');
		if (target) {
			const collabId = target.getAttribute('data-collab-id');
			if (collabId) this.#broadcastPresence(undefined, collabId);
		}
	};

	// ── Click-outside closes panel ──

	#onDocumentClick = (e: MouseEvent) => {
		if (!this.#panelOpen) return;
		// Check if click was inside our shadow DOM
		const path = e.composedPath();
		if (path.includes(this)) return;
		this.#panelOpen = false;
		this.#shadow.getElementById('people-panel')?.classList.remove('open');
	};

	// ── GC stale peers ──

	/** Start GC timer lazily — only when peers exist, stop when they're all gone. */
	#ensureGcTimer() {
		if (!this.#gcInterval && this.#peers.size > 0) {
			this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
		}
	}

	#gcPeers() {
		const now = Date.now();
		const staleThreshold = this.#externalPeers ? 30000 : 15000;
		let changed = false;
		for (const [id, peer] of this.#peers) {
			if (now - peer.lastSeen > staleThreshold) {
				this.#peers.delete(id);
				changed = true;
			}
		}
		if (changed) {
			this.#renderBadge();
			if (!this.#badgeOnly) {
				this.#renderCursors();
				this.#renderFocusRings();
			}
			if (this.#panelOpen) this.#renderPanel();
		}
		// Stop timer when no peers remain
		if (this.#peers.size === 0 && this.#gcInterval) {
			clearInterval(this.#gcInterval);
			this.#gcInterval = null;
		}
	}

	/** Deduplicate peers by username, keeping the most recently seen entry per user. */
	#uniquePeers(): PeerState[] {
		const byName = new Map<string, PeerState>();
		for (const peer of this.#peers.values()) {
			const key = peer.username.toLowerCase();
			const existing = byName.get(key);
			if (!existing || peer.lastSeen > existing.lastSeen) {
				byName.set(key, peer);
			}
		}
		return Array.from(byName.values());
	}

	// ── Color assignment ──

	#colorForPeer(peerId: string): string {
		let hash = 0;
		for (let i = 0; i < peerId.length; i++) {
			hash = ((hash << 5) - hash + peerId.charCodeAt(i)) | 0;
		}
		return PEER_COLORS[Math.abs(hash) % PEER_COLORS.length];
	}

	// ── Rendering ──

	#render() {
		this.#shadow.innerHTML = `
			<style>${OVERLAY_CSS}</style>
			<div class="collab-badge visible" id="badge"></div>
			<div class="people-panel" id="people-panel">
				<div class="people-panel-header">
					<h3>People</h3>
					<span class="panel-count" id="panel-count">1 online</span>
				</div>
				<div class="people-list" id="people-list"></div>
			</div>
			<div class="collab-cursors" id="cursors"></div>
		`;
		this.#shadow.getElementById('badge')?.addEventListener('click', (e) => {
			e.stopPropagation();
			this.#togglePanel();
		});
	}

	// ── Panel toggle ──

	#togglePanel() {
		this.#panelOpen = !this.#panelOpen;
		const panel = this.#shadow.getElementById('people-panel');
		if (this.#panelOpen) {
			panel?.classList.add('open');
			this.#renderPanel();
		} else {
			panel?.classList.remove('open');
		}
	}

	// ── Panel rendering ──

	#renderPanel() {
		const list = this.#shadow.getElementById('people-list');
		const countEl = this.#shadow.getElementById('panel-count');
		if (!list) return;

		const uniquePeers = this.#uniquePeers();
		const onlineCount = uniquePeers.length + 1;
		if (countEl) countEl.textContent = this.#connState === 'connected' ? `${onlineCount} online` : '\u2014';

		const fragments: string[] = [];

		// Connection state notice
		if (this.#connState === 'offline' || this.#connState === 'reconnecting' || this.#connState === 'connecting') {
			const msg = this.#connState === 'offline'
				? '\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect.'
				: this.#connState === 'connecting' ? 'Connecting to server\u2026' : 'Reconnecting to server\u2026';
			fragments.push(`<div class="conn-notice">${this.#escHtml(msg)}</div>`);
		}

		// Self row with Online/Offline toggle
		const isSolo = this.#soloMode;
		const selfModule = this.#moduleId || '';
		fragments.push(`
			<div class="people-row">
				<span class="dot" style="background:${this.#localColor}"></span>
				<div class="name-block">
					<span class="name">${this.#escHtml(this.#localUsername)} <span class="you-tag">(you)</span></span>
					${selfModule ? `<span class="peer-context">${this.#escHtml(selfModule)}</span>` : ''}
				</div>
				<span class="mode-toggle" title="Toggle your online presence">
					<button class="mode-solo ${isSolo ? '' : 'active'}" data-action="share">Online</button>
					<button class="mode-multi ${isSolo ? 'active' : ''}" data-action="solo">Offline</button>
				</span>
			</div>
		`);

		// Remote peer rows (deduplicated by username)
		const isCanvas = this.#moduleId === 'rspace';
		for (const peer of uniquePeers) {
			const ctxParts: string[] = [];
			if (peer.module) ctxParts.push(peer.module);
			if (peer.context) ctxParts.push(peer.context);
			const ctxStr = ctxParts.join(' · ');
			const localView = this.#effectiveViewId;
			const differentView = !!((localView || peer.viewId) && localView !== peer.viewId);
			fragments.push(`
				<div class="people-row${differentView ? ' different-view' : ''}">
					<span class="dot" style="background:${peer.color}"></span>
					<div class="name-block">
						<span class="name">${this.#escHtml(peer.username)}</span>
						${ctxStr ? `<span class="peer-context">${differentView ? '\u{1F4C4} ' : ''}${this.#escHtml(ctxStr)}</span>` : ''}
					</div>
					${isCanvas ? `<button class="actions-btn" data-pid="${this.#escHtml(peer.peerId)}">&gt;</button>` : ''}
				</div>
			`);
			// Expanded actions for canvas
			if (isCanvas && this.#openActionsId === peer.peerId) {
				fragments.push(`
					<div class="people-actions">
						<button data-action="navigate" data-pid="${this.#escHtml(peer.peerId)}">Navigate to</button>
						<button data-action="ping" data-pid="${this.#escHtml(peer.peerId)}">Ping to join you</button>
					</div>
				`);
			}
		}

		// Offline space members (not currently online)
		if (this.#spaceMembers.length > 0) {
			// Build set of online usernames (case-insensitive) for cross-referencing
			const onlineNames = new Set<string>();
			onlineNames.add(this.#localUsername.toLowerCase());
			for (const peer of this.#peers.values()) {
				onlineNames.add(peer.username.toLowerCase());
			}

			const offlineMembers = this.#spaceMembers.filter(
				m => !onlineNames.has(m.displayName.toLowerCase())
			);

			if (offlineMembers.length > 0) {
				fragments.push(`<div class="section-divider">Offline</div>`);
				for (const m of offlineMembers) {
					fragments.push(`
						<div class="people-row offline">
							<span class="dot"></span>
							<div class="name-block">
								<span class="name">${this.#escHtml(m.displayName)}</span>
								<span class="peer-context">${this.#escHtml(m.role)}</span>
							</div>
						</div>
					`);
				}
			}
		}

		list.innerHTML = fragments.join('');

		// Wire up event delegation on the list
		list.onclick = (e) => {
			const btn = (e.target as HTMLElement).closest('button');
			if (!btn) return;
			const action = btn.dataset.action;
			const pid = btn.dataset.pid;

			if (action === 'solo') {
				this.#setSoloMode(true);
			} else if (action === 'share') {
				this.#setSoloMode(false);
			} else if (btn.classList.contains('actions-btn') && pid) {
				this.#openActionsId = this.#openActionsId === pid ? null : pid;
				this.#renderPanel();
			} else if ((action === 'navigate' || action === 'ping') && pid) {
				this.dispatchEvent(new CustomEvent('collab-peer-action', {
					bubbles: true,
					composed: true,
					detail: { action, peerId: pid },
				}));
			}
		};
	}

	// ── Solo mode ──

	#setSoloMode(solo: boolean) {
		this.#soloMode = solo;
		localStorage.setItem('rspace_solo_mode', solo ? '1' : '0');

		if (solo) {
			// Clear remote peers and their visual artifacts (for non-canvas modules)
			if (!this.#externalPeers) {
				this.#peers.clear();
				if (!this.#badgeOnly) {
					this.#renderCursors();
					this.#renderFocusRings();
				}
			}
		}

		this.#renderBadge();
		if (this.#panelOpen) this.#renderPanel();

		// Notify canvas PresenceManager and any other listeners
		document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo } }));
	}

	#renderBadge() {
		const badge = this.#shadow.getElementById('badge');
		if (!badge) return;

		if (this.#soloMode) {
			badge.innerHTML = `<span class="count solo">\u{1F4F4} Offline</span>`;
			badge.classList.add('visible', 'solo');
			badge.title = 'Offline \u2014 your presence is hidden. Click to go online.';
			return;
		}

		badge.classList.remove('solo');

		if (this.#connState === 'offline') {
			badge.innerHTML = `
				<span class="dot" style="background:#f59e0b"></span>
				<span class="count">Offline</span>
			`;
			badge.classList.add('visible');
			badge.title = 'You\u2019re offline \u2014 changes saved locally.';
			return;
		}

		if (this.#connState === 'reconnecting' || this.#connState === 'connecting') {
			const label = this.#connState === 'connecting' ? 'Connecting\u2026' : 'Reconnecting\u2026';
			badge.innerHTML = `
				<span class="dot" style="background:#3b82f6"></span>
				<span class="count">${label}</span>
			`;
			badge.classList.add('visible');
			badge.title = label;
			return;
		}

		const uniquePeers = this.#uniquePeers();
		const count = uniquePeers.length + 1; // +1 for self

		const dots = uniquePeers
			.slice(0, 5) // show max 5 dots
			.map(p => `<span class="dot" style="background:${p.color}"></span>`)
			.join('');

		badge.innerHTML = `
			<span class="dot" style="background:${this.#localColor}"></span>
			${dots}
			<span class="count">\u{1F465} ${count} online</span>
		`;
		badge.classList.add('visible');
		badge.title = `${count} online \u2014 click to see people`;
	}

	#renderCursors() {
		const container = this.#shadow.getElementById('cursors');
		if (!container) return;

		const now = Date.now();
		const fragments: string[] = [];

		// Deduplicate by username — only show the most recent cursor per user
		const localView = this.#effectiveViewId;
		for (const peer of this.#uniquePeers()) {
			if (!peer.cursor) continue;
			// Only show cursors for peers on the same page/view
			if ((localView || peer.viewId) && localView !== peer.viewId) continue;
			const age = now - peer.lastSeen;
			const opacity = age > 5000 ? 0.3 : 1;

			const sx = peer.cursor.x - window.scrollX;
			const sy = peer.cursor.y - window.scrollY;
			fragments.push(`
				<div class="cursor" style="left:${sx}px;top:${sy}px;opacity:${opacity}">
					<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
						<path d="M1 1L6.5 18L8.5 11L15 9L1 1Z" fill="${peer.color}" stroke="white" stroke-width="1.5"/>
					</svg>
					<span class="cursor-label" style="background:${peer.color}">${this.#escHtml(peer.username)}</span>
				</div>
			`);
		}

		container.innerHTML = fragments.join('');
	}

	/** Find a data-collab-id element, searching into shadow roots. */
	#findCollabEl(id: string): Element | null {
		const sel = `[data-collab-id="${CSS.escape(id)}"]`;
		const found = document.querySelector(sel);
		if (found) return found;
		// Walk into shadow roots (one level deep — rApp components)
		for (const el of document.querySelectorAll('*')) {
			if (el.shadowRoot) {
				const inner = el.shadowRoot.querySelector(sel);
				if (inner) return inner;
			}
		}
		return null;
	}

	#renderFocusRings() {
		// Remove all existing focus rings from the document
		document.querySelectorAll('.rstack-collab-focus-ring').forEach(el => el.remove());

		const localView = this.#effectiveViewId;
		for (const peer of this.#uniquePeers()) {
			if (!peer.selection) continue;
			// Only show focus rings for peers on the same page/view
			if ((localView || peer.viewId) && localView !== peer.viewId) continue;
			const target = this.#findCollabEl(peer.selection);
			if (!target) continue;

			const rect = target.getBoundingClientRect();
			const ring = document.createElement('div');
			ring.className = 'rstack-collab-focus-ring';
			ring.style.cssText = `
				position: fixed;
				left: ${rect.left - 3}px;
				top: ${rect.top - 3}px;
				width: ${rect.width + 6}px;
				height: ${rect.height + 6}px;
				border: 2px solid ${peer.color};
				border-radius: 6px;
				pointer-events: none;
				z-index: 9998;
				box-shadow: 0 0 0 1px ${peer.color}33;
				transition: all 0.15s ease;
			`;

			// Username label on the ring
			const label = document.createElement('span');
			label.textContent = peer.username;
			label.style.cssText = `
				position: absolute;
				top: -18px;
				left: 4px;
				font-size: 10px;
				color: white;
				background: ${peer.color};
				padding: 1px 5px;
				border-radius: 3px;
				white-space: nowrap;
				line-height: 14px;
			`;
			ring.appendChild(label);

			document.body.appendChild(ring);
		}
	}

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

	static define() {
		if (!customElements.get('rstack-collab-overlay')) {
			customElements.define('rstack-collab-overlay', RStackCollabOverlay);
		}
	}
}

// ── Styles (inside shadow DOM) ──

const OVERLAY_CSS = `
	:host {
		display: inline-flex;
		align-items: center;
		pointer-events: none;
		position: relative;
	}

	.collab-badge {
		display: none;
		align-items: center;
		gap: 4px;
		padding: 4px 10px 4px 6px;
		border-radius: 16px;
		background: var(--rs-glass-bg, rgba(15, 23, 42, 0.85));
		-webkit-backdrop-filter: blur(8px);
		backdrop-filter: blur(8px);
		font-size: 11px;
		color: var(--rs-text-secondary, #94a3b8);
		pointer-events: auto;
		cursor: pointer;
		user-select: none;
		border: 1px solid var(--rs-glass-border, rgba(255,255,255,0.08));
		transition: opacity 0.2s, border-color 0.2s;
		white-space: nowrap;
	}

	.collab-badge:hover, .collab-badge:active {
		border-color: var(--rs-border-strong, rgba(255,255,255,0.2));
	}

	.collab-badge.visible {
		display: inline-flex;
	}

	.collab-badge.solo {
		opacity: 0.7;
	}

	.count.solo {
		color: var(--rs-text-muted, #64748b);
	}

	.dot {
		width: 7px;
		height: 7px;
		border-radius: 50%;
		flex-shrink: 0;
	}

	.count {
		margin-left: 2px;
		font-weight: 500;
	}

	/* ── People panel dropdown ── */

	.people-panel {
		display: none;
		position: absolute;
		top: calc(100% + 4px);
		right: 0;
		width: 280px;
		max-height: calc(100vh - 120px);
		background: var(--rs-bg-surface, #1e1e1e);
		border-radius: 12px;
		box-shadow: var(--rs-shadow-lg, 0 8px 30px rgba(0,0,0,0.4)), 0 0 0 1px var(--rs-glass-border, rgba(255,255,255,0.08));
		z-index: 10001;
		overflow: hidden;
		pointer-events: auto;
		flex-direction: column;
	}

	.people-panel.open {
		display: flex;
	}

	.people-panel-header {
		padding: 12px 16px;
		border-bottom: 1px solid var(--rs-border-subtle, rgba(0,0,0,0.06));
		display: flex;
		align-items: center;
		justify-content: space-between;
		flex-shrink: 0;
	}

	.people-panel-header h3 {
		font-size: 14px;
		color: var(--rs-text-primary, #0f172a);
		margin: 0;
		font-weight: 600;
	}

	.panel-count {
		font-size: 12px;
		color: var(--rs-text-secondary, #374151);
		background: var(--rs-bg-surface-raised, #f0efe9);
		padding: 2px 8px;
		border-radius: 10px;
	}

	.people-list {
		overflow-y: auto;
		touch-action: pan-y;
		flex: 1;
		padding: 8px;
	}

	.conn-notice {
		padding: 8px 16px;
		font-size: 12px;
		color: var(--rs-text-muted, #64748b);
		background: var(--rs-bg-surface-raised, #f0efe9);
		border-bottom: 1px solid var(--rs-border-subtle, rgba(0,0,0,0.06));
	}

	.people-row {
		display: flex;
		align-items: center;
		gap: 10px;
		padding: 8px 10px;
		border-radius: 8px;
		transition: background 0.15s;
	}

	.people-row:hover, .people-row:active {
		background: var(--rs-bg-hover, rgba(0,0,0,0.04));
	}

	.people-row .dot {
		width: 10px;
		height: 10px;
	}

	.people-row .name-block {
		flex: 1;
		min-width: 0;
		display: flex;
		flex-direction: column;
	}

	.people-row .name {
		font-size: 13px;
		color: var(--rs-text-primary, #0f172a);
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}

	.peer-context {
		font-size: 11px;
		color: var(--rs-text-muted, #64748b);
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
		line-height: 1.2;
	}

	.you-tag {
		font-size: 11px;
		color: #94a3b8;
		font-weight: normal;
	}

	/* ── Offline members ── */

	.section-divider {
		font-size: 10px;
		font-weight: 600;
		text-transform: uppercase;
		letter-spacing: 0.05em;
		color: var(--rs-text-muted, #64748b);
		padding: 8px 10px 4px;
		margin-top: 4px;
	}

	.people-row.offline .dot {
		background: #6b7280 !important;
	}

	.people-row.offline .name {
		color: var(--rs-text-muted, #64748b);
	}

	.people-row.different-view {
		opacity: 0.65;
	}

	/* ── Mode toggle (Online/Offline) ── */

	.mode-toggle {
		display: flex;
		align-items: center;
		gap: 0;
		border: 1px solid var(--rs-border, rgba(0,0,0,0.1));
		border-radius: 6px;
		overflow: hidden;
		flex-shrink: 0;
	}

	.mode-toggle button {
		padding: 3px 8px;
		border: none;
		background: none;
		font-size: 11px;
		color: var(--rs-text-muted, #64748b);
		cursor: pointer;
		transition: background 0.15s, color 0.15s;
		white-space: nowrap;
	}

	.mode-toggle button.active {
		background: var(--rs-accent, #14b8a6);
		color: #fff;
	}

	.mode-toggle button:not(.active):hover {
		background: var(--rs-bg-hover, rgba(0,0,0,0.04));
	}

	/* ── Peer action buttons (canvas only) ── */

	.actions-btn {
		padding: 2px 8px;
		border: 1px solid var(--rs-border, rgba(0,0,0,0.1));
		border-radius: 6px;
		background: var(--rs-bg-surface-raised, #334155);
		cursor: pointer;
		font-size: 12px;
		color: var(--rs-text-muted, #64748b);
		transition: background 0.15s;
	}

	.actions-btn:hover {
		background: var(--rs-bg-hover, rgba(0,0,0,0.04));
	}

	.people-actions {
		padding: 4px 8px 8px 30px;
	}

	.people-actions button {
		display: block;
		width: 100%;
		padding: 6px 10px;
		border: none;
		background: none;
		text-align: left;
		font-size: 12px;
		color: var(--rs-text-secondary, #374151);
		cursor: pointer;
		border-radius: 6px;
		transition: background 0.15s;
	}

	.people-actions button:hover {
		background: var(--rs-bg-hover, rgba(0,0,0,0.04));
	}

	/* ── Cursors ── */

	.collab-cursors {
		position: fixed;
		top: 0;
		left: 0;
		width: 100vw;
		height: 100vh;
		pointer-events: none;
		z-index: 9999;
		overflow: hidden;
	}

	.cursor {
		position: fixed;
		pointer-events: none;
		transition: left 0.1s linear, top 0.1s linear, opacity 0.3s ease;
		z-index: 9999;
	}

	.cursor-label {
		position: absolute;
		left: 14px;
		top: 14px;
		font-size: 10px;
		color: white;
		padding: 1px 5px;
		border-radius: 3px;
		white-space: nowrap;
		line-height: 14px;
		font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
	}

	/* ── Mobile ── */

	@media (max-width: 640px) {
		.count {
			display: none;
		}
		.people-panel {
			max-width: calc(100vw - 32px);
		}
	}
`;
