/**
 * <folk-trust-sankey> — Sankey diagram of delegation trust flows.
 *
 * Left column: delegators. Right column: delegates.
 * Bezier curves between them, width proportional to delegation weight.
 * Animated flow particles, authority filter, time slider for history playback,
 * and per-flow trend sparklines.
 */

interface DelegationFlow {
	id: string;
	fromDid: string;
	fromName: string;
	toDid: string;
	toName: string;
	authority: string;
	weight: number;
	state: string;
	createdAt: number;
	revokedAt: number | null;
}

interface TrustEvent {
	id: string;
	sourceDid: string;
	targetDid: string;
	eventType: string;
	authority: string | null;
	weightDelta: number | null;
	createdAt: number;
}

const SANKEY_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
const SANKEY_AUTHORITY_DISPLAY: Record<string, { label: string; color: string }> = {
	"gov-ops": { label: "Gov", color: "#a78bfa" },
	"fin-ops": { label: "Econ", color: "#10b981" },
	"dev-ops": { label: "Tech", color: "#3b82f6" },
};
// Demo DAO members
const DEMO_MEMBERS = [
	{ did: "demo:alice",  name: "Alice" },
	{ did: "demo:bob",    name: "Bob" },
	{ did: "demo:carol",  name: "Carol" },
	{ did: "demo:dave",   name: "Dave" },
	{ did: "demo:eve",    name: "Eve" },
	{ did: "demo:frank",  name: "Frank" },
	{ did: "demo:grace",  name: "Grace" },
	{ did: "demo:heidi",  name: "Heidi" },
	{ did: "demo:ivan",   name: "Ivan" },
	{ did: "demo:judy",   name: "Judy" },
];

// Per-delegator color palette for flow bands
const DELEGATOR_PALETTE = [
	"#7c3aed", "#6366f1", "#8b5cf6", "#a855f7",
	"#c084fc", "#818cf8", "#6d28d9", "#4f46e5",
	"#7e22ce", "#5b21b6", "#4338ca", "#9333ea",
];

class FolkTrustSankey extends HTMLElement {
	private shadow: ShadowRoot;
	private space = "";
	private authority = "gov-ops";
	private flows: DelegationFlow[] = [];
	private events: TrustEvent[] = [];
	private loading = true;
	private error = "";
	private timeSliderValue = 100; // 0-100, percentage of history
	private animationEnabled = true;
	private hoveredFlowId: string | null = null;
	private hoveredNodeDid: string | null = null;
	private _demoTimer: ReturnType<typeof setInterval> | null = null;
	private _demoIdCounter = 0;

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

	private _delegationsHandler: ((e: Event) => void) | null = null;

	connectedCallback() {
		this.space = this.getAttribute("space") || "demo";
		this.authority = this.getAttribute("authority") || "gov-ops";
		this.render();

		if (this.space === "demo") {
			this.initDemoSimulation();
		} else {
			this.loadData();
			// Listen for cross-component sync
			this._delegationsHandler = () => this.loadData();
			document.addEventListener("delegations-updated", this._delegationsHandler);
		}
	}

	disconnectedCallback() {
		if (this._delegationsHandler) {
			document.removeEventListener("delegations-updated", this._delegationsHandler);
			this._delegationsHandler = null;
		}
		if (this._demoTimer) {
			clearInterval(this._demoTimer);
			this._demoTimer = null;
		}
	}

	private getAuthBase(): string {
		return this.getAttribute("auth-url") || "";
	}

	private getApiBase(): string {
		const path = window.location.pathname;
		const match = path.match(/^(\/[^/]+)?\/rnetwork/);
		return match ? match[0] : "";
	}

	private getAuthHeaders(): Record<string, string> {
		const token = localStorage.getItem("encryptid_session");
		if (!token) return {};
		return { Authorization: `Bearer ${token}` };
	}

