/**
 * folk-holon-explorer — Zoomable hexagonal hierarchy navigator.
 *
 * Dual navigation:
 *   - H3 Mode: Navigate H3 resolution levels via h3-js hierarchy
 *   - Space Mode: Navigate nested rSpace spaces via /api/spaces/:slug/nest
 *
 * Features:
 *   - Circular SVG layout (Holons/Zircle arc placement)
 *   - MetatronGrid sacred geometry background
 *   - Appreciation weight normalization (sum ≤ 100%)
 *   - Endorsement logging to trust engine on weight save
 */

import * as h3 from 'h3-js';
import { FolkShape } from './folk-shape';
import { css, html } from './tags';
import { getHolonHierarchy, getResolutionName } from './holon-service';

// ── Types ──

interface ExplorerNode {
	id: string;
	label: string;
	weight: number;
	ownerDID?: string;
}

type NavMode = 'h3' | 'space';
type ViewMode = 'holon' | 'graph';

// ── Appreciation normalization (from Holons project) ──

function normalize(values: number[], changedIndex: number): number[] {
	const total = values.reduce((s, v) => s + v, 0);
	if (total <= 100) return values;
	const excess = total - 100;
	const othersSum = total - values[changedIndex];
	if (othersSum === 0) return values.map((v, i) => i === changedIndex ? 100 : 0);
	return values.map((v, i) =>
		i === changedIndex ? v : Math.max(0, v - excess * v / othersSum)
	);
}

// ── Hex polygon helper ──

function hexPoly(cx: number, cy: number, r: number): string {
	const pts: string[] = [];
	for (let i = 0; i < 6; i++) {
		const a = (Math.PI / 3) * i - Math.PI / 2;
		pts.push(`${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`);
	}
	return pts.join(' ');
}

// ── MetatronGrid SVG ──

function metatronGrid(size: number): string {
	const cx = size / 2, cy = size / 2, r = size * 0.42, ri = r * 0.5;
	const hex = (radius: number) => {
		const pts: string[] = [];
		for (let i = 0; i < 6; i++) {
			const a = (Math.PI / 3) * i - Math.PI / 2;
			pts.push(`${cx + radius * Math.cos(a)},${cy + radius * Math.sin(a)}`);
		}
		return pts.join(' ');
	};
	// Star of David: two overlapping triangles
	const tri = (offset: number) => {
		const pts: string[] = [];
		for (let i = 0; i < 3; i++) {
			const a = (2 * Math.PI / 3) * i + offset - Math.PI / 2;
			pts.push(`${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`);
		}
		return pts.join(' ');
	};
	return `<polygon points="${hex(r)}" fill="none" stroke="currentColor" opacity="0.12"/>
<polygon points="${hex(ri)}" fill="none" stroke="currentColor" opacity="0.08"/>
<polygon points="${tri(0)}" fill="none" stroke="currentColor" opacity="0.1"/>
<polygon points="${tri(Math.PI / 3)}" fill="none" stroke="currentColor" opacity="0.1"/>`;
}

// ── Styles ──

const styles = css`
	:host {
		background: #0f172a;
		color: #e2e8f0;
		border-radius: 8px;
		box-shadow: 0 2px 12px rgba(0,0,0,0.3);
		min-width: 420px;
		min-height: 400px;
	}
	.header {
		display: flex; align-items: center; justify-content: space-between;
		padding: 8px 12px; background: #0e7490; color: white;
		border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move;
	}
	.header-left { display: flex; align-items: center; gap: 6px; min-width: 0; }
	.header-left span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
	.header-right { display: flex; gap: 4px; flex-shrink: 0; }
	.header-right button, .mode-toggle {
		background: transparent; border: none; color: white; cursor: pointer;
		padding: 2px 6px; border-radius: 4px; font-size: 12px;
	}
	.header-right button:hover, .mode-toggle:hover { background: rgba(255,255,255,0.2); }
	.mode-toggle.active { background: rgba(255,255,255,0.25); font-weight: 700; }
	.breadcrumb {
		display: flex; gap: 4px; padding: 6px 12px; font-size: 11px;
		color: #94a3b8; background: #1e293b; flex-wrap: wrap; align-items: center;
	}
	.breadcrumb span { cursor: pointer; }
	.breadcrumb span:hover { color: #06b6d4; text-decoration: underline; }
	.breadcrumb .sep { color: #475569; cursor: default; }
	.breadcrumb .sep:hover { color: #475569; text-decoration: none; }
	.breadcrumb .current { color: #e2e8f0; font-weight: 600; cursor: default; }
	.breadcrumb .current:hover { color: #e2e8f0; text-decoration: none; }
	.view-strip {
		display: flex; background: #1e293b; border-bottom: 1px solid rgba(255,255,255,0.08);
	}
	.view-btn {
		flex: 1; font-size: 11px; color: #64748b; background: transparent; border: none;
		padding: 6px 0; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s;
	}
	.view-btn:hover { color: #94a3b8; }
	.view-btn.active { color: #10b981; border-bottom-color: #10b981; background: rgba(16,185,129,0.07); }
	.content {
		display: flex; flex-direction: column; overflow: hidden;
		height: calc(100% - 94px); /* header + breadcrumb + view-strip */
	}
	.svg-wrap { flex: 1; min-height: 0; position: relative; }
	.svg-wrap svg { width: 100%; height: 100%; }
	.slider-panel {
		padding: 8px 12px; max-height: 140px; overflow-y: auto;
		border-top: 1px solid rgba(255,255,255,0.08);
	}
	.slider-row {
		display: flex; align-items: center; gap: 8px; font-size: 11px; margin-bottom: 4px;
	}
	.slider-row label { width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #94a3b8; }
	.slider-row input[type=range] { flex: 1; accent-color: #10b981; }
	.slider-row .val { width: 30px; text-align: right; color: #10b981; font-weight: 600; }
	.sum-row { font-size: 11px; color: #64748b; text-align: right; padding: 2px 0; }
	.connect-form {
		display: flex; flex-direction: column; align-items: center;
		justify-content: center; height: 100%; gap: 12px; padding: 16px; text-align: center;
	}
	.connect-form input {
		padding: 8px 12px; border: 1px solid rgba(255,255,255,0.1);
		border-radius: 6px; font-size: 13px; font-family: monospace;
		background: #1e293b; color: #e2e8f0; outline: none; width: 100%; max-width: 320px;
	}
	.connect-form input:focus { border-color: #06b6d4; box-shadow: 0 0 0 2px rgba(6,182,212,0.2); }
	.connect-form button {
		padding: 8px 20px; background: #0e7490; color: white; border: none;
		border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
	}
	.connect-form button:hover { background: #0891b2; }
	.hint { font-size: 12px; color: #64748b; }
	.empty { display: flex; align-items: center; justify-content: center; height: 100%; color: #64748b; font-size: 13px; }
	.more-label { font-size: 10px; fill: #64748b; }
`;

declare global {
	interface HTMLElementTagNameMap {
		'folk-holon-explorer': FolkHolonExplorer;
	}
}

export class FolkHolonExplorer extends FolkShape {
	static override tagName = 'folk-holon-explorer';

	static {
		const sheet = new CSSStyleSheet();
		const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join('\n');
		const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join('\n');
		sheet.replaceSync(`${parentRules}\n${childRules}`);
		this.styles = sheet;
	}

	// ── State ──
	#mode: NavMode = 'space';
	#view: ViewMode = 'holon';
	#rootId = '';
	#spaceSlug = '';
	#breadcrumb: { id: string; label: string }[] = [];
	#children: ExplorerNode[] = [];
	#loading = false;
	#contentEl: HTMLElement | null = null;
	#saveTimer: ReturnType<typeof setTimeout> | null = null;