	private async loadData() {
		const authBase = this.getAuthBase();
		const apiBase = this.getApiBase();

		try {
			// Fetch delegations (including revoked), user directory, and trust events in parallel
			const [delegRes, usersRes, eventsRes] = await Promise.all([
				fetch(`${authBase}/api/delegations/space?space=${encodeURIComponent(this.space)}&include_revoked=true`),
				fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`),
				fetch(`${authBase}/api/trust/events?space=${encodeURIComponent(this.space)}&limit=200`).catch(() => null),
			]);

			const allFlows: DelegationFlow[] = [];
			if (delegRes.ok) {
				const data = await delegRes.json();
				for (const d of data.delegations || []) {
					allFlows.push({
						id: d.id || "",
						fromDid: d.from,
						fromName: d.from.slice(0, 12) + "...",
						toDid: d.to,
						toName: d.to.slice(0, 12) + "...",
						authority: d.authority,
						weight: d.weight,
						state: d.state || "active",
						createdAt: d.createdAt || Date.now(),
						revokedAt: d.revokedAt || null,
					});
				}
			}
			this.flows = allFlows;

			// Resolve user display names
			if (usersRes.ok) {
				const userData = await usersRes.json();
				const nameMap = new Map<string, string>();
				for (const u of userData.users || []) {
					nameMap.set(u.did, u.displayName || u.username);
				}
				for (const f of this.flows) {
					if (nameMap.has(f.fromDid)) f.fromName = nameMap.get(f.fromDid)!;
					if (nameMap.has(f.toDid)) f.toName = nameMap.get(f.toDid)!;
				}
			}

			// Load trust events for sparklines
			if (eventsRes && eventsRes.ok) {
				const evtData = await eventsRes.json();
				this.events = evtData.events || [];
			}
		} catch {
			this.error = "Failed to load delegation data";
		}

		this.loading = false;
		this.render();
	}

	// ── Demo simulation ──────────────────────────────────────────

	private demoNextId(): string { return `demo-${++this._demoIdCounter}`; }

	private demoRand<T>(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; }

	/** Total outbound weight for a given did+authority (active only) */
	private demoOutboundWeight(did: string, authority: string): number {
		return this.flows
			.filter(f => f.fromDid === did && f.authority === authority && f.state === "active")
			.reduce((s, f) => s + f.weight, 0);
	}

	/** Generate initial demo delegations and start the mutation loop */
	private initDemoSimulation() {
		const now = Date.now();
		this.flows = [];
		this.events = [];

		// Seed ~12 initial delegations spread across all 3 verticals
		const authorities: string[] = ["gov-ops", "fin-ops", "dev-ops"];
		for (const auth of authorities) {
			// Each authority gets 3-5 initial delegations
			const count = 3 + Math.floor(Math.random() * 3);
			for (let i = 0; i < count; i++) {
				const from = this.demoRand(DEMO_MEMBERS);
				let to = this.demoRand(DEMO_MEMBERS);
				// No self-delegation
				while (to.did === from.did) to = this.demoRand(DEMO_MEMBERS);

				// Skip if this exact pair already exists for this authority
				if (this.flows.some(f => f.fromDid === from.did && f.toDid === to.did && f.authority === auth)) continue;

				// Weight between 0.05 and 0.40 — capped so total outbound stays <= 1.0
				const maxAvailable = 1.0 - this.demoOutboundWeight(from.did, auth);
				if (maxAvailable < 0.05) continue;
				const weight = Math.min(0.05 + Math.random() * 0.35, maxAvailable);

				const createdAt = now - Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // within last week
				this.flows.push({
					id: this.demoNextId(), fromDid: from.did, fromName: from.name,
					toDid: to.did, toName: to.name, authority: auth,
					weight: Math.round(weight * 100) / 100,
					state: "active", createdAt, revokedAt: null,
				});
				this.events.push({
					id: this.demoNextId(), sourceDid: from.did, targetDid: to.did,
					eventType: "delegate", authority: auth, weightDelta: weight, createdAt,
				});
			}
		}

		this.loading = false;
		this.render();

		// Mutate every 2.5–4s
		this._demoTimer = setInterval(() => this.demoTick(), 2500 + Math.random() * 1500);
	}

	/** Single mutation step — add, adjust, revoke, or reactivate a delegation */
	private demoTick() {
		const now = Date.now();
		const active = this.flows.filter(f => f.state === "active");
		const revoked = this.flows.filter(f => f.state === "revoked");
		const roll = Math.random();

		if (roll < 0.35 || active.length < 4) {
			// — Add a new delegation —
			const auth = this.demoRand(["gov-ops", "fin-ops", "dev-ops"] as string[]);
			const from = this.demoRand(DEMO_MEMBERS);
			let to = this.demoRand(DEMO_MEMBERS);
			let attempts = 0;
			while ((to.did === from.did || this.flows.some(f => f.fromDid === from.did && f.toDid === to.did && f.authority === auth && f.state === "active")) && attempts < 20) {
				to = this.demoRand(DEMO_MEMBERS);
				attempts++;
			}
			if (to.did === from.did) return; // couldn't find a valid pair

			const maxAvailable = 1.0 - this.demoOutboundWeight(from.did, auth);
			if (maxAvailable < 0.05) return;
			const weight = Math.min(0.05 + Math.random() * 0.30, maxAvailable);
			const rounded = Math.round(weight * 100) / 100;

			this.flows.push({
				id: this.demoNextId(), fromDid: from.did, fromName: from.name,
				toDid: to.did, toName: to.name, authority: auth,
				weight: rounded, state: "active", createdAt: now, revokedAt: null,
			});
			this.events.push({
				id: this.demoNextId(), sourceDid: from.did, targetDid: to.did,
				eventType: "delegate", authority: auth, weightDelta: rounded, createdAt: now,
			});

		} else if (roll < 0.65 && active.length > 0) {
			// — Adjust weight of an existing delegation —
			const flow = this.demoRand(active);
			const maxAvailable = 1.0 - this.demoOutboundWeight(flow.fromDid, flow.authority) + flow.weight;
			const delta = (Math.random() - 0.4) * 0.15; // slight bias toward increase
			const newWeight = Math.max(0.03, Math.min(maxAvailable, flow.weight + delta));
			const rounded = Math.round(newWeight * 100) / 100;
			const actualDelta = rounded - flow.weight;
			flow.weight = rounded;

			this.events.push({
				id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid,
				eventType: "adjust", authority: flow.authority, weightDelta: actualDelta, createdAt: now,
			});

		} else if (roll < 0.85 && active.length > 4) {
			// — Revoke a delegation —
			const flow = this.demoRand(active);
			flow.state = "revoked";
			flow.revokedAt = now;

			this.events.push({
				id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid,
				eventType: "revoke", authority: flow.authority, weightDelta: -flow.weight, createdAt: now,
			});

		} else if (revoked.length > 0) {
			// — Reactivate a revoked delegation —
			const flow = this.demoRand(revoked);
			const maxAvailable = 1.0 - this.demoOutboundWeight(flow.fromDid, flow.authority);
			if (maxAvailable < flow.weight) return; // can't fit
			flow.state = "active";
			flow.revokedAt = null;
			flow.createdAt = now; // reset so it shows as recent

			this.events.push({
				id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid,
				eventType: "delegate", authority: flow.authority, weightDelta: flow.weight, createdAt: now,
			});
		}

		// Cap events list to prevent unbounded growth
		if (this.events.length > 300) this.events = this.events.slice(-200);

		// Clear hover state to avoid stale references, then re-render
		this.hoveredFlowId = null;
		this.hoveredNodeDid = null;
		this.render();
	}

	// ── Data filtering ──────────────────────────────────────────

	private getFilteredFlows(): DelegationFlow[] {
		let filtered = this.flows.filter(f => f.authority === this.authority);

		// Time slider: revocation-aware filtering
		// Active at time T = createdAt <= T AND (state=active OR revokedAt > T)
		if (this.timeSliderValue < 100 && filtered.length > 0) {
			const times = filtered.map(f => f.createdAt).sort((a, b) => a - b);
			const earliest = times[0];
			const latest = Date.now();
			const cutoff = earliest + (latest - earliest) * (this.timeSliderValue / 100);
			filtered = filtered.filter(f =>
				f.createdAt <= cutoff &&
				(f.state === "active" || (f.revokedAt != null && f.revokedAt > cutoff))
			);
		} else {
			// At "Now" (100%), only show active flows
			filtered = filtered.filter(f => f.state === "active");
		}

		return filtered;
	}

	private renderSankey(): string {
		const flows = this.getFilteredFlows();
		if (flows.length === 0) {
			return `<div class="empty">No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.</div>`;
		}

		// --- True Sankey layout with stacked flow ports ---
		const MIN_BAND = 6;     // minimum band thickness in px
		const WEIGHT_SCALE = 80; // scale factor: weight 1.0 = 80px band
		const NODE_W = 14;
		const NODE_GAP = 12;    // vertical gap between nodes
		const LABEL_W = 110;    // space for text labels on each side
		const W = 620;
		const leftX = LABEL_W;
		const rightX = W - LABEL_W;

		// Collect unique delegators and delegates
		const delegators = [...new Set(flows.map(f => f.fromDid))];
		const delegates = [...new Set(flows.map(f => f.toDid))];

		// Assign colors per delegator
		const delegatorColor = new Map<string, string>();
		delegators.forEach((did, i) => {
			delegatorColor.set(did, DELEGATOR_PALETTE[i % DELEGATOR_PALETTE.length]);
		});

		// Compute node heights based on total weight
		const leftTotals = new Map<string, number>();
		const rightTotals = new Map<string, number>();
		for (const did of delegators) {
			leftTotals.set(did, flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0));
		}
		for (const did of delegates) {
			rightTotals.set(did, flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0));
		}

		const nodeHeight = (total: number) => Math.max(MIN_BAND, total * WEIGHT_SCALE);
		const bandThickness = (weight: number) => Math.max(MIN_BAND * 0.5, weight * WEIGHT_SCALE);

		// Compute total height needed for each side
		const leftTotalH = delegators.reduce((s, did) => s + nodeHeight(leftTotals.get(did)!), 0) + (delegators.length - 1) * NODE_GAP;
		const rightTotalH = delegates.reduce((s, did) => s + nodeHeight(rightTotals.get(did)!), 0) + (delegates.length - 1) * NODE_GAP;
		const H = Math.max(240, Math.max(leftTotalH, rightTotalH) + 60);

		// Position nodes: center the column vertically, stack nodes top-to-bottom
		const leftNodeY = new Map<string, number>(); // top-edge Y of each left node
		const rightNodeY = new Map<string, number>();

		let curY = (H - leftTotalH) / 2;
		for (const did of delegators) {
			leftNodeY.set(did, curY);
			curY += nodeHeight(leftTotals.get(did)!) + NODE_GAP;
		}
		curY = (H - rightTotalH) / 2;
		for (const did of delegates) {
			rightNodeY.set(did, curY);
			curY += nodeHeight(rightTotals.get(did)!) + NODE_GAP;
		}

		// --- Stacked port allocation ---
		// Track how much of each node's height has been consumed by flow connections
		const leftPortOffset = new Map<string, number>();   // cumulative offset from node top
		const rightPortOffset = new Map<string, number>();
		for (const did of delegators) leftPortOffset.set(did, 0);
		for (const did of delegates) rightPortOffset.set(did, 0);

		// Sort flows by delegator order then delegate order for consistent stacking
		const sortedFlows = [...flows].sort((a, b) => {
			const ai = delegators.indexOf(a.fromDid);
			const bi = delegators.indexOf(b.fromDid);
			if (ai !== bi) return ai - bi;
			return delegates.indexOf(a.toDid) - delegates.indexOf(b.toDid);
		});

		// Build flow band paths (filled bezier area between two curves)
		const flowBands: string[] = [];
		const flowTooltips: string[] = [];
		const particles: string[] = [];
		const midX = (leftX + NODE_W + rightX) / 2;

		for (const f of sortedFlows) {
			const thickness = bandThickness(f.weight);
			const color = delegatorColor.get(f.fromDid) || "#7c3aed";
			const gradRef = `url(#grad-${delegators.indexOf(f.fromDid)})`;

			// Left side: source port position
			const lTop = leftNodeY.get(f.fromDid)!;
			const lOffset = leftPortOffset.get(f.fromDid)!;
			const srcY1 = lTop + lOffset;
			const srcY2 = srcY1 + thickness;
			leftPortOffset.set(f.fromDid, lOffset + thickness);

			// Right side: target port position
			const rTop = rightNodeY.get(f.toDid)!;
			const rOffset = rightPortOffset.get(f.toDid)!;
			const tgtY1 = rTop + rOffset;
			const tgtY2 = tgtY1 + thickness;
			rightPortOffset.set(f.toDid, rOffset + thickness);

			const sx = leftX + NODE_W;
			const tx = rightX;
			const isHovered = this.hoveredFlowId === f.id;
			const opacity = this.hoveredFlowId ? (isHovered ? 0.85 : 0.15) : 0.55;

			// Filled bezier band: top curve left-to-right, bottom curve right-to-left
			const bandPath = [
				`M ${sx} ${srcY1}`,
				`C ${midX} ${srcY1}, ${midX} ${tgtY1}, ${tx} ${tgtY1}`,
				`L ${tx} ${tgtY2}`,
				`C ${midX} ${tgtY2}, ${midX} ${srcY2}, ${sx} ${srcY2}`,
				"Z",
			].join(" ");

			// Center-line path for particles
			const srcMid = (srcY1 + srcY2) / 2;
			const tgtMid = (tgtY1 + tgtY2) / 2;
			const centerPath = `M ${sx} ${srcMid} C ${midX} ${srcMid}, ${midX} ${tgtMid}, ${tx} ${tgtMid}`;

			const fromName = flows.find(fl => fl.fromDid === f.fromDid)?.fromName || f.fromDid.slice(0, 12);
			const toName = flows.find(fl => fl.toDid === f.toDid)?.toName || f.toDid.slice(0, 12);

			flowBands.push(`
				<path class="flow-band" data-flow-id="${f.id}" data-from-did="${f.fromDid}" data-to-did="${f.toDid}"
					data-orig-fill="${gradRef}" d="${bandPath}"
					fill="${gradRef}" opacity="${opacity}"
					stroke="${color}" stroke-width="0.5" stroke-opacity="0.3"/>
			`);

			// Invisible wider hit area for hover
			flowTooltips.push(`
				<path class="flow-hit" data-flow-id="${f.id}" data-from-did="${f.fromDid}" data-to-did="${f.toDid}"
					d="${centerPath}" fill="none" stroke="transparent" stroke-width="${Math.max(thickness, 12)}">
					<title>${this.esc(fromName)} → ${this.esc(toName)}: ${Math.round(f.weight * 100)}%</title>
				</path>
			`);

			// Weight label on band (only if thick enough)
			if (thickness >= 14) {
				const labelX = midX;
				const labelY = ((srcY1 + srcY2) / 2 + (tgtY1 + tgtY2) / 2) / 2 + 3;
				flowBands.push(`
					<text x="${labelX}" y="${labelY}" fill="#fff" font-size="9" font-weight="600"
						text-anchor="middle" opacity="${this.hoveredFlowId ? (isHovered ? 0.9 : 0.1) : 0.7}"
						pointer-events="none">${Math.round(f.weight * 100)}%</text>
				`);
			}

			// Animated particles along center-line
			if (this.animationEnabled) {
				const duration = 2.5 + Math.random() * 1.5;
				const delay = Math.random() * duration;
				const particleR = Math.max(1.5, thickness * 0.12);
				particles.push(`
					<circle r="${particleR}" fill="#fff" opacity="0.6">
						<animateMotion dur="${duration}s" begin="${delay}s" repeatCount="indefinite" path="${centerPath}"/>
					</circle>
				`);
			}
		}

		// Build per-delegator gradients
		const gradients = delegators.map((did, i) => {
			const color = delegatorColor.get(did)!;
			return `<linearGradient id="grad-${i}" x1="0%" y1="0%" x2="100%" y2="0%">
				<stop offset="0%" stop-color="${color}" stop-opacity="0.8"/>
				<stop offset="50%" stop-color="${color}" stop-opacity="0.5"/>
				<stop offset="100%" stop-color="${color}" stop-opacity="0.35"/>
			</linearGradient>`;
		}).join("");

		// --- Weight rankings: all unique people ranked by received trust ---
		const allDids = new Set<string>();
		flows.forEach(f => { allDids.add(f.fromDid); allDids.add(f.toDid); });
		const receivedWeight = new Map<string, number>();
		allDids.forEach(did => receivedWeight.set(did, 0));
		flows.forEach(f => receivedWeight.set(f.toDid, (receivedWeight.get(f.toDid) || 0) + f.weight));
		const ranked = [...receivedWeight.entries()].sort((a, b) => b[1] - a[1]);
		const rankOf = new Map<string, number>();
		ranked.forEach(([did], i) => rankOf.set(did, i + 1));

		// Name lookup helper
		const nameOf = (did: string, side: "from" | "to"): string => {
			const f = side === "from"
				? flows.find(fl => fl.fromDid === did)
				: flows.find(fl => fl.toDid === did);
			if (side === "from") return f?.fromName || did.slice(0, 8);
			return f?.toName || did.slice(0, 8);
		};

		// Left nodes (delegators)
		const leftNodes = delegators.map(did => {
			const y = leftNodeY.get(did)!;
			const total = leftTotals.get(did)!;
			const h = nodeHeight(total);
			const name = nameOf(did, "from");
			const color = delegatorColor.get(did)!;
			const midY = y + h / 2;
			const rank = rankOf.get(did) || 0;
			const recW = receivedWeight.get(did) || 0;
			return `
			<g class="sankey-node" data-node-did="${did}" style="cursor:pointer">
				<rect x="${leftX}" y="${y}" width="${NODE_W}" height="${h}" rx="3" fill="${color}" opacity="0.9"/>
				<text x="${leftX - 8}" y="${midY + 1}" fill="var(--rs-text-primary)" font-size="11" text-anchor="end" font-weight="600" dominant-baseline="middle">${this.esc(name)}</text>
				<text x="${leftX - 8}" y="${midY + 13}" fill="var(--rs-text-muted)" font-size="9" text-anchor="end">${Math.round(total * 100)}% out</text>
				${recW > 0 ? `<text x="${leftX - 8}" y="${midY + 23}" fill="#10b981" font-size="8" text-anchor="end">#${rank} (${Math.round(recW * 100)}% in)</text>` : ""}
			</g>`;
		}).join("");

		// Right nodes (delegates) — sorted by rank
		const rightNodes = delegates.map(did => {
			const y = rightNodeY.get(did)!;
			const total = rightTotals.get(did)!;
			const h = nodeHeight(total);
			const name = nameOf(did, "to");
			const midY = y + h / 2;
			const rank = rankOf.get(did) || 0;

			// Sparkline
			const sparkline = this.renderSparkline(did, 30);

			// Rank badge
			const rankColor = rank <= 3 ? "#fbbf24" : "#a78bfa";

			return `
			<g class="sankey-node" data-node-did="${did}" style="cursor:pointer">
				<rect x="${rightX}" y="${y}" width="${NODE_W}" height="${h}" rx="3" fill="#a78bfa" opacity="0.9"/>
				<text x="${rightX + NODE_W + 8}" y="${midY - 4}" fill="${rankColor}" font-size="9" text-anchor="start" font-weight="700">#${rank}</text>
				<text x="${rightX + NODE_W + 8}" y="${midY + 8}" fill="var(--rs-text-primary)" font-size="11" text-anchor="start" font-weight="600">${this.esc(name)}</text>
				<text x="${rightX + NODE_W + 8}" y="${midY + 20}" fill="var(--rs-text-muted)" font-size="9" text-anchor="start">${Math.round(total * 100)}% received</text>
				${sparkline ? `<g transform="translate(${rightX + NODE_W + 8}, ${midY + 24})">${sparkline}</g>` : ""}
			</g>`;
		}).join("");

		return `
			<svg width="100%" viewBox="0 0 ${W} ${H}" class="sankey-svg">
				<defs>${gradients}</defs>
				<g class="flow-layer">${flowBands.join("")}</g>
				<g class="hit-layer">${flowTooltips.join("")}</g>
				<g class="particle-layer">${particles.join("")}</g>
				<g class="left-nodes">${leftNodes}</g>
				<g class="right-nodes">${rightNodes}</g>
			</svg>
		`;
	}

	/** Render a tiny sparkline SVG (60x16) showing recent weight trend from trust events */
	private renderSparkline(did: string, days: number): string {
		const now = Date.now();
		const cutoff = now - days * 24 * 60 * 60 * 1000;

		// Prefer trust events with weightDelta for accurate sparklines
		const relevantEvents = this.events.filter(e =>
			e.targetDid === did &&
			(e.authority === this.authority || e.authority === null) &&
			e.createdAt > cutoff &&
			e.weightDelta != null
		);

		if (relevantEvents.length >= 2) {
			// Build cumulative weight from events
			const sorted = [...relevantEvents].sort((a, b) => a.createdAt - b.createdAt);
			const points: Array<{ t: number; w: number }> = [];
			let cumulative = 0;
			for (const evt of sorted) {
				cumulative += (evt.weightDelta || 0);
				points.push({ t: evt.createdAt, w: Math.max(0, cumulative) });
			}

			const w = 50, h = 12;
			const tMin = cutoff, tMax = now;
			const wMax = Math.max(...points.map(p => p.w), 0.01);

			const pathData = points.map((p, i) => {
				const x = ((p.t - tMin) / (tMax - tMin)) * w;
				const y = h - (p.w / wMax) * h;
				return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
			}).join(" ");

			return `<path d="${pathData}" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.6"/>`;
		}

		// Fallback: use delegation creation timestamps
		const relevant = this.flows.filter(f => f.toDid === did && f.authority === this.authority && f.createdAt > cutoff);
		if (relevant.length < 2) return "";

		const sorted = [...relevant].sort((a, b) => a.createdAt - b.createdAt);
		const points: Array<{ t: number; w: number }> = [];
		let cumulative = 0;
		for (const f of sorted) {
			cumulative += f.weight;
			points.push({ t: f.createdAt, w: cumulative });
		}

		const w = 50, h = 12;
		const tMin = cutoff, tMax = now;
		const wMax = Math.max(...points.map(p => p.w), 0.01);

		const pathData = points.map((p, i) => {
			const x = ((p.t - tMin) / (tMax - tMin)) * w;
			const y = h - (p.w / wMax) * h;
			return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
		}).join(" ");

		return `<path d="${pathData}" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.6"/>`;
	}

	private render() {
		this.shadow.innerHTML = `
		<style>
			:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
			* { box-sizing: border-box; }

			.sankey-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
			.sankey-title { font-size: 15px; font-weight: 600; }
			.demo-badge {
				font-size: 9px; font-weight: 700; letter-spacing: 0.06em;
				padding: 2px 8px; border-radius: 4px;
				background: rgba(239, 68, 68, 0.15); color: #ef4444;
				border: 1px solid rgba(239, 68, 68, 0.3);
				animation: demo-pulse 2s ease-in-out infinite;
			}
			@keyframes demo-pulse {
				0%, 100% { opacity: 1; }
				50% { opacity: 0.5; }
			}

			.authority-filter {
				display: flex; gap: 4px; flex-wrap: wrap;
			}
			.authority-btn {
				padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-input-border);
				background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer;
				font-size: 11px; text-transform: capitalize;
			}
			.authority-btn:hover { border-color: var(--rs-border-strong); }
			.authority-btn.active { border-color: #a78bfa; color: #a78bfa; background: rgba(167, 139, 250, 0.1); }

			.sankey-container {
				background: var(--rs-canvas-bg, #0a0a0f); border: 1px solid var(--rs-border);
				border-radius: 12px; padding: 16px; overflow-x: auto;
			}
			.sankey-svg { display: block; min-height: 200px; }
			.flow-band { transition: opacity 0.2s, fill 0.2s; cursor: pointer; }
			.flow-hit { cursor: pointer; }
			.sankey-node { transition: opacity 0.2s; }

			.time-slider {
				display: flex; align-items: center; gap: 10px; margin-top: 12px;
			}
			.time-label { font-size: 11px; color: var(--rs-text-muted); min-width: 80px; }
			.time-range { flex: 1; accent-color: #a78bfa; }
			.time-value { font-size: 11px; color: var(--rs-text-muted); min-width: 30px; text-align: right; }

			.controls { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
			.toggle-btn {
				padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-input-border);
				background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer; font-size: 11px;
			}
			.toggle-btn.active { border-color: #a78bfa; color: #a78bfa; }

			.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; font-size: 13px; }
			.loading { text-align: center; color: var(--rs-text-muted); padding: 40px; font-size: 13px; }

			.legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; }
			.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--rs-text-muted); }
			.legend-dot { width: 10px; height: 4px; border-radius: 2px; }
		</style>

		<div class="sankey-header">
			<span class="sankey-title">Delegation Flows</span>
			${this.space === "demo" ? `<span class="demo-badge">LIVE DEMO</span>` : ""}
			<div class="authority-filter">
				${SANKEY_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${SANKEY_AUTHORITY_DISPLAY[a]?.label || a}</button>`).join("")}
			</div>
		</div>

		${this.loading ? `<div class="loading">Loading flows...</div>` : `
			<div class="sankey-container">
				${this.renderSankey()}
			</div>

			<div class="time-slider">
				<span class="time-label">History:</span>
				<input type="range" class="time-range" id="time-slider" min="0" max="100" value="${this.timeSliderValue}">
				<span class="time-value" id="time-value">${this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%"}</span>
			</div>

			<div class="controls">
				<button class="toggle-btn ${this.animationEnabled ? "active" : ""}" id="toggle-animation">
					${this.animationEnabled ? "Pause" : "Play"} particles
				</button>
			</div>

			<div class="legend">
				<div class="legend-item"><span class="legend-dot" style="background:linear-gradient(90deg,#7c3aed,#a78bfa);width:24px"></span> Band width = weight</div>
				<div class="legend-item"><span class="legend-dot" style="background:#10b981"></span> Trust received (hover)</div>
				<div class="legend-item"><span class="legend-dot" style="background:#f59e0b"></span> Trust given (hover)</div>
			</div>
		`}
		`;

		this.attachListeners();
	}

	private attachListeners() {
		// Authority filter
		this.shadow.querySelectorAll("[data-authority]").forEach(el => {
			el.addEventListener("click", () => {
				this.authority = (el as HTMLElement).dataset.authority!;
				this.render();
			});
		});

		// Time slider
		this.shadow.getElementById("time-slider")?.addEventListener("input", (e) => {
			this.timeSliderValue = parseInt((e.target as HTMLInputElement).value);
			const label = this.shadow.getElementById("time-value");
			if (label) label.textContent = this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%";
			// Debounce re-render
			clearTimeout((this as any)._sliderTimer);
			(this as any)._sliderTimer = setTimeout(() => this.render(), 100);
		});

		// Toggle animation
		this.shadow.getElementById("toggle-animation")?.addEventListener("click", () => {
			this.animationEnabled = !this.animationEnabled;
			this.render();
		});

		// Flow band hover — highlight individual flows (only when no node is hovered)
		this.shadow.querySelectorAll(".flow-band, .flow-hit").forEach(el => {
			el.addEventListener("mouseenter", () => {
				if (this.hoveredNodeDid) return; // node hover takes priority
				const id = (el as HTMLElement).dataset.flowId;
				if (id && this.hoveredFlowId !== id) {
					this.hoveredFlowId = id;
					this.updateHighlights();
				}
			});
			el.addEventListener("mouseleave", () => {
				if (this.hoveredNodeDid) return;
				if (this.hoveredFlowId) {
					this.hoveredFlowId = null;
					this.updateHighlights();
				}
			});
		});

		// Node hover — highlight inbound (teal) vs outbound (amber) flows
		this.shadow.querySelectorAll(".sankey-node").forEach(el => {
			el.addEventListener("mouseenter", () => {
				const did = (el as HTMLElement).dataset.nodeDid;
				if (did && this.hoveredNodeDid !== did) {
					this.hoveredNodeDid = did;
					this.hoveredFlowId = null;
					this.updateHighlights();
				}
			});
			el.addEventListener("mouseleave", () => {
				if (this.hoveredNodeDid) {
					this.hoveredNodeDid = null;
					this.updateHighlights();
				}
			});
		});
	}

	// Inbound = trust flowing INTO the hovered node (teal)
	// Outbound = trust flowing FROM the hovered node (amber)
	private static readonly COLOR_INBOUND = "#10b981";
	private static readonly COLOR_OUTBOUND = "#f59e0b";

	/** Update all flow band visual states for hover (node or flow) */
	private updateHighlights() {
		const hNode = this.hoveredNodeDid;
		const hFlow = this.hoveredFlowId;
		const active = !!(hNode || hFlow);

		this.shadow.querySelectorAll(".flow-band").forEach(band => {
			const el = band as SVGElement;
			const ds = (band as HTMLElement).dataset;
			const origFill = ds.origFill || "";

			if (hNode) {
				// Node hover mode: color by direction
				const isInbound = ds.toDid === hNode;
				const isOutbound = ds.fromDid === hNode;
				if (isInbound) {
					el.setAttribute("fill", FolkTrustSankey.COLOR_INBOUND);
					el.setAttribute("opacity", "0.8");
					el.setAttribute("stroke", FolkTrustSankey.COLOR_INBOUND);
				} else if (isOutbound) {
					el.setAttribute("fill", FolkTrustSankey.COLOR_OUTBOUND);
					el.setAttribute("opacity", "0.8");
					el.setAttribute("stroke", FolkTrustSankey.COLOR_OUTBOUND);
				} else {
					el.setAttribute("fill", origFill);
					el.setAttribute("opacity", "0.08");
					el.setAttribute("stroke", "none");
				}
			} else if (hFlow) {
				// Single flow hover mode
				el.setAttribute("fill", origFill);
				el.setAttribute("opacity", ds.flowId === hFlow ? "0.85" : "0.15");
			} else {
				// No hover — restore defaults
				el.setAttribute("fill", origFill);
				el.setAttribute("opacity", "0.55");
			}
		});

		// Dim/show band weight labels
		this.shadow.querySelectorAll(".flow-layer text").forEach(t => {
			(t as SVGElement).setAttribute("opacity", active ? "0.1" : "0.7");
		});

		// Highlight/dim node groups — keep connected nodes visible
		if (hNode) {
			// Build set of DIDs connected to the hovered node
			const connectedDids = new Set<string>([hNode]);
			this.shadow.querySelectorAll(".flow-band").forEach(band => {
				const ds = (band as HTMLElement).dataset;
				if (ds.fromDid === hNode) connectedDids.add(ds.toDid || "");
				if (ds.toDid === hNode) connectedDids.add(ds.fromDid || "");
			});
			this.shadow.querySelectorAll(".sankey-node").forEach(g => {
				const did = (g as HTMLElement).dataset.nodeDid || "";
				if (did === hNode) {
					(g as SVGElement).style.opacity = "1";
				} else if (connectedDids.has(did)) {
					(g as SVGElement).style.opacity = "0.85";
				} else {
					(g as SVGElement).style.opacity = "0.3";
				}
			});
		} else {
			this.shadow.querySelectorAll(".sankey-node").forEach(g => {
				(g as SVGElement).style.opacity = "1";
			});
		}
	}

	private esc(s: string): string {
		const d = document.createElement("div");
		d.textContent = s || "";
		return d.innerHTML;
	}
}

customElements.define("folk-trust-sankey", FolkTrustSankey);