	override createRenderRoot() {
		const root = super.createRenderRoot();

		this.#mode = (this.getAttribute('mode') as NavMode) || 'space';
		this.#view = (this.getAttribute('view') as ViewMode) || 'holon';
		this.#rootId = this.getAttribute('root-id') || '';
		this.#spaceSlug = this.getAttribute('space-slug') || (window as any).__rspaceSpace || '';

		const wrapper = document.createElement('div');
		wrapper.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;';
		wrapper.innerHTML = html`
			<div class="header">
				<div class="header-left">
					<span>🔮</span>
					<span>Holon Explorer</span>
				</div>
				<div class="header-right">
					<button class="mode-toggle ${this.#mode === 'space' ? 'active' : ''}" data-mode="space" title="Space Mode">Spaces</button>
					<button class="mode-toggle ${this.#mode === 'h3' ? 'active' : ''}" data-mode="h3" title="H3 Hex Mode">H3</button>
					<button class="close-btn" title="Close">×</button>
				</div>
			</div>
			<div class="breadcrumb"></div>
			<div class="view-strip">
				<button class="view-btn ${this.#view === 'holon' ? 'active' : ''}" data-view="holon">⬤ Holon</button>
				<button class="view-btn ${this.#view === 'graph' ? 'active' : ''}" data-view="graph">⬡ Graph</button>
			</div>
			<div class="content"></div>
		`;

		const slot = root.querySelector('slot');
		const containerDiv = slot?.parentElement as HTMLElement;
		if (containerDiv) containerDiv.replaceWith(wrapper);

		this.#contentEl = wrapper.querySelector('.content');

		// Wire mode toggles
		wrapper.querySelectorAll('.mode-toggle').forEach((btn) => {
			btn.addEventListener('click', (e) => {
				e.stopPropagation();
				const m = (btn as HTMLElement).dataset.mode as NavMode;
				if (m !== this.#mode) {
					this.#mode = m;
					this.#breadcrumb = [];
					this.#children = [];
					wrapper.querySelectorAll('.mode-toggle').forEach((b) =>
						b.classList.toggle('active', (b as HTMLElement).dataset.mode === m));
					this.#render();
				}
			});
		});

		// Wire view toggles
		wrapper.querySelectorAll('.view-btn').forEach((btn) => {
			btn.addEventListener('click', (e) => {
				e.stopPropagation();
				const v = (btn as HTMLElement).dataset.view as ViewMode;
				if (v !== this.#view) {
					this.#view = v;
					wrapper.querySelectorAll('.view-btn').forEach((b) =>
						b.classList.toggle('active', (b as HTMLElement).dataset.view === v));
					this.#render();
				}
			});
		});

		wrapper.querySelector('.close-btn')!.addEventListener('click', (e) => {
			e.stopPropagation();
			this.dispatchEvent(new CustomEvent('close'));
		});

		// Auto-load
		if (this.#mode === 'space' && this.#spaceSlug) {
			this.#breadcrumb = [{ id: this.#spaceSlug, label: this.#spaceSlug }];
			this.#loadSpaceChildren(this.#spaceSlug);
		} else if (this.#mode === 'h3' && this.#rootId) {
			this.#breadcrumb = [{ id: this.#rootId, label: getResolutionName(h3.getResolution(this.#rootId)) }];
			this.#loadH3Children(this.#rootId);
		}

		this.#render();
		return root;
	}

	disconnectedCallback() {
		if (this.#saveTimer) clearTimeout(this.#saveTimer);
	}

	// ── Data loading ──

	async #loadSpaceChildren(slug: string) {
		this.#loading = true;
		this.#render();
		try {
			const res = await fetch(`/api/spaces/${encodeURIComponent(slug)}/nest`, { signal: AbortSignal.timeout(5000) });
			if (!res.ok) { this.#children = []; this.#loading = false; this.#render(); return; }
			const data = await res.json();
			const nested: any[] = data.nestedSpaces || [];
			this.#children = nested.map((s: any) => ({
				id: s.slug || s.id,
				label: s.name || s.slug || s.id,
				weight: 100 / Math.max(nested.length, 1),
				ownerDID: s.ownerDID,
			}));
			this.#loadSavedWeights();
		} catch { this.#children = []; }
		this.#loading = false;
		this.#render();
	}

	#loadH3Children(cellId: string) {
		this.#loading = true;
		this.#render();
		try {
			const { children } = getHolonHierarchy(cellId);
			// H3 can produce many children — show first 12
			const shown = children.slice(0, 12);
			this.#children = shown.map((c) => ({
				id: c,
				label: `${getResolutionName(h3.getResolution(c))} ${c.slice(-6)}`,
				weight: 100 / Math.max(shown.length, 1),
			}));
			this.#loadSavedWeights();
		} catch { this.#children = []; }
		this.#loading = false;
		this.#render();
	}

	#drillInto(node: ExplorerNode) {
		this.#breadcrumb.push({ id: node.id, label: node.label });
		this.#children = [];
		if (this.#mode === 'space') {
			this.#loadSpaceChildren(node.id);
		} else {
			this.#loadH3Children(node.id);
		}
	}

	#navigateTo(index: number) {
		if (index >= this.#breadcrumb.length - 1) return;
		const target = this.#breadcrumb[index];
		this.#breadcrumb = this.#breadcrumb.slice(0, index + 1);
		this.#children = [];
		if (this.#mode === 'space') {
			this.#loadSpaceChildren(target.id);
		} else {
			this.#loadH3Children(target.id);
		}
	}

	// ── Appreciation persistence ──

	#weightKey(): string {
		const current = this.#breadcrumb[this.#breadcrumb.length - 1];
		return current ? `holon-explorer:weights:${current.id}` : '';
	}

	#loadSavedWeights() {
		const key = this.#weightKey();
		if (!key) return;
		try {
			const raw = localStorage.getItem(key);
			if (!raw) return;
			const saved: Record<string, number> = JSON.parse(raw);
			for (const child of this.#children) {
				if (saved[child.id] != null) child.weight = saved[child.id];
			}
		} catch { /* ignore */ }
	}

	#saveWeights() {
		const key = this.#weightKey();
		if (!key) return;
		const map: Record<string, number> = {};
		for (const c of this.#children) map[c.id] = c.weight;
		try { localStorage.setItem(key, JSON.stringify(map)); } catch { /* ignore */ }
	}

	#scheduleEndorse() {
		if (this.#saveTimer) clearTimeout(this.#saveTimer);
		this.#saveTimer = setTimeout(() => this.#fireEndorsements(), 1500);
	}

	async #fireEndorsements() {
		const space = this.#spaceSlug || (window as any).__rspaceSpace;
		const token = (window as any).__rspaceAuthToken;
		if (!space || !token) return;

		for (const child of this.#children) {
			if (!child.ownerDID) continue;
			try {
				await fetch(`/${encodeURIComponent(space)}/rnetwork/api/endorse`, {
					method: 'POST',
					headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
					body: JSON.stringify({
						targetDid: child.ownerDID,
						authority: 'gov-ops',
						weight: child.weight / 100,
						space,
					}),
				});
			} catch { /* silent */ }
		}
	}

	// ── Rendering ──

	#render() {
		if (!this.#contentEl) return;

		// Breadcrumb
		const bc = this.renderRoot.querySelector('.breadcrumb') as HTMLElement;
		if (bc) {
			if (this.#breadcrumb.length === 0) {
				bc.innerHTML = '';
			} else {
				bc.innerHTML = this.#breadcrumb.map((b, i) => {
					const isLast = i === this.#breadcrumb.length - 1;
					const sep = i > 0 ? '<span class="sep">›</span>' : '';
					return isLast
						? `${sep}<span class="current">${esc(b.label)}</span>`
						: `${sep}<span data-idx="${i}">${esc(b.label)}</span>`;
				}).join('');
				bc.querySelectorAll('span[data-idx]').forEach((el) => {
					el.addEventListener('click', (e) => {
						e.stopPropagation();
						this.#navigateTo(parseInt((el as HTMLElement).dataset.idx!, 10));
					});
				});
			}
		}

		// Content
		if (this.#mode === 'h3' && this.#breadcrumb.length === 0) {
			this.#renderH3Form();
		} else if (this.#loading) {
			this.#contentEl.innerHTML = '<div class="empty">Loading...</div>';
		} else if (this.#children.length === 0 && this.#breadcrumb.length > 0) {
			this.#contentEl.innerHTML = '<div class="empty">No children found</div>';
		} else if (this.#children.length === 0) {
			this.#contentEl.innerHTML = '<div class="empty">Enter a space or H3 cell to explore</div>';
		} else {
			this.#renderExplorer();
		}
	}

	#renderH3Form() {
		this.#contentEl!.innerHTML = html`
			<div class="connect-form">
				<div style="font-size:36px">⬡</div>
				<div style="font-weight:600">H3 Hex Explorer</div>
				<div class="hint">Enter an H3 cell ID to explore its hierarchy</div>
				<input type="text" class="h3-input" placeholder="e.g. 872a1070bffffff" />
				<button class="go-btn">Explore</button>
			</div>
		`;
		const input = this.#contentEl!.querySelector('.h3-input') as HTMLInputElement;
		const btn = this.#contentEl!.querySelector('.go-btn') as HTMLButtonElement;
		const go = () => {
			const val = input.value.trim();
			if (!val) return;
			try {
				if (!h3.isValidCell(val)) return;
				this.#rootId = val;
				this.#breadcrumb = [{ id: val, label: getResolutionName(h3.getResolution(val)) }];
				this.#loadH3Children(val);
			} catch { /* invalid */ }
		};
		input.addEventListener('pointerdown', (e) => e.stopPropagation());
		input.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') go(); });
		btn.addEventListener('click', (e) => { e.stopPropagation(); go(); });
	}

	#renderExplorer() {
		const MAX_VISIBLE = 12;
		const visible = this.#children.slice(0, MAX_VISIBLE);
		const overflow = this.#children.length > MAX_VISIBLE ? this.#children.length - MAX_VISIBLE : 0;

		// Build SVG based on active view
		const W = 400, H = 300;
		const svgContent = this.#view === 'graph'
			? this.#buildGraphSvg(visible, overflow, W, H)
			: this.#buildHolonSvg(visible, overflow, W, H);

		// Slider panel
		const sliders = visible.map((child, i) => `
			<div class="slider-row">
				<label title="${esc(child.label)}">${esc(child.label)}</label>
				<input type="range" min="0" max="100" value="${Math.round(child.weight)}" data-idx="${i}" />
				<span class="val">${Math.round(child.weight)}</span>
			</div>
		`).join('');

		const sum = this.#children.reduce((s, c) => s + c.weight, 0);

		this.#contentEl!.innerHTML = html`
			<div class="svg-wrap">
				<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
					${svgContent}
				</svg>
			</div>
			<div class="slider-panel">
				${sliders}
				<div class="sum-row">Total: ${Math.round(sum)}%</div>
			</div>
		`;

		// Wire node clicks
		this.#contentEl!.querySelectorAll('.node').forEach((g) => {
			g.addEventListener('click', (e) => {
				e.stopPropagation();
				const idx = parseInt((g as HTMLElement).dataset.idx!, 10);
				if (idx >= 0 && idx < visible.length) this.#drillInto(visible[idx]);
			});
		});

		// Wire sliders
		this.#contentEl!.querySelectorAll('input[type=range]').forEach((el) => {
			el.addEventListener('pointerdown', (e) => e.stopPropagation());
			el.addEventListener('input', (e) => {
				e.stopPropagation();
				const idx = parseInt((el as HTMLElement).dataset.idx!, 10);
				const val = parseFloat((el as HTMLInputElement).value);
				const weights = this.#children.map((c) => c.weight);
				weights[idx] = val;
				const normalized = normalize(weights, idx);
				for (let i = 0; i < this.#children.length; i++) this.#children[i].weight = normalized[i];

				// Update slider UI without full re-render
				this.#contentEl!.querySelectorAll('.slider-row').forEach((row, ri) => {
					const inp = row.querySelector('input') as HTMLInputElement;
					const span = row.querySelector('.val') as HTMLElement;
					if (inp && span && ri < this.#children.length) {
						inp.value = String(Math.round(this.#children[ri].weight));
						span.textContent = String(Math.round(this.#children[ri].weight));
					}
				});
				const sumEl = this.#contentEl!.querySelector('.sum-row');
				if (sumEl) sumEl.textContent = `Total: ${Math.round(this.#children.reduce((s, c) => s + c.weight, 0))}%`;

				this.#saveWeights();
				this.#scheduleEndorse();
			});
		});
	}

	#buildHolonSvg(visible: ExplorerNode[], overflow: number, W: number, H: number): string {
		const cx = W / 2, cy = H / 2;
		let orbit = Math.min(W, H) * 0.35;
		if (this.#children.length > 8) orbit *= 0.85;

		const gridSvg = metatronGrid(Math.min(W, H));
		const linesSvg: string[] = [];
		const nodesSvg: string[] = [];

		for (let i = 0; i < visible.length; i++) {
			const child = visible[i];
			const angle = -110 + (220 / (visible.length + 1)) * (i + 1);
			const rad = (angle - 90) * Math.PI / 180;
			const nx = cx + orbit * Math.cos(rad);
			const ny = cy + orbit * Math.sin(rad);
			const r = Math.min(36, Math.max(16, 16 + child.weight * 0.3));

			linesSvg.push(
				`<line x1="${cx}" y1="${cy}" x2="${nx}" y2="${ny}" stroke="#10b981" stroke-opacity="0.3" stroke-width="${1 + child.weight * 0.03}"/>`
			);

			const fill = `rgba(16,185,129,${(0.1 + child.weight * 0.005).toFixed(3)})`;
			nodesSvg.push(`
				<g class="node" data-idx="${i}" style="cursor:pointer">
					<circle cx="${nx}" cy="${ny}" r="${r}" fill="${fill}" stroke="#10b981" stroke-width="1.5"/>
					<text x="${nx}" y="${ny + r + 12}" text-anchor="middle" fill="#94a3b8" font-size="9">${esc(child.label.length > 14 ? child.label.slice(0, 12) + '…' : child.label)}</text>
				</g>
			`);
		}

		const centerLabel = esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?');
		const centerSvg = `
			<circle cx="${cx}" cy="${cy}" r="24" fill="rgba(6,182,212,0.2)" stroke="#06b6d4" stroke-width="2"/>
			<text x="${cx}" y="${cy + 4}" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">${centerLabel}</text>
		`;

		const overflowSvg = overflow > 0
			? `<text x="${cx}" y="${cy + orbit + 30}" text-anchor="middle" class="more-label">+${overflow} more</text>`
			: '';

		return `${gridSvg}${linesSvg.join('')}${centerSvg}${nodesSvg.join('')}${overflowSvg}`;
	}

	#buildGraphSvg(visible: ExplorerNode[], overflow: number, W: number, H: number): string {
		const cx = W / 2, cy = H / 2;
		const orbit = Math.min(W, H) * 0.32;

		const gridSvg = metatronGrid(Math.min(W, H));
		const linesSvg: string[] = [];
		const nodesSvg: string[] = [];
		const N = visible.length;

		for (let i = 0; i < N; i++) {
			const child = visible[i];
			const angleDeg = (360 / N) * i - 90;
			const rad = angleDeg * Math.PI / 180;
			const nx = cx + orbit * Math.cos(rad);
			const ny = cy + orbit * Math.sin(rad);
			const hr = Math.min(24, Math.max(12, 12 + child.weight * 0.2));

			linesSvg.push(
				`<line x1="${cx}" y1="${cy}" x2="${nx}" y2="${ny}" stroke="#10b981" stroke-opacity="0.5" stroke-width="${1 + child.weight * 0.03}"/>`
			);

			const fill = `rgba(16,185,129,${(0.1 + child.weight * 0.005).toFixed(3)})`;
			const label = esc(child.label.length > 14 ? child.label.slice(0, 12) + '…' : child.label);

			// Radial label placement
			const labelDist = hr + 12;
			const lx = nx + labelDist * Math.cos(rad);
			const ly = ny + labelDist * Math.sin(rad);
			const normDeg = ((angleDeg % 360) + 360) % 360;
			const anchor = normDeg > 90 && normDeg < 270 ? 'end' : normDeg === 90 || normDeg === 270 ? 'middle' : 'start';

			nodesSvg.push(`
				<g class="node" data-idx="${i}" style="cursor:pointer">
					<polygon points="${hexPoly(nx, ny, hr)}" fill="${fill}" stroke="#10b981" stroke-width="1.5"/>
					<text x="${lx}" y="${ly + 3}" text-anchor="${anchor}" fill="#94a3b8" font-size="9">${label}</text>
				</g>
			`);
		}

		const centerLabel = esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?');
		const centerSvg = `
			<circle cx="${cx}" cy="${cy}" r="24" fill="rgba(6,182,212,0.2)" stroke="#06b6d4" stroke-width="2"/>
			<text x="${cx}" y="${cy + 4}" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">${centerLabel}</text>
		`;

		const overflowSvg = overflow > 0
			? `<text x="${cx}" y="${H - 10}" text-anchor="middle" class="more-label">+${overflow} more</text>`
			: '';

		return `${gridSvg}${linesSvg.join('')}${centerSvg}${nodesSvg.join('')}${overflowSvg}`;
	}

	// ── Serialization ──

	override toJSON() {
		return {
			...super.toJSON(),
			type: 'folk-holon-explorer',
			mode: this.#mode,
			view: this.#view,
			rootId: this.#rootId,
			spaceSlug: this.#spaceSlug,
			breadcrumb: this.#breadcrumb,
		};
	}

	static override fromData(data: Record<string, any>): FolkHolonExplorer {
		const shape = FolkShape.fromData(data) as FolkHolonExplorer;
		if (data.mode) shape.setAttribute('mode', data.mode);
		if (data.view) shape.setAttribute('view', data.view);
		if (data.rootId) shape.setAttribute('root-id', data.rootId);
		if (data.spaceSlug) shape.setAttribute('space-slug', data.spaceSlug);
		return shape;
	}

	override applyData(data: Record<string, any>): void {
		super.applyData(data);
		const modeChanged = data.mode && data.mode !== this.#mode;
		const rootChanged = data.rootId && data.rootId !== this.#rootId;
		const spaceChanged = data.spaceSlug && data.spaceSlug !== this.#spaceSlug;

		if (data.mode) this.#mode = data.mode;
		if (data.view) this.#view = data.view;
		if (data.rootId) this.#rootId = data.rootId;
		if (data.spaceSlug) this.#spaceSlug = data.spaceSlug;
		if (data.breadcrumb) this.#breadcrumb = data.breadcrumb;

		if (modeChanged || rootChanged || spaceChanged) {
			if (this.#mode === 'space' && this.#spaceSlug) {
				if (this.#breadcrumb.length === 0)
					this.#breadcrumb = [{ id: this.#spaceSlug, label: this.#spaceSlug }];
				this.#loadSpaceChildren(this.#breadcrumb[this.#breadcrumb.length - 1].id);
			} else if (this.#mode === 'h3' && this.#rootId) {
				if (this.#breadcrumb.length === 0)
					this.#breadcrumb = [{ id: this.#rootId, label: getResolutionName(h3.getResolution(this.#rootId)) }];
				this.#loadH3Children(this.#breadcrumb[this.#breadcrumb.length - 1].id);
			}
		}
		this.#render();
	}
}

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